Fix: make the web build bundle and run in a browser - #614
Conversation
Two defects in the browser build output, both invisible to the existing suite because it runs against src rather than the built artifacts. 1. `models/apigee_llm.js` did not parse. The browser target named chrome58/safari11, which predate native async generators, so esbuild downlevelled `yield* super.generateContentAsync(...)` into an __asyncGenerator closure and emitted `super` inside it. Raising the target to chrome63/safari12 keeps async generators native. This was a hard blocker: LlmAgent -> models/registry -> apigee_llm, so no browser consumer could avoid it, and no bundler configuration can parse invalid syntax. 2. Every file in dist/web carried the Node `createRequire` banner, because the banner was gated on `format === 'esm'` and the web build is also ESM. 187 files imported `module`; none ever called require(). Bundling dist/web/agents/llm_agent.js for the browser goes from 86 errors to 21, and dist/web/index_web.js from 346 to 198. The remainder is winston and node:async_hooks, tracked separately. Adds a regression test over the build output, which is currently unguarded. Verified it fails without this change, reporting all 198 bannered files and naming models/apigee_llm.js as unparseable. Fixes #608 Fixes #609 Part of #607
There was a problem hiding this comment.
Do we want to make bundle always true for web?
There was a problem hiding this comment.
Good question — I tried it, and the short answer is that it doesn't get us there on its own and it costs us three things.
What bundle: true for web fixes. It makes alias usable. esbuild rejects alias outright without bundling (Cannot use "alias" without "bundle"), which is why the existing node:async_hooks shim is gated on bundle and therefore never reaches published output. With bundling on, that shim applies, and adding a winston alias clears winston too.
What it doesn't fix. packages: 'external' keeps node_modules external, so after bundling and both shims, dist/web/index.js still imports:
node:fs/promises, node:path, node:net, node:dns/promises,
adm-zip, google-auth-library
Those come from the barrel pulling skills/loader (#612) and from node-only packages. So bundling alone doesn't produce a browser-usable artifact.
What it costs.
dist/web/index_web.jsstops existing — output becomesdist/web/index.js— so"browser": "./dist/web/index_web.js"inpackage.jsonpoints at a missing file.- Deep imports die.
dist/web/agents/llm_agent.jsand friends disappear, and importing deep modules is currently the only way to consume the web build at all, precisely because the barrel is unbundleable. It would remove today's workaround before the replacement works. minify: bundleandsourcemap: bundleare tied to the same flag, so the web build silently becomes minified.
So: I think bundling is likely part of the eventual answer for #611, but as a package it's a bigger change with its own regressions and it needs #612 resolved to actually pay off. I'd rather not fold it into this PR, which is deliberately scoped to the two mechanical defects (unparseable file, Node banner) — that keeps this one easy to reason about and revert.
I've written the four options up on #611, including this one with the measurements above. Happy to take whichever you prefer as a follow-up. My own preference is dropping the winston dependency instead: utils/logger.ts uses it for a level filter and a printf, which is about 40 lines of console code, and that removes the problem at the source for every platform without changing the build shape at all.
One correction I owe you while we're here: my original writeup on #611 claimed the && bundle gate was an oversight and proposed removing it. That was wrong — it's load-bearing, for the reason above. I've posted a retraction on the issue.
There was a problem hiding this comment.
Done — dist/web/index_web.js now bundles for --platform=browser with zero errors, down from 346, and the test drives the whole thing.
You were right about bundling, and my earlier reply was wrong on the substance. I said it "doesn't get us there on its own and costs three things". Two of those three evaporate once the barrel is fixed:
- the missing
index_web.jswas self-inflicted — the outfile was hardcoded toindex.js; making it follow the entry keeps thebrowserfield resolving - losing deep imports only mattered because the barrel was unbundleable; once it bundles, nobody needs them
So the web target is now always bundled, which is what makes alias usable and lets the node:async_hooks shim actually reach a published artifact for the first time.
What it took beyond that:
- a
winstonshim (utils/winston_shim.ts) —utils/logger.tsdrags winston into every module that logs, and winston needsos/fs/util/zlib/http - moving the Node-only exports out of
common.ts
On the second one: I didn't hand-pick that list. I bundled all 199 source modules for the browser and recorded which reach a Node-only dependency, then moved exactly those. It turned out to be a small set — skills/loader, skills/gcp_skill_registry, tools/openapi_tool/*, tools/skill/{load_skill_resource,skill_toolset}, tools/load_web_page, and memory/vertex_ai_memory_bank_service. I checked list_skills_tool, load_skill_tool and search_skills_tool individually — they're browser-safe, so they stayed.
This also turned out not to be a new structure. index.ts already holds the Node-only exports plus export * from './common.js', and index_web.ts is common.js alone — so common.ts was already meant to be the browser-safe surface, and things had just leaked into it. The moved exports land next to the ones already in index.ts.
The Node public API is unchanged: 206 exports from dist/esm/index.js before and after, verified by diffing resolved export lists against a build of origin/main.
The test now covers the entire bundle, per your comment on the other thread — it bundles index_web.js, imports it, and constructs an LlmAgent with a FunctionTool, creates a session and executes the tool. Full detail in the PR description.
Two things I'd flag rather than bury:
dist/webis now a single minifiedindex_web.jsinstead of a file tree, so@google/adk/dist/web/agents/llm_agent.jsno longer resolves. The exports map doesn't expose those paths anyway (package.jsonexportshas nobrowsercondition, so bundlers resolve the Node build and thebrowserfield is ignored #610), and they were only a workaround for this exact bug, but it is a real change.- The browser entry no longer exports the Node-only surface. Nothing that previously worked is lost — none of it could run in a browser — but it is a narrower public surface for the web build specifically.
Happy to split the barrel move into its own commit or PR if you'd rather review it separately; it's already an isolated commit (a6192c9) so it lifts out cleanly.
There was a problem hiding this comment.
Can we create another test that will spin up real html page that will include compiled ADK with some simple test to see that everything works and nothing is really broken?
There was a problem hiding this comment.
Agreed, and I've pushed a version of this in d84b1ce.
The new web build output is usable by a browser bundler block takes entry points from dist/web, bundles them with --platform=browser, imports the result, and exercises it for real:
const tool = new FunctionTool({name: 'add', ..., execute: async ({a, b}) => ({sum: a + b})});
expect(isFunctionTool(tool)).toBe(true);
expect(tool._getDeclaration()?.parameters).toEqual({...}); // what a model actually receives
expect(await tool.runAsync({args: {a: 2, b: 3}, context: undefined})).toEqual({sum: 5});So it's compiled browser output being constructed and executed, not just parsed. I verified all three new tests fail against an unpatched build.
Two things I want to flag rather than quietly decide:
1. The entry-point list is short, and that's the real finding. Right now only events/event.js and tools/function_tool.js bundle with zero errors. Here's the current state with this PR applied:
| entry | bundle errors |
|---|---|
events/event.js |
0 |
tools/function_tool.js |
0 |
models/base_llm.js |
1 |
sessions/in_memory_session_service.js |
20 |
agents/llm_agent.js |
21 |
runner/runner.js |
21 |
index_web.js |
198 |
The 20–21 are winston + node:async_hooks (#611); the 198 adds the barrel's skills/loader (#612). So a test that loads everything can't pass until those land — I've left a comment in the file saying to add agents/llm_agent.js and runner/runner.js as they become bundleable, since each is one dependency away.
2. It's not literally an HTML page in a browser, and that was a deliberate call. The repo has no browser test tooling today — no puppeteer, playwright or jsdom, and every vitest project is environment: 'node'. A true headless-browser test means adding a browser dependency that runs on all three CI OSes, which felt like your call rather than something to slip into a bugfix PR.
What's committed gets most of the value with no new dependency: it proves the compiled artifact bundles, imports and behaves. What it can't catch is anything genuinely browser-specific — a real window/DOM dependency, or code that only breaks under a browser's module loader.
If you'd like the real thing, I'm happy to add it. My suggestion would be to do it once #611 and #612 land, so the page can load the actual barrel and assert something meaningful end to end, and to open it as its own PR given it's a new devDependency. Say the word and I'll file the issue.
There was a problem hiding this comment.
Understood — done. The test now bundles the entire web entry rather than a subset.
I'd scoped it to two modules because that was all that bundled cleanly at the time. That was treating the symptom, so I fixed the remaining two blockers instead (winston + node:async_hooks, and the Node-only exports leaking into common.ts) — details on the other thread and in the PR description.
tests/integration/build_setup/web_build_test.ts now does:
await esbuild.build({
entryPoints: [WEB_ENTRY], // core/dist/web/index_web.js — the whole bundle
bundle: true, format: 'esm', platform: 'browser', target: 'chrome138',
});then imports the result and drives the public API:
LlmAgent,SequentialAgent,ParallelAgent,LoopAgent,FunctionTool,Runner,InMemorySessionService,BaseLlmare all present and callable- an
LlmAgentis constructed with aFunctionTool, a session is created, and the tool is executed —{sum: 5} - the Node-only surface (
loadAllSkillsInDir,OpenAPIToolset,UnsafeLocalCodeExecutor,GcsArtifactService,VertexAiMemoryBankService,loadWebPage) is asserted absent from the browser entry, so the barrel can't silently re-acquire anode:fsdependency
The bundle step is in beforeAll, so if the web build ever stops bundling the whole file fails rather than skipping — I checked that specifically: reverting the common.ts hunk gives 173 unresolved Node imports, vitest exits 1, Test Files 1 failed.
On the "real html page" part specifically: this stops just short of a browser engine. It proves the bundle builds, imports and behaves, but it can't catch something genuinely browser-only — a real window/DOM dependency, or a failure specific to a browser's module loader. Closing that gap means a headless-browser devDependency (there's no puppeteer/playwright/jsdom in the repo today, and every vitest project is environment: 'node'), which felt like your call rather than mine to make inside a bugfix PR.
Now that the bundle actually works, that test would be straightforward — load index_web.js in a page and run the same agent construction. Happy to add it here if you want it, or file it as a follow-up. Just say which.
There was a problem hiding this comment.
You're right, and this is now built.
tests/integration/build_setup/web_app/ is a real page — message list, prompt input, send button — that imports core/dist/web/index_web.js directly with no build step of its own. web_app_test.ts drives it in headless Chromium via Playwright:
✓ loads the shipped bundle without a module or runtime error
✓ shows the user message and the agent reply after clicking send
✓ runs a second turn on the same session
It types into the input, clicks send, waits for li[data-author="agent"], and asserts the rendered list is exactly:
[{author: 'user', text: 'hello agent'},
{author: 'agent', text: 'Hello from the agent'}]The model is a BaseLlm subclass returning a canned response — the same shape as the mock in tests/integration/build_setup/ts_esm/agent.ts. pageerror and console errors are collected and asserted empty, so a DOM reference or a module-loading failure fails the test rather than passing quietly.
You were right about the synthetic-ness, and I'd add a specific reason my previous test could not have caught real issues: it imported browser-targeted code into Node. Anything that only breaks under a browser's module loader, or touches window/document, would have passed it. That gap was the whole point of the test.
One thing this surfaced that I'd missed. dist/web/index_web.js was bundled but still had packages: 'external', so it emitted bare specifiers (@google/genai, zod, lodash-es) that a browser cannot resolve — the page could not have loaded it. The web target now bundles its dependencies and is genuinely self-contained at 972 KB. Node keeps packages: 'external' unchanged. That is also what makes your other comment satisfiable: the test no longer bundles anything.
Agreed on expanding from here — streaming, tool calls and multi-agent are the obvious next ones, and the fixture is set up so each is a new page interaction rather than new plumbing.
Addresses review feedback asking for a test that loads compiled ADK and checks it actually works. Parsing is necessary but not sufficient: it would not catch output that bundles and then throws on import, or a primitive that is broken once compiled for the browser. This bundles entry points from dist/web with --platform=browser and runs them — constructing a FunctionTool, checking the declaration a model would receive, and executing it. The entry-point list is short because most of dist/web still cannot be bundled: the barrel pulls skills/loader (node:fs, node:path) and the logger pulls winston. agents/llm_agent.js and runner/runner.js are the two worth adding next and are one dependency away each. Verified all three new tests fail against an unpatched build.
| * and `runner/runner.js` are the two worth having next, and both are one | ||
| * dependency away. | ||
| */ | ||
| describe('web build output is usable by a browser bundler', () => { |
There was a problem hiding this comment.
No, we need to test entire ADK TS bundle for web.
There was a problem hiding this comment.
Addressed — the test now bundles and runs the entire dist/web/index_web.js, not a subset. See the thread above for what it took (the two remaining blockers had to be fixed, which they now are) and the PR description for the full testing plan.
There was a problem hiding this comment.
Fixed — and you were right that it was circular. dist/web is the bundle; re-bundling it in the test only exercised my esbuild invocation.
Removed. The browser test now loads core/dist/web/index_web.js straight from a <script type="module"> with no build step.
That only actually works because of a related fix: the web output had packages: 'external', so it still emitted bare specifiers a browser cannot resolve. It is now self-contained, so "already bundled" is true in the sense you meant it.
There was a problem hiding this comment.
Done — moved to tests/integration/build_setup/node_build_test.ts, which covers the ESM and CJS builds:
- the ESM build still emits the
createRequirebanner dist/cjs/package.jsondeclares"type": "commonjs"- the Node builds still import winston (see below)
web_build_test.ts is now dist/web only.
Follows review feedback asking that the entire ADK TS web bundle be tested, not a subset. Getting there needed the remaining two defects fixed rather than deferred. Bundle the web target always. esbuild rejects `alias` unless bundling, so the existing node:async_hooks shim never reached published output -- `npm run build` does not pass --bundle. Bundling the web target makes the alias mechanism work, and the emitted filename now follows the entry so package.json's "browser" field still resolves. Add a browser stand-in for winston. utils/logger.ts pulls winston into every module that logs, and winston needs os/fs/util/zlib/http. The shim implements only the surface logger.ts uses and writes to the console, which is what a browser transport does. Keep Node-only modules out of the web barrel. index.ts already holds the Node-only exports and index_web.ts is common.ts alone, so common.ts is meant to be the browser-safe surface -- but exports reaching node:fs, node:dns, adm-zip, google-auth-library and @google-cloud/vertexai had leaked into it. Those move to index.ts alongside the ones already there. The set was derived by bundling all 199 source modules for the browser and recording which reach a Node-only dependency, not chosen by hand. core/dist/web/index_web.js now bundles for --platform=browser with zero errors, down from 346. The bundle imports and drives the public API: LlmAgent, ParallelAgent, SequentialAgent, LoopAgent, FunctionTool, Runner, InMemorySessionService and BaseLlm are all present and working. The Node public API is unchanged -- 206 exports from dist/esm/index.js before and after, verified by diffing the resolved export lists. Mutation check: reverting only the common.ts hunk makes the bundle step fail with 173 unresolved Node imports, vitest exits 1 and reports the file as failed.
| ) as {browser?: string}; | ||
| expect(pkg.browser).toBeDefined(); | ||
| const target = path.join(process.cwd(), 'core', pkg.browser!); | ||
| await expect(fs.access(target)).resolves.toBeUndefined(); |
There was a problem hiding this comment.
seems to be a bug. Should it be defined instead?
There was a problem hiding this comment.
Good catch — it was confusing even though it happened to pass. fs.access resolves to undefined on success, so .resolves.toBeUndefined() was technically correct, but it reads like an inverted assertion and would silently keep passing if the call were swapped for something else.
Replaced with something that says what it means:
const stat = await fs.stat(target);
expect(stat.isFile()).toBe(true);
expect(stat.size).toBeGreaterThan(0);That also asserts more than the old version did: the path exists, is a file, and is non-empty.
|
|
||
| it('still emits the createRequire banner for the Node ESM build', async () => { | ||
| // Guards against fixing the browser build by dropping the banner outright. | ||
| const nodeFiles = await collectJsFiles(NODE_ESM_DIST); |
There was a problem hiding this comment.
it make sense to create a separate test that will test it against the ESM and CJS builds. So please drop this test from the web test
There was a problem hiding this comment.
Done — moved to tests/integration/build_setup/node_build_test.ts, which covers the ESM and CJS builds:
- the ESM build still emits the
createRequirebanner dist/cjs/package.jsondeclares"type": "commonjs"- the Node builds still import winston (see below)
web_build_test.ts is now dist/web only.
| await esbuild.build({ | ||
| entryPoints: [WEB_ENTRY], | ||
| outfile: bundlePath, | ||
| bundle: true, | ||
| format: 'esm', | ||
| platform: 'browser', | ||
| target: 'chrome138', | ||
| logLevel: 'silent', | ||
| }); |
There was a problem hiding this comment.
we should already test agains the bundled ADK TS. IT does not make sense to bundle it in the test.
There was a problem hiding this comment.
Fixed — and you were right that it was circular. dist/web is the bundle; re-bundling it in the test only exercised my esbuild invocation.
Removed. The browser test now loads core/dist/web/index_web.js straight from a <script type="module"> with no build step.
That only actually works because of a related fix: the web output had packages: 'external', so it still emitted bare specifiers a browser cannot resolve. It is now self-contained, so "already bundled" is true in the sense you meant it.
| it('bundles the entire web entry point for the browser', async () => { | ||
| const stat = await fs.stat(bundlePath); | ||
| expect(stat.size).toBeGreaterThan(0); | ||
| }); | ||
|
|
||
| it('exposes the core primitives once bundled', async () => { | ||
| const adk = await import(pathToFileURL(bundlePath).href); | ||
| for (const symbol of [ | ||
| 'LlmAgent', | ||
| 'SequentialAgent', | ||
| 'ParallelAgent', | ||
| 'LoopAgent', | ||
| 'FunctionTool', | ||
| 'Runner', | ||
| 'InMemorySessionService', | ||
| 'BaseLlm', | ||
| ]) { | ||
| expect(typeof adk[symbol], symbol).toBe('function'); | ||
| } | ||
| }); | ||
|
|
||
| it('excludes the Node-only surface from the browser entry point', async () => { | ||
| const adk = await import(pathToFileURL(bundlePath).href); | ||
| // These reach node:fs, child_process, google-auth-library or | ||
| // @google-cloud/vertexai, and are exported from index.ts for Node only. | ||
| for (const symbol of [ | ||
| 'loadAllSkillsInDir', | ||
| 'OpenAPIToolset', | ||
| 'UnsafeLocalCodeExecutor', | ||
| 'GcsArtifactService', | ||
| 'VertexAiMemoryBankService', | ||
| 'loadWebPage', | ||
| ]) { | ||
| expect(adk[symbol], symbol).toBeUndefined(); | ||
| } | ||
| }); | ||
|
|
||
| it('builds a working agent and tool from the bundle', async () => { | ||
| const adk = await import(pathToFileURL(bundlePath).href); | ||
|
|
||
| const tool = new adk.FunctionTool({ | ||
| name: 'add', | ||
| description: 'adds two numbers', | ||
| parameters: { | ||
| type: 'object', | ||
| properties: {a: {type: 'number'}, b: {type: 'number'}}, | ||
| required: ['a', 'b'], | ||
| }, | ||
| execute: async ({a, b}: {a: number; b: number}) => ({sum: a + b}), | ||
| }); | ||
|
|
||
| const agent = new adk.LlmAgent({ | ||
| name: 'browser_agent', | ||
| model: 'gemini-2.0-flash', | ||
| instruction: 'You are a calculator.', | ||
| tools: [tool], | ||
| }); | ||
| expect(agent.name).toBe('browser_agent'); | ||
| expect(agent.tools).toHaveLength(1); | ||
|
|
||
| const sessions = new adk.InMemorySessionService(); | ||
| const session = await sessions.createSession({ | ||
| appName: 'browser_app', | ||
| userId: 'user', | ||
| }); | ||
| expect(session.id).toBeTruthy(); | ||
|
|
||
| const result = await tool.runAsync({ | ||
| args: {a: 2, b: 3}, | ||
| context: undefined, | ||
| }); | ||
| expect(result).toEqual({sum: 5}); | ||
| }); |
There was a problem hiding this comment.
That is not what we really want to have. This kind of tests are very synthetic and might not catch real issues.
What I propose is to create a real HTML page that will include complied (and bundled) ADK TS from dist and RUN some REAL browser (headless) test.
For example the HTML page where messages, prompt input and send button. When user puts something in the input and clicks send button, their message should appear in the message list and agent should respond with some message. Test should verify that it is actually working.
The real model response can be mocked with predefined result (in the same way we do that for our integration tests).
So such kind of test will ensure that ADK TS for web compiled and bundled. It can be imported and used in the real web app and it will provide some basic functionality.
Once this is done, we can expand this testcases to test more ADK TS features on web.
There was a problem hiding this comment.
You're right, and this is now built.
tests/integration/build_setup/web_app/ is a real page — message list, prompt input, send button — that imports core/dist/web/index_web.js directly with no build step of its own. web_app_test.ts drives it in headless Chromium via Playwright:
✓ loads the shipped bundle without a module or runtime error
✓ shows the user message and the agent reply after clicking send
✓ runs a second turn on the same session
It types into the input, clicks send, waits for li[data-author="agent"], and asserts the rendered list is exactly:
[{author: 'user', text: 'hello agent'},
{author: 'agent', text: 'Hello from the agent'}]The model is a BaseLlm subclass returning a canned response — the same shape as the mock in tests/integration/build_setup/ts_esm/agent.ts. pageerror and console errors are collected and asserted empty, so a DOM reference or a module-loading failure fails the test rather than passing quietly.
You were right about the synthetic-ness, and I'd add a specific reason my previous test could not have caught real issues: it imported browser-targeted code into Node. Anything that only breaks under a browser's module loader, or touches window/document, would have passed it. That gap was the whole point of the test.
One thing this surfaced that I'd missed. dist/web/index_web.js was bundled but still had packages: 'external', so it emitted bare specifiers (@google/genai, zod, lodash-es) that a browser cannot resolve — the page could not have loaded it. The web target now bundles its dependencies and is genuinely self-contained at 972 KB. Node keeps packages: 'external' unchanged. That is also what makes your other comment satisfiable: the test no longer bundles anything.
Agreed on expanding from here — streaming, tool calls and multi-agent are the obvious next ones, and the fixture is set up so each is a new page interaction rather than new plumbing.
…eb bundle Replaces the synthetic bundle test with the real thing, per review. tests/integration/build_setup/web_app/ is a small chat page -- message list, prompt input, send button -- that imports core/dist/web/index_web.js directly, with no build step of its own. web_app_test.ts drives it in headless Chromium with Playwright: types a message, clicks send, and asserts the user message and the agent reply both render, then runs a second turn on the same session. The model is a BaseLlm subclass returning a canned response, matching the mock in the existing build_setup fixtures. Page errors and console errors fail the test, so a DOM or module-loading failure surfaces instead of passing silently. Making that possible meant the web target had to become genuinely self-contained. It was bundled but kept `packages: 'external'`, so the output still imported '@google/genai', 'zod' and friends as bare specifiers, which a browser cannot resolve. The web target now bundles its dependencies; Node keeps packages external as before. 972 KB. Splits the build-output tests as requested: web_build_test.ts covers dist/web only, and the Node ESM/CJS assertions move to node_build_test.ts. Drops the assertions that re-bundled dist/web inside the test -- the shipped artifact is already a bundle, and the browser test now exercises it directly. Adds playwright-chromium and a CI step to install the browser.
# Conflicts: # core/build.js
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Closes: #608
Closes: #609
Closes: #611
Closes: #612
Related: #607
Problem:
@google/adkadvertises browser support, butcore/dist/webcannot be consumed by a browser bundler. Bundlingdist/web/index_web.jswith--platform=browserproduced 346 errors, one of which was a syntax error in a shipped file, so no bundler configuration could work around it. Four separate defects:models/apigee_llm.jsdid not parse. The browser target namedchrome58/safari11, which predate native async generators, so esbuild downlevelledyield* super.generateContentAsync(...)into an__asyncGeneratorclosure — wheresuperis not valid. Unavoidable viaLlmAgent → models/registry → apigee_llm.dist/webcarried the NodecreateRequirebanner, because it was gated onformat === 'esm'and the web build is also ESM. 187 files importedmodule; none calledrequire().winstonandnode:async_hookswere imported by the web build. A shim fornode:async_hooksalready existed but was gated behind--bundle, whichnpm run builddoes not pass, so it never reached a published artifact.common.tshad accumulated exports reachingnode:fs,node:dns,adm-zip,google-auth-libraryand@google-cloud/vertexai, andindex_web.tsre-exports it wholesale.Solution:
chrome63/safari12, the first versions with native async generators. Verified each target individually:chrome58andsafari11downlevel and break,chrome63andsafari12do not.createRequirebanner onplatform === 'node'as well as format.dist/esmkeeps it,dist/webdoes not.aliaswithout bundling, so this is what makes the shim mechanism work at all. The emitted filename follows the entry, so"browser": "./dist/web/index_web.js"still resolves. Addsutils/winston_shim.ts, implementing only the surfaceutils/logger.tsuses and writing to the console — that is what a browser log transport does.common.tstoindex.ts. This follows the layout already in place:index.tsholds the Node-only exports plusexport * from './common.js', andindex_web.tsiscommon.jsalone, socommon.tsis already intended to be the browser-safe surface. The moved set was derived, not chosen — all 199 source modules were bundled for the browser and the ones reaching a Node-only dependency recorded.list_skills_tool,load_skill_toolandsearch_skills_toolwere checked individually and are browser-safe, so they stay.Result:
dist/web/index_web.jsbundles for--platform=browserwith zero errors, down from 346, and the bundle runs.--platform=browserdist/web/index_web.jsUnexpected "super"Could not resolve "module"The Node public API is unchanged.
dist/esm/index.jsexports the same 206 symbols before and after, verified by diffing the resolved export lists from a build oforigin/mainagainst this branch.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:
tests/integration/build_setup/web_build_test.tsis new; the build output had no test coverage at all, which is how all four defects shipped. Eight tests:dist/webcontains nocreateRequirebanner, anddist/esmstill does — the second guards against "fixing" the browser build by deleting the banner outrightesbuild.transform)browserfield exists--platform=browser, then is imported and driven:LlmAgent,SequentialAgent,ParallelAgent,LoopAgent,FunctionTool,Runner,InMemorySessionServiceandBaseLlmare all present; anLlmAgentis constructed with aFunctionTool, a session is created, and the tool is executedloadAllSkillsInDir,OpenAPIToolset,UnsafeLocalCodeExecutor,GcsArtifactService,VertexAiMemoryBankService,loadWebPage) is absent from the browser entryRan locally:
Mutation check (proving the tests can fail): reverting only the
common.tshunk and rebuilding makes the bundle step fail with 173 unresolved Node imports —node:dns/promises,node:net,node:fs/promises,node:path, andfsvia@google-cloud/vertexai. vitest exits1and reportsTest Files 1 failed. Reverting only thebuild.jshunk fails 6 of 8, namingmodels/apigee_llm.jsas unparseable and listing all 198 bannered files.Manual End-to-End (E2E) Tests:
Emitted bundle is 166 KB.
Checklist
Notes for the reviewer
dist/webis now a single bundled, minifiedindex_web.jsrather than a file tree, so deep imports like@google/adk/dist/web/agents/llm_agent.jsno longer resolve. Those were only ever a workaround for the barrel being unbundleable, and the exports map does not expose them anyway (package.jsonexportshas nobrowsercondition, so bundlers resolve the Node build and thebrowserfield is ignored #610), but flagging it. And the browser entry no longer exports the Node-only surface — none of it could ever have run in a browser, so nothing that worked is lost.winston_shim.tswrites toconsole. That is deliberate: it is the browser log transport, the direct counterpart ofwinston.transports.Console. No other module gained aconsolecall.exportshas nobrowsercondition, so bundlers resolve the Node build and thebrowserfield is ignored #610 (adding abrowsercondition to theexportsmap) is intentionally not in this PR. It is a package-resolution change, independently reviewable, and this PR is what makes it worth doing.