Skip to content

👷 migrate module builds from TypeScript compiler API to tsdown#4767

Open
thomas-lebeau wants to merge 9 commits into
mainfrom
worktree-migrate-build-to-tsdown
Open

👷 migrate module builds from TypeScript compiler API to tsdown#4767
thomas-lebeau wants to merge 9 commits into
mainfrom
worktree-migrate-build-to-tsdown

Conversation

@thomas-lebeau

@thomas-lebeau thomas-lebeau commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Motivation

The TypeScript compiler API (ts.createProgram) used for building package modules is slow and verbose. This was called out in a TODO comment in build-package.ts:

TODO: in the future, consider building packages with something else than typescript (ex: rspack, tsdown...)

This migrates module transpilation to tsdown (backed by Rolldown/Rust).

Changes

  • Transpile package modules (CJS + ESM) with tsdown instead of ts.createProgram/program.emit.
    • unbundle: true preserves per-file output (matching tsc).
    • Per-format outDir keeps the ./cjs and ./esm layout. ESM now uses .mjs, so the --esm-type-module flag (which wrote esm/package.json) is removed.
    • define inlines build-time constants (__BUILD_ENV__*__) at transpile time, replacing the old replaceBuildEnvInDirectory post-processing.
  • Declarations are still emitted by tsc (--emitDeclarationOnly, dts: false in tsdown). Rolldown's declaration bundler restructures modules in ways that break older TypeScript consumers — inline type modifiers (TS 4.5+), .js/.mjs extensions on declaration imports (unresolvable under moduleResolution: node < TS 4.7), and export ... from re-exports rewritten into import + local export (trips isolatedModules on re-exported const enums). Keeping tsc for .d.ts makes declaration output identical to before, preserving the full TS 3.8+ compatibility matrix. Declarations are emitted only next to the CJS output, since every package's types field (and the types condition in exports) already points at ./cjs.

Test instructions

  • yarn buildyarn typecheck — passes.
  • scripts/cli typecheck test/apps/vanilla — passes.
  • yarn test:compat:tsc — all checks pass (TS 3.8.2, isolatedModules, 4.1.6, latest, exactOptionalPropertyTypes, ESNext).
  • yarn build:apps — all test apps build.

Checklist

  • Tested locally
  • Tested on staging
  • Added unit tests for this change.
  • Added e2e/integration tests for this change.
  • Updated documentation and/or relevant AGENTS.md file

@datadog-official

datadog-official Bot commented Jun 11, 2026

Copy link
Copy Markdown

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

🎯 Code Coverage (details)
Patch Coverage: 85.71%
Overall Coverage: 76.81% (+0.00%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: f510dab | Docs | Datadog PR Page | Give us feedback!

@thomas-lebeau thomas-lebeau force-pushed the worktree-migrate-build-to-tsdown branch 2 times, most recently from 5af4990 to bf28c82 Compare June 11, 2026 14:26
@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Jun 11, 2026

Copy link
Copy Markdown

Bundles Sizes Evolution

📦 Bundle Name Base Size Local Size 𝚫 𝚫% Status
Rum 172.04 KiB 172.04 KiB 0 B 0.00%
Rum Profiler 7.88 KiB 7.88 KiB 0 B 0.00%
Rum Recorder 21.09 KiB 21.09 KiB 0 B 0.00%
Logs 54.64 KiB 54.21 KiB -437 B -0.78%
Rum Slim 129.72 KiB 129.72 KiB 0 B 0.00%
Worker 22.96 KiB 22.96 KiB 0 B 0.00%

@thomas-lebeau thomas-lebeau force-pushed the worktree-migrate-build-to-tsdown branch 9 times, most recently from 8533ccc to 31c1d92 Compare June 12, 2026 08:11
Comment thread package.json Outdated
Comment on lines +15 to +16
"build": "yarn workspaces foreach --all --parallel --topological-dev run build",
"build:bundle": "yarn workspaces foreach --all --parallel --topological-dev run build:bundle",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: why do we need those -dev? Also build:bundle don't need --topological because all bundles are built including all source files, not built artifacts. Paths are resolved from tsconfig.base.json.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because I added browser-worker as a dev dependency, in Theory this is needed because it need to builds the worker before building browser-rum.

In practice, needsWorkerRebuild() in buildEnv deals about it. Ideally I would get rid of it, but it is still needed because unit tests don't build first.

Comment thread scripts/build/build-package.ts Outdated
Comment thread scripts/build/build-package.ts Outdated
Comment on lines +110 to +112
define: Object.fromEntries(
referencedBuildEnvKeys().map((key) => [`__BUILD_ENV__${key}__`, JSON.stringify(getBuildEnvValue(key))])
),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: We can probably define all build env even if they are unused?

    define: Object.fromEntries(buildEnvKeys.map((key) => [`__BUILD_ENV__${key}__`, getBuildEnvValue(key)])),

@thomas-lebeau thomas-lebeau Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filtering is there specifically because WORKER_STRING isn't a pure value like the others — evaluating it has a side effect. getBuildEnvValue('WORKER_STRING') reads packages/browser-worker/bundle/worker.js (and rebuilds the worker if it's missing/stale). Only browser-rum actually references __BUILD_ENV__WORKER_STRING__ (in deflateWorker.ts).

Since tsdown's define is eager — every listed key is computed up front, for every package — defining all keys unconditionally makes every package's build read the worker bundle, even though only browser-rum needs it. In the parallel yarn build that's a problem: browser-debugger/logs/etc. end up reading worker.js while browser-worker is concurrently rebuilding it (its build does fs.rm('./bundle') first), which gives a flaky ENOENT.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see! Thank you for the explanation. Makes sense then, however this worker string brings a lot of complexity, if we were able to build it fast and in-memory we wouldn't need to cache it on disk, so no dependency needed and no concurrent access to the files. Let's keep it in mind :)

