Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/olive-bobcats-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@redocly/cli': patch
---

Improved CLI install speed by bundling the CLI into a dependency-free package.
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@
!tsconfig.build.json
!tsconfig.json
!package-lock.json
!README.md
!LICENSE.md
!packages/*
!scripts/local-pack.sh
1 change: 1 addition & 0 deletions .github/workflows/performance.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
with:
node-version: 24
cache: npm
- uses: oven-sh/setup-bun@v2
- name: Install Dependencies
run: npm ci
- name: Install External
Expand Down
11 changes: 10 additions & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ jobs:
- name: Install Dependencies
run: npm ci

- name: Setup Bun
uses: oven-sh/setup-bun@v2

- name: Create Release Pull Request or Publish to npm
id: changesets
uses: RomanHotsiy/changesets-action@v1
Expand Down Expand Up @@ -200,6 +203,9 @@ jobs:
cache: npm
registry-url: https://registry.npmjs.org

- name: Setup Bun
uses: oven-sh/setup-bun@v2

- name: Update package versions
run: |
TIMESTAMP=$(date +%s)
Expand Down Expand Up @@ -255,4 +261,7 @@ jobs:
sleep 10

cd ../cli
npm publish --tag snapshot
PUBLISH_DIR=$(jq -r '.publishConfig.directory // ".publish"' package.json)
npm run prepare:publish-dir
npm publish "./$PUBLISH_DIR" --tag snapshot
npm run clean:publish-dir
1 change: 1 addition & 0 deletions .github/workflows/smoke.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
with:
node-version: 24
cache: npm
- uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: npm ci
- name: Prepare Smoke
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ jobs:
continue-on-error: true # Do not fail if there is an error during reporting
uses: davelosert/vitest-coverage-report-action@v2

- name: Setup Bun
uses: oven-sh/setup-bun@v2

- name: E2E Tests
run: npm run e2e

Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ output/
*.tsbuildinfo
*.tgz
redoc-static.html
packages/cli/README.md
packages/cli/.publish/
.env
packages/respect-core/src/modules/runtime-expressions/abnf-parser.js
2 changes: 0 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ RUN apk add --no-cache jq git && \
npm run prepare && \
npm run pack:prepare && \
npm install --global redocly-cli.tgz && \
cp packages/cli/src/commands/build-docs/template.hbs \
/usr/local/lib/node_modules/@redocly/cli/lib/commands/build-docs/ && \
# Clean up to reduce image size
npm cache clean --force && rm -rf /build

Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"scripts": {
"test": "npm run compile && npm run typecheck && npm run unit && npm run e2e",
"unit": "VITEST_SUITE=unit vitest run",
"e2e": "VITEST_SUITE=e2e vitest run",
"e2e": "npm run compile:cli:bundle && VITEST_SUITE=e2e vitest run",
"smoke:rebilly": "VITEST_SUITE=smoke-rebilly vitest run",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
Expand All @@ -24,8 +24,9 @@
"prepare": "husky && npm run compile",
"cli": "node --import tsx packages/cli/src/index.ts",
"precli": "npm run compile",
"release": "changeset publish",
"release": "node ./scripts/release-publish.mjs",
"pack:prepare": "./scripts/local-pack.sh",
"compile:cli:bundle": "npm --workspace packages/cli run compile",
"respect:parser:generate": "pegjs --format es --output packages/respect-core/lib/modules/runtime-expressions/abnf-parser.js packages/respect-core/src/modules/runtime-expressions/abnf-parser.pegjs && cp packages/respect-core/lib/modules/runtime-expressions/abnf-parser.js packages/respect-core/src/modules/runtime-expressions/abnf-parser.js",
"build-docs:copy-assets": "cp packages/cli/src/commands/build-docs/template.hbs packages/cli/lib/commands/build-docs/template.hbs ",
"json-server": "json-server --watch tests/e2e/respect/local-json-server/fake-db.json --port 3000 --host 0.0.0.0"
Expand Down
10 changes: 6 additions & 4 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
},
"engineStrict": true,
"scripts": {
"compile": "tsc",
"copy-assets": "cp src/commands/build-docs/template.hbs lib/commands/build-docs/template.hbs ",
"prepack": "npm run copy-assets",
"prepublishOnly": "npm run copy-assets && cp ../../README.md ."
"compile": "node ./scripts/compile-bundle.mjs",
"prepare:publish-dir": "node ./scripts/prepare-publish-dir.mjs",
"clean:publish-dir": "rm -rf .publish"
},
"publishConfig": {
"directory": ".publish"
},
"repository": {
"type": "git",
Expand Down
49 changes: 49 additions & 0 deletions packages/cli/scripts/compile-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const packageDir = path.resolve(scriptDir, '..');
const bunBuildArgs = [
'build',
'src/index.ts',
'--target',
'node',
'--define',
'process.env.NODE_ENV=process.env.NODE_ENV',
'--outfile',
'lib/index.js',
];

export function compileCliBundle() {
const directBun = spawnSync('bun', bunBuildArgs, {
cwd: packageDir,
stdio: 'inherit',
});

if (!directBun.error) {
return directBun;
}

if (directBun.error.code !== 'ENOENT') {
throw directBun.error;
}

console.warn('Bun binary not found on PATH; falling back to `npx bun@1.3.10`.');
return spawnSync('npx', ['--yes', 'bun@1.3.10', ...bunBuildArgs], {
cwd: packageDir,
stdio: 'inherit',
});
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const compileResult = compileCliBundle();

if (compileResult.error) {
throw compileResult.error;
}

if (compileResult.status !== 0) {
process.exit(compileResult.status ?? 1);
}
}
64 changes: 64 additions & 0 deletions packages/cli/scripts/prepare-publish-dir.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { compileCliBundle } from './compile-bundle.mjs';

const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const packageDir = path.resolve(scriptDir, '..');
const rootDir = path.resolve(packageDir, '..', '..');

const packageJsonPath = path.join(packageDir, 'package.json');
const readmeSourcePath = path.join(rootDir, 'README.md');
const licenseSourcePath = path.join(rootDir, 'LICENSE.md');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
const publishDirName = packageJson.publishConfig?.directory ?? '.publish';
const publishDir = path.join(packageDir, publishDirName);

const compileResult = compileCliBundle();

if (compileResult.error) {
throw compileResult.error;
}

if (compileResult.status !== 0) {
process.exit(compileResult.status ?? 1);
}

rmSync(publishDir, { recursive: true, force: true });
mkdirSync(publishDir, { recursive: true });

const binEntrypoint = packageJson.bin?.redocly ?? 'bin/cli.js';
const binTargetPath = path.join(publishDir, binEntrypoint);

mkdirSync(path.dirname(binTargetPath), { recursive: true });
mkdirSync(path.join(publishDir, 'lib'), { recursive: true });

copyFileSync(path.join(packageDir, binEntrypoint), binTargetPath);
copyFileSync(path.join(packageDir, 'lib/index.js'), path.join(publishDir, 'lib/index.js'));
copyFileSync(readmeSourcePath, path.join(publishDir, 'README.md'));
copyFileSync(licenseSourcePath, path.join(publishDir, 'LICENSE'));

const publishPackageJson = {
name: packageJson.name,
version: packageJson.version,
description: packageJson.description,
license: packageJson.license,
type: packageJson.type,
repository: packageJson.repository,
homepage: packageJson.homepage,
keywords: packageJson.keywords,
contributors: packageJson.contributors,
engines: packageJson.engines,
engineStrict: packageJson.engineStrict,
bin: {
redocly: binEntrypoint,
openapi: binEntrypoint,
},
files: [binEntrypoint, 'lib/index.js', 'README.md', 'LICENSE'],
};

writeFileSync(
path.join(publishDir, 'package.json'),
`${JSON.stringify(publishPackageJson, null, 2)}\n`
);
8 changes: 2 additions & 6 deletions packages/cli/src/commands/build-docs/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import { isAbsoluteUrl, logger } from '@redocly/openapi-core';
import { writeFileSync, mkdirSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { performance } from 'node:perf_hooks';
import { default as redoc } from 'redoc';

import { exitWithError } from '../../utils/error.js';
import { getExecutionTime, getFallbackApisOrExit } from '../../utils/miscellaneous.js';
import { redocVersion } from '../../utils/package.js';
import type { CommandArgs } from '../../wrapper.js';
import type { BuildDocsArgv } from './types.js';
import { getObjectOrJSON, getPageHTML } from './utils.js';

const packageJson = createRequire(import.meta.url ?? __dirname)('../../../package.json');

export const handlerBuildCommand = async ({
argv,
config,
Expand All @@ -31,8 +29,6 @@ export const handlerBuildCommand = async ({
redocOptions: getObjectOrJSON(argv.theme?.openapi, config.forAlias(alias)),
};

const redocCurrentVersion = packageJson.dependencies.redoc;

try {
const elapsed = getExecutionTime(startedAt);

Expand All @@ -43,7 +39,7 @@ export const handlerBuildCommand = async ({
const pageHTML = await getPageHTML(
api,
pathToApi,
{ ...options, redocCurrentVersion },
{ ...options, redocCurrentVersion: redocVersion },
argv.config
);

Expand Down
44 changes: 42 additions & 2 deletions packages/cli/src/commands/build-docs/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,31 @@ import type { BuildDocsOptions } from './types.js';
const __internalDirname = import.meta.url
? path.dirname(url.fileURLToPath(import.meta.url))
: __dirname;
const DEFAULT_TEMPLATE_FILE_NAME = path.join(__internalDirname, './template.hbs');
const DEFAULT_TEMPLATE_SOURCE = `<!DOCTYPE html>
<html>

<head>
<meta charset="utf8" />
<title>{{title}}</title>
<!-- needed for adaptive design -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
padding: 0;
margin: 0;
}
</style>
{{{redocHead}}}
{{#unless disableGoogleFont}}<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">{{/unless}}
</head>

<body>
{{{redocHTML}}}
</body>

</html>
`;

export function getObjectOrJSON(
openapiOptions: string | Record<string, unknown>,
Expand Down Expand Up @@ -75,8 +100,10 @@ export async function getPageHTML(
? templateFileName
: redocOptions?.htmlTemplate
? path.resolve(configPath ? path.dirname(configPath) : '', redocOptions.htmlTemplate)
: path.join(__internalDirname, './template.hbs');
const template = handlebars.compile(readFileSync(templateFileName).toString());
: DEFAULT_TEMPLATE_FILE_NAME;

const templateSource = resolveTemplateSource(templateFileName);
const template = handlebars.compile(templateSource);
return template({
redocHTML: `
<div id="redoc">${html || ''}</div>
Expand All @@ -96,6 +123,19 @@ export async function getPageHTML(
});
}

function resolveTemplateSource(templateFileName: string): string {
if (templateFileName !== DEFAULT_TEMPLATE_FILE_NAME) {
return readFileSync(templateFileName, 'utf-8');
}

try {
return readFileSync(templateFileName, 'utf-8');
} catch {
// Bun bundling may not include template.hbs as a real file; fallback to the embedded template.
return DEFAULT_TEMPLATE_SOURCE;
}
}

export function sanitizeJSONString(str: string): string {
return escapeClosingScriptTag(escapeUnicode(str));
}
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/utils/package.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createRequire } from 'node:module';

const packageJson = createRequire(import.meta.url ?? __dirname)('../../package.json');
import packageJson from '../../package.json' with { type: 'json' };

export const { version, name, engines } = packageJson;
export const redocVersion = packageJson.dependencies.redoc;
9 changes: 4 additions & 5 deletions scripts/local-pack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
# Backup package.json files
cp packages/core/package.json packages/core/package.json.bak
cp packages/respect-core/package.json packages/respect-core/package.json.bak
cp packages/cli/package.json packages/cli/package.json.bak

# Build and pack core package
cd packages/core
Expand All @@ -20,13 +19,13 @@ cd ../../

# Update and pack cli package
cd packages/cli
jq '.dependencies["@redocly/openapi-core"] = "./openapi-core.tgz"' package.json > tmp.json && mv tmp.json package.json
jq '.dependencies["@redocly/respect-core"] = "./respect-core.tgz"' package.json > tmp.json && mv tmp.json package.json
cli=$(npm pack | tail -n 1)
publish_dir=$(jq -r '.publishConfig.directory // ".publish"' package.json)
npm run prepare:publish-dir
cli=$(npm pack "./$publish_dir" | tail -n 1)
npm run clean:publish-dir
mv $cli ../../redocly-cli.tgz
cd ../../

# Restore original package.json files
mv packages/core/package.json.bak packages/core/package.json
mv packages/respect-core/package.json.bak packages/respect-core/package.json
mv packages/cli/package.json.bak packages/cli/package.json
Loading
Loading