Skip to content

Fix: gate the esbuild createRequire preamble on the Node platform - #555

Open
AmaadMartin wants to merge 2 commits into
mainfrom
fix/gate-createrequire-banner-on-node-platform
Open

Fix: gate the esbuild createRequire preamble on the Node platform#555
AmaadMartin wants to merge 2 commits into
mainfrom
fix/gate-createrequire-banner-on-node-platform

Conversation

@AmaadMartin

Copy link
Copy Markdown
Owner

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    N/A

  2. Or, if no issue exists, describe the change:

Problem: @google/adk and @google/adk-integrations both advertise a browser build ("browser": "./dist/web/index_web.js"), but that artifact cannot be loaded by a browser or consumed by a browser-targeted bundler without extra configuration.

core/build.js and integrations/build.js append a CommonJS-interop preamble whenever the output format is ESM:

if (format === 'esm') {
  buildOptions.banner = {
    js:
      (buildOptions.banner?.js || '') +
      `import {createRequire as topLevelCreateRequire} from 'module';\nconst require = topLevelCreateRequire(import.meta.url);`,
  };
}

main() builds three targets: esm (node/esm), cjs (node/cjs) and web (browser/esm). Because the gate is format === 'esm' alone, the browser target gets the preamble too, and packages: 'external' leaves the bare 'module' specifier untouched in the emitted JavaScript. Measured on this branch before the fix: all 198 files under core/dist/web/ and all 3 under integrations/dist/web/ — including both index_web.js files the browser fields point at — import the Node module builtin.

Solution: Add the platform to the gate — one line per build script, plus a comment explaining why the preamble exists at all:

