Skip to content

perf(build): bundle CLI entry point with esbuild#3108

Open
StoneCypher wants to merge 1 commit into
TypeStrong:masterfrom
StoneCypher:perf_26-05-26_bundle-cli
Open

perf(build): bundle CLI entry point with esbuild#3108
StoneCypher wants to merge 1 commit into
TypeStrong:masterfrom
StoneCypher:perf_26-05-26_bundle-cli

Conversation

@StoneCypher

Copy link
Copy Markdown
Contributor

What

Adds a `build:bundle` step that produces `dist/lib/cli.bundled.js` — a single-file bundle of typedoc's CLI entry path, produced by esbuild. `bin/typedoc` (kept as CommonJS to honor `bin/package.json`'s `"type": "commonjs"` override) now prefers the bundle when present and falls back to the unbundled `dist/lib/cli.js` otherwise. The unbundled `dist/` tree stays in place so plugin imports keep working.

Why

Profiling typedoc cold-start attributes ~440 ms of self-time across `detectModuleFormat`, `internalModuleStat`, `getPackageScopeConfig`, and `cjs-module-lexer` parsing — Node's per-file ESM/CJS resolution machinery touching every typedoc-owned JS module. A single-file bundle collapses those resolutions into one file load. Plugins import from `typedoc` (and deep paths under `typedoc/dist/...`) which still resolve through normal Node machinery against the unbundled tree, so the bundle is a CLI-startup optimization only and not a packaging change.

How

This was nearly an embarrassment. Naively running esbuild with `--bundle --packages=external --format=esm` produces a bundle that looks like it works (`bin/typedoc --version` prints "TypeDoc 0.28.19") but is actually broken — typedoc uses `import.meta.url` in 3 places to compute source-relative paths (`loadTranslations`, `TYPEDOC_ROOT`, `AssetsPlugin`), and after bundling every `import.meta.url` collapses to `cli.bundled.js`. Translation lookups then recurse infinitely, the project root becomes wrong, and theme asset paths break.

The fix is in `scripts/build_bundle.js`: a small esbuild API script with an `onLoad` plugin that rewrites `import.meta.url` per-module to a string literal of each module's original file URL (`pathToFileURL(args.path).href`). That preserves the source-relative semantics the unbundled tree depends on. `--showConfig` (which exercises i18n and `TYPEDOC_ROOT`) now runs cleanly through the bundle.

Perf impact

Estimated ~440 ms one-time cold-start reduction per CLI invocation, on machines with cold filesystem cache. No effect on subsequent phases (conversion, render). Most visible after `pnpm install` or after a reboot when the FS cache is empty. CI measurements will land in this description after a confirmation run.

Bundle size: 1,046,753 bytes (1022 KB). Runtime deps (`@gerrit0/mini-shiki`, `lunr`, `markdown-it`, `minimatch`, `yaml`, `typescript` peer) stay external.

Test plan

  • `bin/typedoc --version` works through the bundle
  • `bin/typedoc --help` works
  • `bin/typedoc --showConfig` runs cleanly (no i18n / path resolution errors — proves the `import.meta.url`-rewriting plugin holds)
  • Fallback to unbundled `dist/lib/cli.js` still works when the bundle is absent
  • ESLint, dprint clean
  • CI: full mocha suite green across Node versions (the bundle isn't exercised by tests directly; it's the entrypoint for CLI users)

Risks worth a reviewer's attention

  1. Plugin authors: plugins import from `typedoc` and `typedoc/dist/...`. Those resolutions go through normal Node machinery against the unbundled tree, which is unchanged. Plugin behavior should be identical. If a maintainer can confirm one or two well-known typedoc plugins still load against a bundle-using `bin/typedoc`, that would be ideal.
  2. String replacement of `import.meta.url` in the plugin is plain (`replaceAll("import.meta.url", ...)`) — could theoretically rewrite the same substring inside an unrelated comment or string literal. In practice the rewrite produces a valid URL literal so a benign over-rewrite is harmless, but a stricter regex-anchored rewrite (`/\bimport.meta.url\b/g`) would be marginally safer. Happy to tighten if preferred.
  3. bin/typedoc is now CJS (it was already, but I deliberately kept it CJS after the implementer tried ESM and Node silently parsed-failed under the `bin/package.json` `"type": "commonjs"` override). Worth a maintainer eyeball.

Rollback

Revert this PR. The unbundled `dist/lib/cli.js` continues to work as before; `bin/typedoc`'s existence check falls back automatically when no `cli.bundled.js` is present.

Stack context

Part of the typedoc speedup stack tracked by #3103. Group B PR — independent of the async-git stack (#3104) and the other Group B PRs (#3105 page writes, #3106 caches, #3107 JSX string buffer).

Blocks #3103

Adds a build:bundle step that uses esbuild to produce dist/lib/cli.bundled.js
from the compiled dist/lib/cli.js, collapsing typedoc internal module
resolution at cold start while keeping node_modules dependencies external
(so plugins and the unbundled cli.js fallback continue to work).

bin/typedoc now prefers cli.bundled.js when present and falls back to
cli.js otherwise. A small esbuild plugin in scripts/build_bundle.js
rewrites per-module import.meta.url so source-relative path lookups
(locales, TYPEDOC_ROOT, theme assets) continue to resolve correctly
inside the bundle. tsconfig.bundle.json builds the source without the
test tree so the bundle input compiles cleanly.
@Gerrit0

Gerrit0 commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Unfortunately, as CI is hinting, this is still subtly broken. Bundling is a good idea, but typedoc needs to be bundled in a way that only one copy of the files exist on disc to avoid issues where there are multiple instances of the library loaded (breaks instanceof for plugins, among other things)

Thankfully, we (mostly) don't need to worry about plugins importing deep paths because TypeDoc uses the exports key in package.json to specify which paths may be imported, and very few paths are in that list.

I think to make this fully work there needs to be a few bundles:

  1. models -- src/models/index.ts
  2. browser-utils -- src/browser-utils.ts, imports models
  3. lib -- src/index.ts - imports the models and browser-utils folders

The cli can then be implemented by importing the lib bundle, and typedoc will only need 4 files instead of the 160ish it currently uses. Not quite as good as 1 file, but it's still a major improvement.

Ideally I'd like to have only a single copy of the built source, so it's potentially time to enable noEmit permanently in tsconfig and just use esbuild's built bundle for everything.

@StoneCypher

Copy link
Copy Markdown
Contributor Author

ok, happy to make the change

don't entirely understand yet so i might need a couple of tries, patience is appreciated

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants