feat(serve-app): add agent-bundle/serve-app-command and AB4837 for compiler imports in routed executables (#558) - #582
Conversation
…mpiler imports in routed executables (#558)
🦋 Changeset detectedLatest commit: c7afff7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f261ee5291
ℹ️ 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".
…uts/providers only when bundled; keep closed pending through post-spawn errors; changeset names the PR
…uses the signal; judge layouts only for rendered routes and providers only where mounted
…dy-aborted re-check so a refused kill() is stop-failed, not an unhandled error
Closes #558
What lands
Issue #558's recommendation (c) plus the named diagnostic. Option (b) — a bundleable host inside the artifact — is superseded by #564 (a production
websurface) and is not built here; the helper is shaped so #564's generated<plugin> webcommand can reuse it (spawnServeApptakes every argv-form option and an injectablecli,relay,signal).agent-bundle/serve-app-command— a dependency-free leaf entrypackages/agent-bundle/src/serve-app-command.ts, declared inpackage.jsonexportsand built byrslib.config.tsexactly likeagent-bundle/launch-env/agent-bundle/terminal-capability: plain Node built-ins only, so the bundler inlines it into every self-contained generated executable (noautoExternal, no externals;AB6005stays satisfied — the packed proof asserts no liveagent-bundleimport survives in the bin).spawnServeApp(options)— lowers the options toagent-bundle serve-appargv, resolves the framework CLI installed at or aboveroot, spawnsnode <bin> serve-app …withstdio: ['ignore', 'pipe', 'inherit'], relays every stdout line to this process's stderr (or arelaycallback) so the routed command keeps stdout for its JSON result, and resolves once the CLI prints its ready line with{ app, url, port, tool, server, pid, closed, close() }. The route'ssignalturns into the child'sSIGTERM;close()does the same and awaits the exit.serveAppArgv(options)— the exhaustive lowering: a{ [K in keyof ServeAppArgvOptions]-?: string[] }record, so a new option fails to compile until it is lowered.locateFrameworkCli(root)—createRequire(<root>/package.json).resolve('agent-bundle/package.json')(honours hoisting and pnpm's layout; build: gate releases on attw + declaration-import check; manifest hygiene for agent-bundle, rsc-markdown-stream, create-agent-bundle (#566 §2) #568's./package.jsonexport makes this the primary path), falling back to the ancestornode_moduleswalk when a package'sexportshides its manifest; readsbin. Sharessrc/core/dependency-manifest.tswith the build's dependency-root discovery (src/build/rslib.tswas rewired onto it in the same change — no second copy).ServeAppCommandError(CodedError) withcode∈framework-not-installed|artifact-missing|spawn-failed|exited-before-ready(carriesexit: { code, signal }) |aborted|stop-failed(the running child refusedSIGTERM, e.g.EPERM; thrown byclose(), or the rejection of a pre-ready abort — the child is still running andclosedstill settles only on its real exit), each with an actionable message. Akill()failure is never mistaken for an exit:closedis tied to the child'scloseevent alone.parseServeAppReadyLine/formatServeAppReadyLine/serveAppAllowCapabilitieslive insrc/serve-app/command-contract.ts;cli.tsnow prints the ready line and validates--allowthrough the same module, so the CLI and the parser cannot drift.ServeAppArgvOptions= every key ofServeAppOptions(agent-bundle/api) except the host-process-onlylogger,registry,openBrowser,targets,timeoutMs;autoApproveis narrowed to the four capabilities--allowactually accepts (call-tool,download-file,open-external-link,request-display-mode).AB4837— the compiler imported into a generated executablepackages/agent-bundle/src/routes/framework-imports.ts, wired intocompileRouteGraph(src/routes/graph.ts), soinspect,validate,build, anddevall report it before the bundler runs. A route module of a generated server or CLI, a script, an event route — or a module one of them reaches through relative value imports — that value-importsagent-bundle,agent-bundle/api,agent-bundle/config,agent-bundle/eval,agent-bundle/rstest,agent-bundle/test, oragent-bundle/test/browsergets one error naming the file, the specifier, the executable it would be inlined into, and the helper. Layouts are judged only when a rendered route the build inlines them into exists (a generated server's route, a rendered CLI command, a rendered script — the same selectionbuild/cli-bins.tsmakes); providers only where a generated executable mounts them (a generated server, a generated CLI, a rendered script, an event route — not a plain.tsscript, which is bundled from its own source). The scanner resolves each identifier through the TypeScript binder (ts.Program+TypeChecker), soimport type,type-qualified specifiers, bindings used only in type positions, and imported names shadowed by a local declaration are not reported (SWC elides them); routes of acustom/command/remoteserver or aconventionalCLI are never bundled and are not judged; App routes are browser builds and are exempt.Allocated in the
AB48xxroute-graph range (next free afterAB4836) rather thanAB6xxx: it is a route-graph judgment made at config inspection, before any artifact exists — theAB6xxxcodes are post-build artifact-module checks.docs/diagnostics.mdcarries the row (the reference pages render from it).Diagnostic sample (from
tests/cli-routes-build.test.ts):Docs and changeset
website/docs/{en,zh}/guide/authoring/mcp.mdx"Serving an App standalone": thehauler dashboardsample now usesspawnServeApp; the host-process note explainsAB4837.website/docs/{en,zh}/reference/api.mdx(new entry row),reference/cli.mdx(theserve-appready-line/exit contract),guide/distribution/validation.mdx(AB4837next toAB6005);website/rspress.config.tsadds the entry to TypeDoc.docs/entry-conventions.md,docs/framework-mode.md,docs/diagnostics.md..changeset/558-serve-app-command.md(agent-bundle: patch).Proofs
Argv round-trip (
tests/serve-app-command.test.ts, unit pool): a{ [K in keyof ServeAppOptions]-?: 'argv' | 'host-only' }classification record andEquals<>type assertions make a newServeAppOptionskey a compile error until it is lowered or declared host-only; aRequired<ServeAppArgvOptions>sample is lowered and parsed back through the realserve-appCLI parser (runCliwithserveApprecorded), including--no-env/--no-open, the CLI defaults for a minimal option set, and the--env-file/--no-envconflict.locateFrameworkCliis covered for object/stringbin, a manifest two levels above the root, anexports-hidden manifest, a bin-less manifest, and this checkout's own bin.Spawn (
tests/serve-app-command-spawn.test.ts, integration pool, 13 tests against a fake CLI and the real one): ready-line relay and resolution,SIGTERMclose,exited-before-readywith the exit,abortedbefore/after the ready line, when already aborted, and when the abort lands during the artifact check (the post-spawnsignal.abortedre-check),framework-not-installed,artifact-missing,spawn-failed(both throw anderrorevent), a post-spawnerrorthat keepsclosedpending until the real exit and is carried as thecauseif the child then exits before ready,stop-failedfromclose(), from a pre-ready abort, and from an abort re-checked synchronously after the spawn (a fakekillthat emitserrorEPERM and returnsfalse, exactly as Node does; the child is asserted alive, then torn down), and the real CLI failing fast on a missing manifest classified asexited-before-ready.Packed (
tests/packed-serve-app-command.test.ts,pnpm test:packed, 4 tests):fixtures/serve-app-command(one server, one App, one routeddashboardcommand using the helper) is copied into a scratch consumer, the packedagent-bundletarball isnpm installed,agent-bundle buildruns, and both generated bins (dist/bin/<plugin>.jsandartifact/portable/bin/<plugin>.mjs) are spawned as OS processes: no liveagent-bundleimport in the bin bytes; the JSON result on stdout carries the URL (http://127.0.0.1:<port>/), the child pid, andprobeStatus: 200, the ready line is on stderr; after the run the child pid isESRCHand the URL refuses connections;SIGINTto the bin exits130with the served App torn down (child and its packed MCP server both gone);artifact-missing,framework-not-installed, andexited-before-ready(the child'sAB6000on stderr) each come back as the result document.Diagnostic (
tests/cli-routes-build.test.ts, integration pool): a routed command withawait import('agent-bundle/api')is reported byvalidatewithout a build and rejectsbuildwithDiagnosticErrorcarrying exactly oneAB4837, noCan't resolveand noAB6005, and neitherdist/norartifact/is written. Onmaintoday the same fixture fails inside the bundler.Gates run on the final branch state (after merging
origin/mainat #568, and again after each self-review fix):pnpm build,pnpm typecheck,pnpm lint,pnpm lint:release,pnpm test:unit,pnpm test:route-unit,pnpm test:projection, the integration files above,pnpm test:packed packages/agent-bundle/tests/packed-serve-app-command.test.ts,pnpm docs:site:build(en/zh parity).Self-review
Reviewer: a read-only reviewer subagent on gpt-5.6-sol-medium (TraceDecay was unavailable through both MCP and CLI during the review, so it worked from the diff and read-only source inspection), asked for concrete merge risks only — bugs, breaking changes, missed tests, doc or changeset gaps — against the diff vs
origin/main; four rounds, each re-run after the fixes the previous one asked for.routes/framework-imports.ts— binding detection compared identifier text globally and ignored lexical shadowing: a type-only import shadowed by a same-named parameter used at runtime was a falseAB4837. Reproduced.ts.Program+TypeChecker) to the import it actually refers to. Shadowing, namespace/default imports, JSX, decorators, re-exports, labels,import.meta, and.js/.mjsmodules are covered intests/route-framework-imports.test.ts. Confirmed resolved in round 2.routes/graph.ts— every root layout was scanned unconditionally, but layouts are inlined only into rendered routes, and (round 2) plain CLI routes and plain scripts do not import layouts, plain scripts do not mount providers either: valid projects could get a false build-blockingAB4837.cli.commands,isRenderedScriptRoutescripts); providers where a generated executable mounts them (generated server, generated CLI, rendered script, event route) and not for plain.tsscripts.tests/route-graph.test.tsgained App-only, plain-CLI, rendered-CLI, plain-script, and rendered-script cases;docs/diagnostics.mdrow reworded. Confirmed resolved in round 3.serve-app-command.ts— a childerrorafter readiness (a failedkill()) calledfinish()with a synthetic exit, soclosedresolved while the process was still alive; (round 2) merely storing the error instead leftspawnServeApp()/close()hanging on a refusedkill(); (round 3) the already-aborted re-check calledstop()before the child'serrorlistener existed, so a synchronous EPERM fromkill()rejected with an untyped error and leaked the child. All three reproduced.closedsettles only on the realcloseevent; a post-spawnerroris kept as thecauseshould the child then exit before ready; a refusedkill()is the newstop-failedcode, thrown byclose()and the rejection of a pre-ready abort; the child'sdata/spawn/error/closelisteners are attached before the AbortSignal is registered or re-checked, and anerroremitted whilestop()runs is classified as the stop failure ahead of the not-yet-firedspawnflag. Each path has an integration test that failed on the preceding commit. Confirmed resolved in round 4..changeset/558-serve-app-command.mdended with the issue number(#558)instead of the PR(#582).Round 4: "No remaining concrete merge risks found. Recommendation: merge."