if (platform === 'node' && format === 'esm') {

The preamble is only needed so Node ESM output can reach CommonJS-only dependencies (esbuild lowers await import(pkg) of an external package to require(pkg) for the node10.4 target). It has no purpose in a browser bundle. The body of the block is left byte-for-byte unchanged, deliberately: a separate queued change rewrites the specifier to 'node:module' inside the same block, and touching only the condition keeps the two changes conflict-free (the new test's regex accepts either specifier, so it passes in both orderings).

Resulting banner per target:

target before after
esm (node, non-bundle) preamble preamble (unchanged)
esm (node, bundle) license + preamble license + preamble (unchanged)
cjs (node) license license (unchanged)
web (browser, non-bundle) preamble no banner
web (browser, bundle) license + preamble license only

Deliberately not done here (kept the diff minimal and reviewable): the two build scripts are near-duplicates by design, and this change does not extract a shared helper, reformat surrounding code, or refactor either script.

Known limitation, called out deliberately: exactly one emitted file, core/dist/web/sessions/db/operations.js, actually calls require(...) — esbuild lowers await import('@mikro-orm/…') to __toESM(require('@mikro-orm/…')) for the chrome58 target — so it loses a defined require. That module is not reachable from the browser entry (bundling src/index_web.ts produces no @mikro-orm reference; the only require( occurrences in the bundled web output are inside string literals from src/tools/skill/run_skill_script_tool.ts). It is emitted into dist/web only because non-bundle mode transpiles all of src/**/*.ts, and it depends on Node-only ORM packages, so it could never run in a browser either way. Trading an unresolvable bare import for an undefined require in an already-unusable module is a net improvement.

Out of scope: dist/web also imports node:path, node:fs/promises, node:net and node:dns/promises through src/common.ts's export graph. Only node:async_hooks is shimmed today. The new test is therefore scoped to the module builtin — the one this fix removes — and does not assert "no Node builtins at all", which cannot pass today. The broader browser-compatibility cleanup is tracked separately.

Collision check (fork AmaadMartin/adk-js, all 379 open PRs scanned): three open PRs touch the same banner block, none of them makes this change, so this is not a duplicate:

This PR is branched from main rather than stacked on any of them: none is a prerequisite, the edits are semantically independent (condition vs. banner contents vs. banner ordering), and stacking on one would still leave the other two overlapping while suppressing CI on a non-main base.

Testing Plan

Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.

Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.

The changed lines live in build scripts, which are outside the vitest coverage include globs (core/src/**, dev/src/**, integrations/src/**), so they cannot be covered by a unit test and cannot move the configured thresholds — no threshold was edited. Both branches of the changed condition are covered behaviourally by one new integration test, tests/integration/build_output/web_output_test.ts (picked up by the existing tests/integration/**/*_test.ts glob; no vitest.config.ts change):

  • for core and integrations: every emitted .js file under dist/web is read and asserted not to match /from\s*['"](?:node:)?module['"]/, collecting offending paths into an array so a failure names the files;
  • dist/web/index_web.js (the file each package.json browser field resolves to) is asserted to exist;
  • outside the describe.each, core/dist/esm/index.js must match the same regex, so the browser build cannot be "fixed" later by deleting the preamble everywhere.

The test reads the already-built dist trees rather than running a build, because other integration tests consume core/dist via file: workspace fixtures and rewriting dist from inside a test would race them. CI builds before it tests (validation.yaml: "Build packages" precedes "Run tests and check code coverage").

npm run build
npx vitest run --project integration tests/integration/build_output/web_output_test.ts
#  ✓ tests/integration/build_output/web_output_test.ts (5 tests) 130ms
#  Test Files  1 passed (1) / Tests  5 passed (5)

Failure paths exercised (a missing build must not produce a vacuous pass):

injected condition result
integrations/dist/web removed AssertionError: …/integrations/dist/web is missing. Run \npm run build` first.`
integrations/dist/web present but empty AssertionError: …/integrations/dist/web holds no .js files. Run \npm run build` first.`

Proof the tests can fail (each mutation applied, npm run build re-run, test re-run):

mutation result
core/build.js gate reverted to if (format === 'esm') { core browser build > never imports the Node module builtinAssertionError: expected [ 'common.js', 'index.js', …(196) ] to deeply equal [], the diff naming "index_web.js"
integrations/build.js gate reverted to if (format === 'esm') { integrations browser build > never imports the Node module builtinAssertionError: expected [ 'index.js', 'index_web.js', …(1) ] to deeply equal []
whole if (…) { buildOptions.banner = … } block deleted from core/build.js core Node ESM build > keeps the createRequire preambleAssertionError: expected '/**\n * @license\n * Copyright 2025 G…' to match /from\s*['"](?:node:)?module['"]/
fix restored, rebuilt ✓ 5 passed

Node artifacts are provably untouched. sha256sum over every file in core/dist/{esm,cjs} + integrations/dist/{esm,cjs}, built with the fix, with the fix reverted, and with the fix restored, is the same digest all three times (a04a2678…), confirming the fix was not implemented by deleting the preamble.

Manual End-to-End (E2E) Tests:

npm run build
grep -rl "topLevelCreateRequire" core/dist/web integrations/dist/web   # no matches
grep -c  "topLevelCreateRequire" core/dist/esm/index.js                # 2
head -6  core/dist/web/index_web.js                                    # license header only
npm run build:bundle --workspace=core
head -8  core/dist/web/index.js                                        # license header, no preamble
head -8  core/dist/esm/index.js                                        # license header + preamble

All observed as expected with esbuild 0.25.12 (resolved from the root devDependencies range ^0.25.9) on Node v22.22.2.

Local validation on the exact pushed commit: npm run build ✓, npx vitest run --project integration tests/integration/build_output/web_output_test.ts ✓ (5/5), npm run lint ✓ (exit 0), npx prettier --check on all three changed files ✓. No new @ts-expect-error/@ts-ignore/eslint-disable/any anywhere in the diff.

Checklist

[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.

Amaad Martin added 2 commits August 2, 2026 18:55
The createRequire preamble exists so Node ESM output can reach
CommonJS-only dependencies, but it was applied to every ESM target,
including the browser build. That left an unresolvable bare 'module'
import in dist/web, the artifact the package browser field points at.

Gate the preamble on platform === 'node' as well as format === 'esm'.
The Node esm/cjs artifacts are byte-identical before and after.
…uiltin

Reads the already-built dist trees for core and integrations and fails
if any emitted browser file imports the Node 'module' builtin. A
companion assertion pins core/dist/esm/index.js to keep the preamble so
the browser fix cannot be re-implemented by deleting it everywhere.

The hooks fail with an actionable message when dist/web is absent or
empty, so a missing build cannot make the suite pass vacuously.
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.

1 participant