diff --git a/.changeset/olive-bobcats-happen.md b/.changeset/olive-bobcats-happen.md new file mode 100644 index 0000000000..73780308f8 --- /dev/null +++ b/.changeset/olive-bobcats-happen.md @@ -0,0 +1,5 @@ +--- +'@redocly/cli': patch +--- + +Improved CLI install speed by bundling the CLI into a dependency-free package. diff --git a/.dockerignore b/.dockerignore index 0593c83433..4480b16f8f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,5 +3,7 @@ !tsconfig.build.json !tsconfig.json !package-lock.json +!README.md +!LICENSE.md !packages/* !scripts/local-pack.sh diff --git a/.github/workflows/performance.yaml b/.github/workflows/performance.yaml index d17fd79f01..6efc80dcce 100644 --- a/.github/workflows/performance.yaml +++ b/.github/workflows/performance.yaml @@ -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 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0a815cf2cf..dc098affcb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 @@ -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) @@ -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 diff --git a/.github/workflows/smoke.yaml b/.github/workflows/smoke.yaml index 047b1fc0e7..fccc03cbf0 100644 --- a/.github/workflows/smoke.yaml +++ b/.github/workflows/smoke.yaml @@ -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 diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4e7e9b8db7..85d1ee7cc3 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -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 diff --git a/.gitignore b/.gitignore index 0163899902..b52b76e841 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile index 29976eed7d..cd8993c80b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/package.json b/package.json index 33443f6396..3b6ea1f67a 100644 --- a/package.json +++ b/package.json @@ -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 .", @@ -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" diff --git a/packages/cli/package.json b/packages/cli/package.json index 473c35ad0e..1411de76da 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/scripts/compile-bundle.mjs b/packages/cli/scripts/compile-bundle.mjs new file mode 100644 index 0000000000..c2bf7cd329 --- /dev/null +++ b/packages/cli/scripts/compile-bundle.mjs @@ -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); + } +} diff --git a/packages/cli/scripts/prepare-publish-dir.mjs b/packages/cli/scripts/prepare-publish-dir.mjs new file mode 100644 index 0000000000..605539573e --- /dev/null +++ b/packages/cli/scripts/prepare-publish-dir.mjs @@ -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` +); diff --git a/packages/cli/src/commands/build-docs/index.ts b/packages/cli/src/commands/build-docs/index.ts index f63bb393aa..83e7c79c55 100644 --- a/packages/cli/src/commands/build-docs/index.ts +++ b/packages/cli/src/commands/build-docs/index.ts @@ -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, @@ -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); @@ -43,7 +39,7 @@ export const handlerBuildCommand = async ({ const pageHTML = await getPageHTML( api, pathToApi, - { ...options, redocCurrentVersion }, + { ...options, redocCurrentVersion: redocVersion }, argv.config ); diff --git a/packages/cli/src/commands/build-docs/utils.ts b/packages/cli/src/commands/build-docs/utils.ts index 1838cd15de..a0fbd8fd29 100644 --- a/packages/cli/src/commands/build-docs/utils.ts +++ b/packages/cli/src/commands/build-docs/utils.ts @@ -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 = ` + + + + + {{title}} + + + + {{{redocHead}}} + {{#unless disableGoogleFont}}{{/unless}} + + + + {{{redocHTML}}} + + + +`; export function getObjectOrJSON( openapiOptions: string | Record, @@ -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: `
${html || ''}
@@ -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)); } diff --git a/packages/cli/src/utils/package.ts b/packages/cli/src/utils/package.ts index 0c44c1fcab..d928c18279 100644 --- a/packages/cli/src/utils/package.ts +++ b/packages/cli/src/utils/package.ts @@ -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; diff --git a/scripts/local-pack.sh b/scripts/local-pack.sh index b1870f5b0c..516ec92e0a 100755 --- a/scripts/local-pack.sh +++ b/scripts/local-pack.sh @@ -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 @@ -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 \ No newline at end of file diff --git a/scripts/release-publish.mjs b/scripts/release-publish.mjs new file mode 100644 index 0000000000..74f601e7f6 --- /dev/null +++ b/scripts/release-publish.mjs @@ -0,0 +1,46 @@ +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 rootDir = path.resolve(scriptDir, '..'); + +function run(command, args, { allowFailure = false } = {}) { + const result = spawnSync(command, args, { + cwd: rootDir, + stdio: 'inherit', + }); + + if (!allowFailure) { + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + throw new Error( + `${command} exited with status ${result.status ?? 1}: ${args.join(' ')}`, + ); + } + } + + return result; +} + +let publishFailed = false; + +try { + run('npm', ['--workspace', 'packages/cli', 'run', 'prepare:publish-dir']); + // Changesets reads `publishConfig.directory` from the package manifest + // and publishes `packages/cli/.publish` instead of the workspace root. + run('npm', ['exec', 'changeset', 'publish']); +} catch { + publishFailed = true; +} finally { + run('npm', ['--workspace', 'packages/cli', 'run', 'clean:publish-dir'], { + allowFailure: true, + }); +} + +if (publishFailed) { + process.exit(1); +} diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index bf03ef7a0f..e2cac1dc98 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -50,7 +50,5 @@ export function cleanupOutput(message: string) { const cwdRegexp = new RegExp(process.cwd(), 'g'); const cleanedFromCwd = message.replace(cwdRegexp, '.'); - const cleanedFromVersion = cleanUpVersion(cleanedFromCwd); - - return cleanedFromVersion; + return cleanUpVersion(cleanedFromCwd); } diff --git a/tests/smoke/basic/pre-built/redoc.html b/tests/smoke/basic/pre-built/redoc.html index 1c02773fc7..a3880f16d0 100644 --- a/tests/smoke/basic/pre-built/redoc.html +++ b/tests/smoke/basic/pre-built/redoc.html @@ -311,7 +311,7 @@ " class="sc-iKGpAq sc-cCYyou sc-cjERFZ dXXcln fTBBlJ dkmSdy">

OK

Response samples

Content type
application/json
{
  • "message": "string"
}
+

Response samples

Content type
application/json
{
  • "message": "string"
}