Comment thread scripts/build/build-package.ts Outdated
@thomas-lebeau thomas-lebeau marked this pull request as ready for review June 12, 2026 13:28
@thomas-lebeau thomas-lebeau requested review from a team as code owners June 12, 2026 13:28
})

context = combine(
callMonitored(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: we should be able to remove the monitored decorator no?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could yes. I think it's good to have a decorator for any modern consumer that might want to use it.
To use it in the browser-sdk while building with tsdown we would need to add a dependency for the decorator pollyfill. tsc inline it OOTB, but not tsdown and I though it's not worth it complicating the build to inline it for just this one usage.

Replace the TypeScript compiler API (`ts.createProgram`/`program.emit`) with tsdown
(Rolldown-based) for transpiling package modules to CJS and ESM.

Declarations are still emitted by `tsc` (`--emitDeclarationOnly`): Rolldown's
declaration bundler restructures modules in ways that break older TypeScript
consumers (inline `type` modifiers, `.js` import extensions, rewritten re-exports
that trip `isolatedModules` on const enums). Keeping tsc for `.d.ts` makes the
declaration output identical to before, preserving the TS 3.8+ compatibility matrix.
Declarations are emitted only next to the CJS output, which every package's `types`
field already points at.

oxc lowers the one decorator in the codebase (`@monitored`) to a helper imported from
`@oxc-project/runtime` and cannot inline it. To avoid adding a runtime dependency, the
used helpers are vendored into the output and their imports rewritten to local copies.

Build-time constants (`__BUILD_ENV__*__`) are inlined via tsdown's `define` option
instead of post-processing emitted files. ESM output uses the `.mjs` extension, so the
`--esm-type-module` flag (which wrote `esm/package.json`) is no longer needed.
- Replace @monitored decorator on Logger.logImplementation with callMonitored
  to avoid tsdown emitting oxc runtime helpers
- Remove vendorOxcRuntimeHelpers workaround from build-package.ts
- Drop @oxc-project/runtime devDependency and its LICENSE-3rdparty.csv entry
Declare @datadog/browser-worker as a devDependency of browser-rum and
switch the build command to --topological-dev so yarn's workspace
topological ordering guarantees browser-worker is built before browser-rum.

Previously, the parallel build could start browser-rum before browser-worker
produced bundle/worker.js, causing a flaky ENOENT error in getBuildEnvValue.
- add getEntries() to pick entry globs based on whether the package
  uses src/index.ts + src/entries or top-level src modules
- set tsdown `root` to ./src so unbundle output mirrors the src layout
  (e.g. src/entries/main.ts -> cjs/entries/main.js) instead of being
  flattened to the entries' common ancestor
- drop the `deps.neverBundle` external override
- bundles no longer depend on each other's build output, so parallel
  ordering is unnecessary after the tsdown migration
- move js-core time.ts to src/entries/time.ts so its public surface lives under src/entries
- drop getEntries() heuristic; always derive tsdown entries from src/index.ts + src/entries/*.ts
- update js-core exports, time/package.json, and tsconfig path to the new location
- update js-core AGENTS.md: sub-paths live under src/entries/, ESM output uses
  .mjs (no esm/package.json needed), and tsconfig paths point at src/entries
- add TODO in build-package.ts explaining why declarations use emitDeclarations
  instead of tsdown dts (needs TS 4.7+ for bundler resolution and inline type)
@thomas-lebeau thomas-lebeau force-pushed the worktree-migrate-build-to-tsdown branch from 9f852fb to edc5440 Compare June 15, 2026 12:50

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f510dab3a5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

platform: 'neutral',
unbundle: true,
dts: false,
tsconfig: '../../tsconfig.base.json',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Set an ES2020 target for tsdown

issue: Passing the tsconfig here does not make tsdown use compilerOptions.target; per tsdown's target option docs, when no engines.node is present it applies no syntax lowering. Several packages have no engines field and the sources already contain ES2021 logical assignment (for example worker ??= in packages/browser-rum/src/boot/recorderApi.ts and ||= in segment.ts), which the previous TypeScript module build lowered because tsconfig.base.json targets ES2020. As a result, npm consumers that serve or bundle these module files without transpiling node_modules can now receive syntax above the SDK's ES2020 output target.

Useful? React with 👍 / 👎.

})

context = combine(
callMonitored(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve pre-init Logger exceptions

issue: The old @monitored decorator only wrapped the method after startMonitorErrorCollection() was installed; before Logs init (or for consumers instantiating the exported Logger directly) it invoked the original method and let errors from the log strategy surface. callMonitored() always catches and swallows those errors, so a throwing handleLogStrategy before initialization now fails silently instead of preserving the previous public Logger behavior.

Useful? React with 👍 / 👎.

// and remove emitDeclarations. The bundler output needs inline `type` modifiers (TS 4.5+) and
// `.mjs`/`.js` extensioned import paths that only resolve under `node16`/`bundler` module
// resolution (TS 4.7+), so 4.7 is the floor where it works.
await tsdownBuild({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep const enums erased in module output

issue: Switching the JS module emit to tsdown here makes exported const enums become runtime enum objects instead of the string/number literals that the previous TypeScript emit produced. The codebase relies on erasing these for size (for example LifeCycleEventType is documented as avoiding a ~2KB minified cost), and dependent packages import browser-core/rum-core const enums such as ErrorHandling, DOM_EVENT, and LifeCycleEventType, so npm consumers now pull those enum objects into their bundles instead of inlined constants.

Useful? React with 👍 / 👎.

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