diff --git a/.changeset/xref-one-rslib-instance-per-target.md b/.changeset/xref-one-rslib-instance-per-target.md new file mode 100644 index 000000000..c3a838a16 --- /dev/null +++ b/.changeset/xref-one-rslib-instance-per-target.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': patch +--- + +Compile every agent-host surface of a target — the routed CLI bin, bundled scripts, hook wrappers, MCP stdio entries, and their react-server Flight workers — through one Rslib instance per target instead of one instance per surface, with the optional browser MCP Apps stage ordered first only for targets that declare App routes; `agent-bundle build`, `dev`, and `prepack` emit byte-identical artifacts with the same manifest source inputs while spending less time in bundler setup and stats collection (#503) diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 39e2fdf21..12731fa97 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -485,6 +485,32 @@ Hook tool selectors a host cannot map still fail at plan time ## Distribution +### How a target compiles + +`agent-bundle build` plans every target before it compiles anything, then +lowers each target's outputs in at most two stages into one staged root that +is published atomically once the artifact validates +(`src/build/target-stages.ts`): + +1. **MCP Apps** — the browser environment, compiled through the workspace + `@rsbuild/core`. This stage exists only for a target whose project + declares App routes and always runs first: the MCP entries embed its + emitted HTML. +2. **Agent-host surfaces** — the routed CLI bin, bundled scripts, hook + wrappers, MCP stdio entries, and each surface's react-server Flight worker. + All of them lower together through **one Rslib instance per target**: one + Rsbuild environment per output, compiled by one Rspack multi-compiler. + A host surface reaches its Flight worker by file name at run time, never + through a build-time manifest, so nothing orders the two within the stage; + each surface keeps its own authored-source evidence for the manifest. + +Every synthesized bundler config — both stages plus the `dist/` package build +— composes the same way: the framework profile, then the consumer's +`tools.rsbuild` fragment, then the `tools.rspack` hatch, then the framework +invariant layer that no hatch value can override +(`src/build/compose-layers.ts`; see the `tools` section of the configuration +reference). `agent-bundle inspect --bundler` prints the result. + `agent-bundle build` makes each target directory independently distributable. Every target includes `INSTALL.md` generated with its real plugin and marketplace names. Claude and Codex bundles include local marketplace manifests diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index f81672c77..b37bd1ac7 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -11,25 +11,27 @@ import { pathTokens, type AgentBundleToolsConfig, type NormalizedPlugin } from ' import { assertInside, isInsideOrEqual } from '../core/paths.ts'; import { agentSkillsSchemaRevision } from '../schemas/agent-skills/contract.ts'; import { - compileEntries, - compileHooks, - compileMcpEntries, planCompiledEntries, planCompiledHooks, planCompiledMcpEntries, + planHooksSurface, + planMcpEntriesSurface, + planScriptsSurface, type CompiledEntry, type CompiledHookEntry, type CompiledMcpEntry, } from './entries.ts'; import { cliBinCollisionDiagnostics, - compileCliBins, + planCliBinsSurface, planCompiledCliBins, targetHostsCliBin, type CompiledCliBin, } from './cli-bins.ts'; import { projectMeta } from './meta.ts'; import { compileMcpApps, planCompiledMcpApps, type CompiledMcpApp } from './mcp-apps.ts'; +import { compileRslibSurfaces, settledRslibSurface } from './rslib.ts'; +import { planTargetStages } from './target-stages.ts'; import { assertUniqueArtifactDestinations, artifactHookIndexName, @@ -388,73 +390,84 @@ export const build = async (options: BuildOptions): Promise => { // One identity feeds every compiled surface, exactly the identity the // manifest, `inspect`, and dev status report (issue #237). const meta = projectMeta(options.model.metadata); + const plugin = { name: options.model.metadata.name, version: options.model.metadata.version }; for (const target of stagedTargets) { - // MCP Apps compile first: their Rsbuild pass asserts the target root - // holds nothing but its own HTML, so every other surface follows it. - const targetMcpApps = await compileMcpApps(options.model.mcpApps ?? [], { - cwd: options.projectRoot, - meta, - outDir: target.root, - target: target.name, - ...tools, - }); - compiledMcpApps.push(...targetMcpApps); - await emitPlanEntries({ entries: target.entries, root: target.root }); - if (target.cliBin) { - compiledCliBins.push(...(await compileCliBins(options.model, { - cwd: options.projectRoot, - meta, - outDir: target.root, - target: target.name, - ...tools, - }))); + let targetMcpApps: readonly CompiledMcpApp[] = Object.freeze([]); + for (const stage of planTargetStages(target)) { + switch (stage.kind) { + case 'mcp-apps': + // The optional browser stage, always first: the MCP entries + // embed its HTML, and its Rsbuild pass asserts the target root + // holds nothing but that HTML. + targetMcpApps = await compileMcpApps(options.model.mcpApps ?? [], { + cwd: options.projectRoot, + meta, + outDir: target.root, + target: target.name, + ...tools, + }); + compiledMcpApps.push(...targetMcpApps); + break; + case 'node-surfaces': { + await emitPlanEntries({ entries: target.entries, root: target.root }); + const noticeDelivery = options.registry.noticeDelivery(target.name); + // Every agent-host surface of the target lowers through one Rslib + // instance; each surface keeps its own evidence and result. + const [cliBins, scripts, hooks, mcpEntries] = await compileRslibSurfaces( + { cwd: options.projectRoot, meta, outputRoot: target.root, ...tools }, + [ + target.cliBin + ? planCliBinsSurface(options.model, { outDir: target.root, target: target.name }) + : settledRslibSurface(Object.freeze([])), + await planScriptsSurface( + options.model.scripts.filter((script) => script.targets.includes(target.name)), + { + cwd: options.projectRoot, + layouts: options.model.layouts ?? [], + outDir: target.root, + ...noticePolicy, + providers: options.model.providers ?? [], + ...(options.model.state === undefined ? {} : { state: options.model.state }), + }, + ), + planHooksSurface(target.hookEntries, { + artifactEpoch: options.projectContext.revision, + ...(noticeDelivery === undefined ? {} : { noticeDelivery }), + ...noticePolicy, + outDir: target.root, + plugin, + providers: options.model.providers ?? [], + ...(options.model.state === undefined ? {} : { state: options.model.state }), + }), + await planMcpEntriesSurface(options.model.mcpServers, { + apps: targetMcpApps, + artifactEpoch: options.projectContext.revision, + eventHooks: target.hookEntries + .filter((entry) => entry.hook.eventRoute !== undefined) + .map((entry) => entry.hook), + layouts: options.model.layouts ?? [], + ...(noticeDelivery === undefined ? {} : { noticeDelivery }), + ...noticePolicy, + outDir: target.root, + plugin, + providers: options.model.providers ?? [], + ...(options.model.state === undefined ? {} : { state: options.model.state }), + target: target.name, + }), + ], + ); + compiledCliBins.push(...cliBins); + compiledEntries.push(...scripts); + compiledHooks.push(...hooks); + compiledMcpEntries.push(...mcpEntries); + break; + } + default: { + const exhaustive: never = stage; + throw new Error(`Unknown target compile stage ${JSON.stringify(exhaustive)}.`); + } + } } - compiledEntries.push( - ...(await compileEntries( - options.model.scripts.filter((script) => script.targets.includes(target.name)), - { - cwd: options.projectRoot, - layouts: options.model.layouts ?? [], - meta, - outDir: target.root, - ...noticePolicy, - providers: options.model.providers ?? [], - ...(options.model.state === undefined ? {} : { state: options.model.state }), - ...tools, - }, - )), - ); - const noticeDelivery = options.registry.noticeDelivery(target.name); - compiledHooks.push(...(await compileHooks(target.hookEntries, { - artifactEpoch: options.projectContext.revision, - cwd: options.projectRoot, - meta, - ...(noticeDelivery === undefined ? {} : { noticeDelivery }), - ...noticePolicy, - outDir: target.root, - plugin: { name: options.model.metadata.name, version: options.model.metadata.version }, - providers: options.model.providers ?? [], - ...(options.model.state === undefined ? {} : { state: options.model.state }), - ...tools, - }))); - compiledMcpEntries.push(...(await compileMcpEntries(options.model.mcpServers, { - apps: targetMcpApps, - artifactEpoch: options.projectContext.revision, - cwd: options.projectRoot, - eventHooks: target.hookEntries - .filter((entry) => entry.hook.eventRoute !== undefined) - .map((entry) => entry.hook), - layouts: options.model.layouts ?? [], - meta, - ...(noticeDelivery === undefined ? {} : { noticeDelivery }), - ...noticePolicy, - outDir: target.root, - plugin: { name: options.model.metadata.name, version: options.model.metadata.version }, - providers: options.model.providers ?? [], - ...(options.model.state === undefined ? {} : { state: options.model.state }), - target: target.name, - ...tools, - }))); } const publishedCompiledEntries = deepFreeze(compiledEntries.map((entry) => ({ diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index 3979390f0..5d5fc2bff 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -4,8 +4,7 @@ import { cliBinCapability } from '../adapters/capability-state.ts'; import type { TargetRegistry } from '../adapters/registry.ts'; import { routedCliBinLayout, type TargetArtifactEntry } from '../adapters/types.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; -import type { AgentBundleToolsConfig, NormalizedBinEntry, NormalizedPlugin } from '../core/types.ts'; -import type { AgentBundleMeta } from '../meta.ts'; +import type { NormalizedBinEntry, NormalizedPlugin } from '../core/types.ts'; import { resolveArtifactDestination } from './emit.ts'; import { runtimeIgnoredRoot, type CompiledEntry } from './entries.ts'; import { @@ -14,7 +13,7 @@ import { generatedCliBinEntrySource, generatedRenderedRouteWorkerSource, } from './entry-shell.ts'; -import { buildWithRslib, type RslibEntry } from './rslib.ts'; +import type { RslibEntry, RslibSurfacePlan } from './rslib.ts'; /** * The artifact-hosted routed CLI (#387). A generated-mode `src/cli/**` @@ -159,48 +158,44 @@ export const cliBinRslibEntries = ( return entries; }); -export const compileCliBins = async ( +/** + * Plans the routed CLI bin of one hosting target (#387) as a surface of the + * target's shared Rslib run: the executable plus, for rendered commands, its + * react-server Flight worker. + */ +export const planCliBinsSurface = ( model: NormalizedPlugin, - options: { - readonly cwd: string; - readonly meta: AgentBundleMeta; - readonly outDir: string; - readonly target: string; - readonly tools?: AgentBundleToolsConfig; - }, -): Promise => { + options: { readonly outDir: string; readonly target: string }, +): RslibSurfacePlan => { const planned = planCompiledCliBins(model, options); - if (planned.length === 0) return Object.freeze([]); - const evidence = await buildWithRslib({ - cwd: options.cwd, - entries: cliBinRslibEntries(planned, model), + return { + entries: planned.length === 0 ? [] : cliBinRslibEntries(planned, model), + finish: async (evidence) => { + const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs])); + const bundledInputs = (path: string, label: string): readonly string[] => { + const inputs = evidenceByPath.get(path); + if (inputs === undefined) throw new Error(`Missing bundled routed CLI ${label} evidence for ${JSON.stringify(path)}.`); + return inputs; + }; + return Object.freeze(planned.map((entry): CompiledCliBin => Object.freeze({ + id: entry.id, + name: entry.name, + output: entry.output, + outputKind: entry.outputKind, + source: entry.source, + sourceInputs: bundledInputs(cliBinArtifactPath(entry.name), 'executable'), + target: entry.target, + ...(entry.workerOutput === undefined + ? {} + : { + workerOutput: entry.workerOutput, + workerSourceInputs: bundledInputs(cliBinWorkerArtifactPath(entry.name), 'worker'), + }), + }))); + }, ignoredSourcePaths: [runtimeIgnoredRoot(cliEntryRuntimePath())], logLevel: 'error', - meta: options.meta, - outputRoot: options.outDir, - ...(options.tools === undefined ? {} : { tools: options.tools }), - }); - const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs])); - const bundledInputs = (path: string, label: string): readonly string[] => { - const inputs = evidenceByPath.get(path); - if (inputs === undefined) throw new Error(`Missing bundled routed CLI ${label} evidence for ${JSON.stringify(path)}.`); - return inputs; }; - return Object.freeze(planned.map((entry): CompiledCliBin => Object.freeze({ - id: entry.id, - name: entry.name, - output: entry.output, - outputKind: entry.outputKind, - source: entry.source, - sourceInputs: bundledInputs(cliBinArtifactPath(entry.name), 'executable'), - target: entry.target, - ...(entry.workerOutput === undefined - ? {} - : { - workerOutput: entry.workerOutput, - workerSourceInputs: bundledInputs(cliBinWorkerArtifactPath(entry.name), 'worker'), - }), - }))); }; /** diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 814cf467d..028d8f23a 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -12,14 +12,12 @@ import { type TargetHookEntry, } from '../adapters/hook-contract.ts'; import type { - AgentBundleToolsConfig, NormalizedHook, NormalizedMcpServer, NormalizedNoticeRetentionPolicy, NormalizedScript, NormalizedStateDefinition, } from '../core/types.ts'; -import type { AgentBundleMeta } from '../meta.ts'; import { mcpEntryAliasPattern } from '../config/normalize.ts'; import { stableJson } from '../core/digest.ts'; import { emitPlanEntries, resolveArtifactDestination } from './emit.ts'; @@ -43,7 +41,7 @@ import { import { emptyRouteConfig, type CompiledLayout, type CompiledProvider } from '../routes/types.ts'; import type { CompiledMcpApp } from './mcp-apps.ts'; import type { ArtifactOutputKind } from './provenance.ts'; -import { buildWithRslib } from './rslib.ts'; +import type { RslibSurfacePlan } from './rslib.ts'; const eventRuntimeModulePath = (module: 'ipc' | 'project'): string => { for (const candidate of [ @@ -156,26 +154,29 @@ export const planCompiledEntries = ( }).map((entry) => Object.freeze(entry))); }; -export const compileEntries = async ( +/** + * Plans the artifact scripts of one target as a surface of the target's + * shared Rslib run: bundled scripts (with the react-server Flight worker + * beside each rendered one) compile there; copied scripts are emitted when + * the surface finishes. + */ +export const planScriptsSurface = async ( entries: readonly NormalizedScript[], options: { readonly cwd: string; readonly layouts?: readonly CompiledLayout[]; - readonly meta: AgentBundleMeta; readonly outDir: string; readonly providers?: readonly CompiledProvider[]; readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; - readonly tools?: AgentBundleToolsConfig; }, -): Promise => { +): Promise> => { const compiled = planCompiledEntries(entries, options); const bundled = compiled.filter((entry) => entry.mode === 'bundle'); const cliRuntimeShell = bundled.some((entry) => entry.rendered !== undefined) ? cliEntryRuntimePath() : undefined; - const evidence = await buildWithRslib({ - cwd: options.cwd, + return { entries: await Promise.all(bundled.flatMap((entry) => { const { name, rendered, source, sourceInputs } = entry; if (rendered !== undefined) { @@ -243,33 +244,32 @@ export const compileEntries = async ( })()]; })), ...(cliRuntimeShell === undefined ? {} : { ignoredSourcePaths: [runtimeIgnoredRoot(cliRuntimeShell)] }), - meta: options.meta, - outputRoot: options.outDir, - ...(options.tools === undefined ? {} : { tools: options.tools }), - }); - await emitPlanEntries({ - entries: await Promise.all(compiled - .filter((entry) => entry.mode === 'copy') - .map(async (entry) => ({ - bytes: (await stat(entry.source)).size, - kind: 'copy' as const, - relativePath: relative(options.outDir, entry.output).replaceAll('\\', '/'), - source: entry.source, - sourceInputs: entry.sourceInputs, - }))), - root: options.outDir, - }); + finish: async (evidence) => { + await emitPlanEntries({ + entries: await Promise.all(compiled + .filter((entry) => entry.mode === 'copy') + .map(async (entry) => ({ + bytes: (await stat(entry.source)).size, + kind: 'copy' as const, + relativePath: relative(options.outDir, entry.output).replaceAll('\\', '/'), + source: entry.source, + sourceInputs: entry.sourceInputs, + }))), + root: options.outDir, + }); - const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs])); - return Object.freeze(compiled.map((entry) => Object.freeze({ - ...entry, - sourceInputs: entry.mode === 'bundle' - ? evidenceByPath.get(`scripts/${entry.name}.mjs`) ?? (() => { throw new Error(`Missing bundled script evidence for ${JSON.stringify(entry.name)}.`); })() - : entry.sourceInputs, - ...(entry.rendered === undefined ? {} : { - workerSourceInputs: evidenceByPath.get(`scripts/${entry.rendered.workerFile}`) ?? (() => { throw new Error(`Missing bundled script worker evidence for ${JSON.stringify(entry.name)}.`); })(), - }), - }))); + const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs])); + return Object.freeze(compiled.map((entry) => Object.freeze({ + ...entry, + sourceInputs: entry.mode === 'bundle' + ? evidenceByPath.get(`scripts/${entry.name}.mjs`) ?? (() => { throw new Error(`Missing bundled script evidence for ${JSON.stringify(entry.name)}.`); })() + : entry.sourceInputs, + ...(entry.rendered === undefined ? {} : { + workerSourceInputs: evidenceByPath.get(`scripts/${entry.rendered.workerFile}`) ?? (() => { throw new Error(`Missing bundled script worker evidence for ${JSON.stringify(entry.name)}.`); })(), + }), + }))); + }, + }; }; const localMcpOutputName = (server: NormalizedMcpServer): string => { @@ -318,15 +318,19 @@ export const planCompiledMcpEntries = ( })); }; -export const compileMcpEntries = async ( +/** + * Plans the local MCP servers of one target as a surface of the target's + * shared Rslib run: each stdio entry plus the react-server Flight worker of + * each route-generated server. The compiled MCP App HTML is read here, so + * the target's browser stage must have finished before this plan is made. + */ +export const planMcpEntriesSurface = async ( servers: readonly NormalizedMcpServer[], options: { readonly apps?: readonly CompiledMcpApp[]; readonly artifactEpoch: string; - readonly cwd: string; readonly eventHooks: readonly NormalizedHook[]; readonly layouts?: readonly CompiledLayout[]; - readonly meta: AgentBundleMeta; /** The target adapter's notice delivery advertisement; absent wires no cross-request route. */ readonly noticeDelivery?: NoticeDeliveryAdvertisement; readonly outDir: string; @@ -335,9 +339,8 @@ export const compileMcpEntries = async ( readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; readonly target: string; - readonly tools?: AgentBundleToolsConfig; }, -): Promise => { +): Promise> => { const compiled = planCompiledMcpEntries(servers, options); const eventHostId = compiled.find((entry) => servers.find((server) => server.id === entry.id)?.generatedRoutes !== undefined)?.id; @@ -465,8 +468,7 @@ export const compileMcpEntries = async ( virtualSource: workerSource, }]; }); - const evidence = await buildWithRslib({ - cwd: options.cwd, + return { entries: [...mainEntries, ...workerEntries], ...([runtimeShell, eventIpcRuntime, serverRuntime].filter((path): path is string => path !== undefined).length === 0 ? {} @@ -477,19 +479,18 @@ export const compileMcpEntries = async ( ...(serverRuntime === undefined ? [] : [runtimeIgnoredRoot(serverRuntime)]), ], }), + finish: async (evidence) => { + const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs])); + return Object.freeze(compiled.map((entry) => Object.freeze({ + ...entry, + sourceInputs: evidenceByPath.get(`mcp/${entry.name}.mjs`) ?? (() => { throw new Error(`Missing bundled MCP evidence for ${JSON.stringify(entry.name)}.`); })(), + ...(entry.workerOutput === undefined ? {} : { + workerSourceInputs: evidenceByPath.get(`mcp/${entry.name}-flight.mjs`) ?? (() => { throw new Error(`Missing bundled MCP Flight worker evidence for ${JSON.stringify(entry.name)}.`); })(), + }), + }))); + }, logLevel: 'error', - meta: options.meta, - outputRoot: options.outDir, - ...(options.tools === undefined ? {} : { tools: options.tools }), - }); - const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs])); - return Object.freeze(compiled.map((entry) => Object.freeze({ - ...entry, - sourceInputs: evidenceByPath.get(`mcp/${entry.name}.mjs`) ?? (() => { throw new Error(`Missing bundled MCP evidence for ${JSON.stringify(entry.name)}.`); })(), - ...(entry.workerOutput === undefined ? {} : { - workerSourceInputs: evidenceByPath.get(`mcp/${entry.name}-flight.mjs`) ?? (() => { throw new Error(`Missing bundled MCP Flight worker evidence for ${JSON.stringify(entry.name)}.`); })(), - }), - }))); + }; }; export const planCompiledHooks = ( @@ -522,21 +523,23 @@ export const planCompiledHooks = ( }))); }; -export const compileHooks = async ( +/** + * Plans the hook wrappers of one target as a surface of the target's shared + * Rslib run, plus the one standalone react-server Flight worker the + * event-routed hooks share. + */ +export const planHooksSurface = ( entries: readonly TargetHookEntry[], options: { readonly artifactEpoch: string; - readonly cwd: string; - readonly meta: AgentBundleMeta; readonly noticeDelivery?: NoticeDeliveryAdvertisement; readonly outDir: string; readonly plugin: { readonly name: string; readonly version: string }; readonly providers?: readonly CompiledProvider[]; readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; - readonly tools?: AgentBundleToolsConfig; }, -): Promise => { +): RslibSurfacePlan => { const compiled = planCompiledHooks(entries, options); const routeEntries = entries.filter((entry) => entry.hook.eventRoute !== undefined); const standaloneEventRoutes = [...new Map(routeEntries @@ -572,8 +575,7 @@ export const compileHooks = async ( ...(options.state === undefined ? {} : { state: options.state }), }), }; - const evidence = await buildWithRslib({ - cwd: options.cwd, + return { entries: [ ...compiled.map((entry, index) => ({ // One hook can compile into several host wrappers (for example a shared @@ -606,16 +608,15 @@ export const compileHooks = async ( : { ignoredSourcePaths: [runtimeIgnoredRoot(eventIpcRuntime)], }), - meta: options.meta, - outputRoot: options.outDir, - ...(options.tools === undefined ? {} : { tools: options.tools }), - }); - const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs])); - return Object.freeze(compiled.map((entry, index) => Object.freeze({ - ...entry, - sourceInputs: evidenceByPath.get(entries[index]!.relativePath) ?? (() => { throw new Error(`Missing bundled hook evidence for ${JSON.stringify(entry.name)}.`); })(), - ...(entry.workerOutput === undefined ? {} : { - workerSourceInputs: evidenceByPath.get('hooks/hooks-flight.mjs') ?? (() => { throw new Error('Missing bundled hook Flight worker evidence.'); })(), - }), - }))); + finish: async (evidence) => { + const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs])); + return Object.freeze(compiled.map((entry, index) => Object.freeze({ + ...entry, + sourceInputs: evidenceByPath.get(entries[index]!.relativePath) ?? (() => { throw new Error(`Missing bundled hook evidence for ${JSON.stringify(entry.name)}.`); })(), + ...(entry.workerOutput === undefined ? {} : { + workerSourceInputs: evidenceByPath.get('hooks/hooks-flight.mjs') ?? (() => { throw new Error('Missing bundled hook Flight worker evidence.'); })(), + }), + }))); + }, + }; }; diff --git a/packages/agent-bundle/src/build/provenance.ts b/packages/agent-bundle/src/build/provenance.ts index 67b3ac679..650552365 100644 --- a/packages/agent-bundle/src/build/provenance.ts +++ b/packages/agent-bundle/src/build/provenance.ts @@ -22,6 +22,13 @@ export interface ArtifactOutputCandidate { } export interface BundledOutputCandidate { + /** + * Module roots excluded from this asset's authored-source evidence, on top + * of the roots every asset of the run excludes. Surfaces sharing one + * bundler run keep their own exclusions this way (a hook wrapper inlines + * the event runtime; a script never does). + */ + readonly ignoredSourcePaths?: readonly string[]; /** Asset path reported by the bundler, relative to its output root. */ readonly path: string; /** Absolute authored inputs known by the compiler before bundling. */ @@ -180,7 +187,33 @@ export const collectBundledOutputEvidence = (options: { if (options.stats === undefined) { throw new Error('Bundler build result did not include public stats for output provenance.'); } - const json = asRecord(options.stats.toJson({ assets: true, children: true, modules: true, nestedModules: true })); + // The evidence reads each compilation's asset names and chunk ids, its + // module list with every module's `chunks`, `nameForCondition`, + // `identifier`, `name`, `moduleType`, and concatenated `modules`, and the + // `filtered*` counters. Everything else Rspack renders into JSON by + // default — per-module reasons, export usage, optimization bailouts, + // depth, module traces, errors — is paid for by walking every module of + // every environment and is switched off; the module list itself (orphans, + // runtime modules, cached modules) stays complete. + const json = asRecord(options.stats.toJson({ + assets: true, + children: true, + chunkOrigins: false, + depth: false, + errorDetails: false, + errors: false, + moduleAssets: false, + moduleTrace: false, + modules: true, + nestedModules: true, + optimizationBailout: false, + providedExports: false, + reasons: false, + relatedAssets: false, + source: false, + usedExports: false, + warnings: false, + })); if (json === undefined) throw new Error('Bundler stats were not an object.'); throwOnFilteredStats(json); const compilations = flattenCompilations(json); @@ -206,7 +239,9 @@ export const collectBundledOutputEvidence = (options: { compilation: match.compilation, allowUnassociatedHtml: expected.allowUnassociatedHtml === true, explicitInputs: expected.sourceInputs, - ignoredSourcePaths, + ignoredSourcePaths: expected.ignoredSourcePaths === undefined + ? ignoredSourcePaths + : [...ignoredSourcePaths, ...expected.ignoredSourcePaths], projectRoot: options.projectRoot, }), }); diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 36a471bd1..451a64ab1 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -96,7 +96,18 @@ const asRslibRspackHatch = ( hatch: NonNullable, ): RslibToolsRspack => hatch as RslibToolsRspack; -const entryLibId = (entry: Pick): string => `agent-bundle-${entry.name}`; +/** + * The Rslib lib id — and so the Rsbuild environment name and the Rspack + * compiler name — of an entry. It derives from the artifact destination, + * which the planner already asserts unique within a target, rather than + * from the entry name: surfaces sharing one run may legitimately reuse a + * name (a script authored as `hooks-flight` emits `scripts/hooks-flight.mjs` + * beside the hook surface's standalone worker `hooks/hooks-flight.mjs`). + * Visible only in `inspect --bundler` and bundler stats, never in emitted + * bytes. + */ +export const entryLibId = (entry: Pick): string => + `agent-bundle-${entry.outputRelativePath.replace(/\.[^./]+$/u, '').replaceAll('/', '-')}`; /** * rsbuild-plugin-dts aborts a failed declaration pass with a stackless prose @@ -360,10 +371,10 @@ const assertExecutableConfig = ( if (environment === undefined) { throw new Error('Rslib did not resolve one environment for every generated executable.'); } - // Scripts, MCP entries, hooks, and MCP Apps build sequentially into one - // shared staged root, so an environment that cleans its dist path would - // delete sibling outputs already emitted there; the composed invariant - // pins it off after the hatch merge. + // Every surface of a target builds into one shared staged root (MCP Apps + // first, then the node surfaces together), so an environment that cleans + // its dist path would delete sibling outputs already emitted there; the + // composed invariant pins it off after the hatch merge. if (environment.output?.cleanDistPath !== false) { throw new Error('Rslib resolved a generated executable environment that would clean its own output root.'); } @@ -585,30 +596,94 @@ export const composeEntryLibConfig = ( return lib; }; -export const buildWithRslib = async (options: { - readonly cwd: string; +/** + * One compiled surface's share of a target's Rslib run: the entries it + * synthesizes and the module roots its authored-source evidence excludes. + * Every surface of one target (routed CLI bin, scripts, hook wrappers, MCP + * entries, and each one's react-server Flight worker) rides one Rslib + * instance — one Rsbuild environment per entry, compiled by one Rspack + * multi-compiler in parallel — instead of one sequential instance per + * surface. Surfaces keep their own evidence exclusions and results. + */ +export interface RslibSurface { readonly entries: readonly RslibEntry[]; - /** Extra module roots excluded from authored-source evidence (e.g. the aliased runtime shell). */ + /** Extra module roots excluded from this surface's authored-source evidence (e.g. an aliased runtime shell). */ readonly ignoredSourcePaths?: readonly string[]; - /** 'error' lets declaration-generation failures reach the consumer's terminal. */ + /** 'error' lets bundler and declaration-generation failures reach the consumer's terminal. */ readonly logLevel?: 'error' | 'silent'; +} + +/** + * A surface planned for a shared Rslib run: its entries plus the step that + * turns the run's evidence for those entries back into compiled records. + * Planning is separated from finishing so the orchestrator can gather every + * surface of a target first and lower them together in one instance. + */ +export interface RslibSurfacePlan extends RslibSurface { + readonly finish: (evidence: readonly BundledOutputEvidence[]) => Promise; +} + +/** A surface with nothing to compile whose result is already settled (e.g. a target that hosts no CLI bin). */ +export const settledRslibSurface = (result: Result): RslibSurfacePlan => ({ + entries: Object.freeze([]), + finish: async () => result, +}); + +export interface RslibRunOptions { + readonly cwd: string; /** The project identity served to plugin source as `agent-bundle/meta`. */ readonly meta: AgentBundleMeta; readonly outputRoot: string; /** The consumer escape hatch, merged last-but-bounded into every synthesized entry. */ readonly tools?: AgentBundleToolsConfig; -}, dependencies: RslibDependencies = {}): Promise => { - if (options.entries.length === 0) { - return Object.freeze([]); +} + +/** + * Lib ids key Rslib environments and `mergeRslibConfig` folds same-id libs + * into one, so two entries of one run may not share an id. Ids derive from + * artifact destinations the planner already rejects as duplicates, so this + * is an internal invariant rather than a consumer-facing diagnostic. + */ +const assertDistinctLibIds = (entries: readonly RslibEntry[]): void => { + const seen = new Map(); + for (const entry of entries) { + const id = entryLibId(entry); + const previous = seen.get(id); + if (previous !== undefined) { + throw new Error( + `Rslib surfaces of one target synthesize the same lib id ${JSON.stringify(id)} for ` + + `${JSON.stringify(previous)} and ${JSON.stringify(entry.outputRelativePath)}.`, + ); + } + seen.set(id, entry.outputRelativePath); } +}; + +/** + * Lowers every surface's entries through one Rslib instance and returns the + * bundled evidence per surface, in surface order. A surface without entries + * contributes nothing and receives no evidence; with no entries at all no + * instance is created. + */ +export const buildRslibSurfaces = async ( + options: RslibRunOptions, + surfaces: readonly RslibSurface[], + dependencies: RslibDependencies = {}, +): Promise => { + const entries = surfaces.flatMap((surface) => surface.entries); + if (entries.length === 0) { + return Object.freeze(surfaces.map(() => Object.freeze([]))); + } + assertDistinctLibIds(entries); const dependencyRoots = await declaredDependencyRoots(options.cwd); const reservedExternalViolations: string[] = []; const rslib = await (dependencies.createRslib ?? createRslib)({ cwd: options.cwd, config: { - logLevel: options.logLevel ?? 'silent', - lib: options.entries.map((entry) => composeEntryLibConfig(entry, { + // The run reports at the most verbose level any surface asks for. + logLevel: surfaces.some((surface) => surface.logLevel === 'error') ? 'error' : 'silent', + lib: entries.map((entry) => composeEntryLibConfig(entry, { meta: options.meta, onReservedExternal: (specifier) => reservedExternalViolations.push(specifier), outputRoot: options.outputRoot, @@ -618,7 +693,7 @@ export const buildWithRslib = async (options: { }); const inspection = await rslib.inspectConfig(); - assertExecutableConfig(options.entries, inspection.origin, options.outputRoot, options.meta); + assertExecutableConfig(entries, inspection.origin, options.outputRoot, options.meta); let result: Awaited> | undefined; try { try { @@ -631,23 +706,57 @@ export const buildWithRslib = async (options: { } if (reservedExternalViolations.length > 0) throw reservedExternalError(reservedExternalViolations[0]!); const evidence = collectBundledOutputEvidence({ - expectedAssets: options.entries.map((entry) => ({ + expectedAssets: surfaces.flatMap((surface) => surface.entries.map((entry) => ({ + ...(surface.ignoredSourcePaths === undefined ? {} : { ignoredSourcePaths: surface.ignoredSourcePaths }), path: entry.outputRelativePath, sourceInputs: entry.sourceInputs, - })), + }))), ignoredSourcePaths: [ // Generated wrapper/registry modules are virtual, but they still // surface in stats as modules under this reserved namespace. resolve(options.outputRoot, generatedModulesDirname), - ...(options.ignoredSourcePaths ?? []), ...dependencyRoots, ], projectRoot: options.cwd, stats: result.stats, }); - await assertNoResidualReservedImports(options.entries, options.outputRoot); - return evidence; + await assertNoResidualReservedImports(entries, options.outputRoot); + // Evidence covers exactly every entry (a missing asset already threw), + // sorted by path; each surface takes back its own entries' records. + return Object.freeze(surfaces.map((surface) => { + const paths = new Set(surface.entries.map((entry) => entry.outputRelativePath)); + return Object.freeze(evidence.filter((record) => paths.has(record.path))); + })); } finally { await result?.close(); } }; + +/** Lowers every planned surface in one Rslib run and finishes each with its own evidence, in order. */ +export const compileRslibSurfaces = async []>( + options: RslibRunOptions, + plans: Plans, +): Promise<{ readonly [Index in keyof Plans]: Plans[Index] extends RslibSurfacePlan ? Result : never }> => { + const evidence = await buildRslibSurfaces(options, plans); + const results: unknown[] = []; + // Sequential on purpose: a finish step may emit sibling files into the + // shared staged root, and the former per-surface order is preserved. + for (const [index, plan] of plans.entries()) { + results.push(await plan.finish(evidence[index]!)); + } + return results as { readonly [Index in keyof Plans]: Plans[Index] extends RslibSurfacePlan ? Result : never }; +}; + +/** The single-surface run: the package build and direct callers. */ +export const buildWithRslib = async ( + options: RslibRunOptions & RslibSurface, + dependencies: RslibDependencies = {}, +): Promise => { + const { entries, ignoredSourcePaths, logLevel, ...run } = options; + const [evidence] = await buildRslibSurfaces(run, [{ + entries, + ...(ignoredSourcePaths === undefined ? {} : { ignoredSourcePaths }), + ...(logLevel === undefined ? {} : { logLevel }), + }], dependencies); + return evidence!; +}; diff --git a/packages/agent-bundle/src/build/target-stages.ts b/packages/agent-bundle/src/build/target-stages.ts new file mode 100644 index 000000000..19006ca24 --- /dev/null +++ b/packages/agent-bundle/src/build/target-stages.ts @@ -0,0 +1,62 @@ +import type { CompiledCliBin } from './cli-bins.ts'; +import type { CompiledEntry, CompiledHookEntry, CompiledMcpEntry } from './entries.ts'; +import type { CompiledMcpApp } from './mcp-apps.ts'; + +/** The planned outputs of one target, before anything compiles. */ +export interface PlannedTargetOutputs { + readonly compiledCliBins: readonly Pick[]; + readonly compiledEntries: readonly Pick[]; + readonly compiledHooks: readonly Pick[]; + readonly compiledMcpApps: readonly Pick[]; + readonly compiledMcpEntries: readonly Pick[]; +} + +/** + * One compile stage of a target, in dependency order. + * + * - `mcp-apps`: the browser environment — MCP App views through the + * workspace `@rsbuild/core`. Present only for a target that declares + * apps, and always first: the MCP entries embed its emitted HTML, and its + * pass asserts the target root holds nothing but that HTML. + * - `node-surfaces`: every agent-host surface — the routed CLI bin, bundled + * scripts, hook wrappers, MCP entries, and each surface's react-server + * Flight worker — lowered together through one Rslib instance (one + * Rsbuild environment per output, one Rspack multi-compiler). A Flight + * worker and the host surface that spawns it share this stage: the host + * reaches the worker by file name at run time, never through a build-time + * manifest, so nothing orders them within it. + */ +export type TargetCompileStage = + | { readonly kind: 'mcp-apps'; readonly outputs: readonly string[] } + | { readonly kind: 'node-surfaces'; readonly outputs: readonly string[] }; + +const withWorkers = ( + entries: readonly { readonly output: string; readonly workerOutput?: string }[], +): readonly string[] => entries.flatMap((entry) => [ + entry.output, + ...(entry.workerOutput === undefined ? [] : [entry.workerOutput]), +]); + +/** + * The compile stages of one target. The build runs them in this order; the + * browser stage is skipped entirely — no Rsbuild instance — for a target + * without MCP Apps, and the node stage creates no Rslib instance when it + * has no outputs. + */ +export const planTargetStages = (target: PlannedTargetOutputs): readonly TargetCompileStage[] => Object.freeze([ + ...(target.compiledMcpApps.length === 0 + ? [] + : [Object.freeze({ + kind: 'mcp-apps' as const, + outputs: Object.freeze(target.compiledMcpApps.map((app) => app.output)), + })]), + Object.freeze({ + kind: 'node-surfaces' as const, + outputs: Object.freeze([ + ...withWorkers(target.compiledCliBins), + ...withWorkers(target.compiledEntries.filter((entry) => entry.outputKind === 'bundle')), + ...withWorkers(target.compiledHooks), + ...withWorkers(target.compiledMcpEntries), + ]), + }), +]); diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 24dd828a6..5e48c9d8e 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -13,9 +13,9 @@ import { nativeHookWrapperSource, type TargetHookWrapper } from '../src/adapters import { build } from './support/build.ts'; import { runNodeScript } from './support/run-node-script.ts'; import { writeHookIndex } from '../src/build/emit.ts'; -import { compileHooks } from '../src/build/entries.ts'; +import { planHooksSurface } from '../src/build/entries.ts'; import { generatedMetaModulePath, metaModuleSpecifier, projectMeta } from '../src/build/meta.ts'; -import { buildWithRslib } from '../src/build/rslib.ts'; +import { buildWithRslib, compileRslibSurfaces } from '../src/build/rslib.ts'; import type { AgentBundleMeta } from '../src/meta.ts'; import { HookService, isHookSimulationCancellation } from '../src/services/hook-service.ts'; import { parseArtifactHookIndex } from '../src/build/hook-index.ts'; @@ -199,13 +199,13 @@ it('does not share a persistent Rslib cache between generated executables', asyn inspectConfig: async () => ({ origin: { bundlerConfigs: [{ - name: 'agent-bundle-cache-probe', + name: 'agent-bundle-hooks-cache-probe', output: { asyncChunks: false, path: outputRoot }, performance: { buildCache: false }, target: 'node', ...resolvedVirtualModules(outputRoot), }], - environmentConfigs: { 'agent-bundle-cache-probe': { output: { cleanDistPath: false } } }, + environmentConfigs: { 'agent-bundle-hooks-cache-probe': { output: { cleanDistPath: false } } }, }, }), }; @@ -268,12 +268,12 @@ it('closes the Rslib build result and serves the generated wrapper entry virtual origin: { bundlerConfigs: [{ entry: { 'close-probe': [virtualEntryPath] }, - name: 'agent-bundle-close-probe', + name: 'agent-bundle-hooks-close-probe', output: { asyncChunks: false, path: outputRoot }, target: 'node', ...resolvedVirtualModules(outputRoot), }], - environmentConfigs: { 'agent-bundle-close-probe': { output: { cleanDistPath: false } } }, + environmentConfigs: { 'agent-bundle-hooks-close-probe': { output: { cleanDistPath: false } } }, }, }), }; @@ -343,12 +343,12 @@ it('fails closed when the resolved environment lost its virtual modules or wrapp inspectConfig: async () => ({ origin: { bundlerConfigs: [{ - name: 'agent-bundle-lost-probe', + name: 'agent-bundle-hooks-lost-probe', output: { asyncChunks: false, path: outputRoot }, target: 'node', ...bundlerConfig, }], - environmentConfigs: { 'agent-bundle-lost-probe': { output: { cleanDistPath: false } } }, + environmentConfigs: { 'agent-bundle-hooks-lost-probe': { output: { cleanDistPath: false } } }, }, }), }); @@ -402,12 +402,12 @@ it('fails closed when an emitted bundle retains a residual reserved import', asy inspectConfig: async () => ({ origin: { bundlerConfigs: [{ - name: 'agent-bundle-residual-probe', + name: 'agent-bundle-hooks-residual-probe', output: { asyncChunks: false, path: outputRoot }, target: 'node', ...resolvedVirtualModules(outputRoot), }], - environmentConfigs: { 'agent-bundle-residual-probe': { output: { cleanDistPath: false } } }, + environmentConfigs: { 'agent-bundle-hooks-residual-probe': { output: { cleanDistPath: false } } }, }, }), }; @@ -442,12 +442,12 @@ it('closes the Rslib build result when provenance stats are unavailable', async inspectConfig: async () => ({ origin: { bundlerConfigs: [{ - name: 'agent-bundle-close-error-probe', + name: 'agent-bundle-hooks-close-error-probe', output: { asyncChunks: false, path: outputRoot }, target: 'node', ...resolvedVirtualModules(outputRoot), }], - environmentConfigs: { 'agent-bundle-close-error-probe': { output: { cleanDistPath: false } } }, + environmentConfigs: { 'agent-bundle-hooks-close-error-probe': { output: { cleanDistPath: false } } }, }, }), }; @@ -1141,13 +1141,14 @@ it('runs the Cursor workspace/open lifecycle starter through a generated wrapper expect(plan.diagnostics).toEqual([]); expect(generated).toBeDefined(); - await compileHooks(plan.hookEntries ?? [], { - artifactEpoch: 'cursor-workspace-open-test', - cwd: buildRoot, - meta: projectMeta(model.metadata), - outDir: outputRoot, - plugin: { name: model.metadata.name, version: model.metadata.version }, - }); + await compileRslibSurfaces( + { cwd: buildRoot, meta: projectMeta(model.metadata), outputRoot }, + [planHooksSurface(plan.hookEntries ?? [], { + artifactEpoch: 'cursor-workspace-open-test', + outDir: outputRoot, + plugin: { name: model.metadata.name, version: model.metadata.version }, + })], + ); expect(starter).toEqual({ cursor_version: 'lifecycle-replay', hook_event_name: 'workspaceOpen', diff --git a/packages/agent-bundle/tests/inspect-bundler.test.ts b/packages/agent-bundle/tests/inspect-bundler.test.ts index c595752e5..2a9198b3f 100644 --- a/packages/agent-bundle/tests/inspect-bundler.test.ts +++ b/packages/agent-bundle/tests/inspect-bundler.test.ts @@ -97,7 +97,7 @@ it('surfaces every synthesized bundler config with the tools hatch merged over t // The generated executable envelope wraps the `main` export. expect(script.generatedEntry).toContain('process.argv.slice(2)'); expect(script.config).toMatchObject({ - id: 'agent-bundle-tool', + id: 'agent-bundle-scripts-tool', // Routes are authored as TSX, so every Rslib entry carries the React // plugin: without it JSX lowers to a `React` factory that no generated // executable has in scope. diff --git a/packages/agent-bundle/tests/target-stages.test.ts b/packages/agent-bundle/tests/target-stages.test.ts new file mode 100644 index 000000000..55bc2b29b --- /dev/null +++ b/packages/agent-bundle/tests/target-stages.test.ts @@ -0,0 +1,225 @@ +import { rspack } from '@rslib/core'; +import { describe, expect, it } from '@rstest/core'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { generatedMetaModulePath, metaModuleSpecifier } from '../src/build/meta.ts'; +import { buildRslibSurfaces, compileRslibSurfaces, entryLibId, settledRslibSurface, type RslibEntry } from '../src/build/rslib.ts'; +import { planTargetStages } from '../src/build/target-stages.ts'; +import type { AgentBundleMeta } from '../src/meta.ts'; + +const meta: AgentBundleMeta = Object.freeze({ + name: 'stages-fixture', + packageName: undefined, + packageVersion: undefined, + version: '1.0.0', +}); + +const root = '/staged/claude'; + +describe('planTargetStages', () => { + const nodeOutputs = { + compiledCliBins: [{ output: `${root}/bin/tool.mjs`, workerOutput: `${root}/bin/tool-flight.mjs` }], + compiledEntries: [ + { output: `${root}/scripts/report.mjs`, outputKind: 'bundle' as const, workerOutput: `${root}/scripts/report-flight.mjs` }, + { output: `${root}/scripts/notes.md`, outputKind: 'copy' as const }, + ], + compiledHooks: [ + { output: `${root}/hooks/event-route-stop.mjs`, workerOutput: `${root}/hooks/hooks-flight.mjs` }, + { output: `${root}/hooks/event-route-tool-before.mjs` }, + ], + compiledMcpEntries: [{ output: `${root}/mcp/server.mjs`, workerOutput: `${root}/mcp/server-flight.mjs` }], + }; + + it('skips the browser stage entirely for a target without MCP Apps and lowers every host surface in one node stage', () => { + const stages = planTargetStages({ ...nodeOutputs, compiledMcpApps: [] }); + expect(stages.map((stage) => stage.kind)).toEqual(['node-surfaces']); + expect(stages[0]!.outputs).toEqual([ + `${root}/bin/tool.mjs`, + `${root}/bin/tool-flight.mjs`, + `${root}/scripts/report.mjs`, + `${root}/scripts/report-flight.mjs`, + `${root}/hooks/event-route-stop.mjs`, + `${root}/hooks/hooks-flight.mjs`, + `${root}/hooks/event-route-tool-before.mjs`, + `${root}/mcp/server.mjs`, + `${root}/mcp/server-flight.mjs`, + ]); + }); + + it('runs the browser stage before the node stage only when the target declares MCP Apps', () => { + const stages = planTargetStages({ + ...nodeOutputs, + compiledMcpApps: [{ output: `${root}/mcp-apps/dashboard.html` }], + }); + expect(stages.map((stage) => stage.kind)).toEqual(['mcp-apps', 'node-surfaces']); + expect(stages[0]!.outputs).toEqual([`${root}/mcp-apps/dashboard.html`]); + // Copied scripts are emitted, not compiled: they belong to no stage. + expect(stages[1]!.outputs).not.toContain(`${root}/scripts/notes.md`); + }); + + it('keeps each react-server Flight worker in the same stage as the host surface that spawns it', () => { + const [stage] = planTargetStages({ ...nodeOutputs, compiledMcpApps: [] }); + for (const [host, worker] of [ + [`${root}/bin/tool.mjs`, `${root}/bin/tool-flight.mjs`], + [`${root}/scripts/report.mjs`, `${root}/scripts/report-flight.mjs`], + [`${root}/hooks/event-route-stop.mjs`, `${root}/hooks/hooks-flight.mjs`], + [`${root}/mcp/server.mjs`, `${root}/mcp/server-flight.mjs`], + ]) { + expect(stage!.outputs).toContain(host); + expect(stage!.outputs).toContain(worker); + } + }); + + it('plans an empty node stage for a target with nothing to compile', () => { + expect(planTargetStages({ + compiledCliBins: [], + compiledEntries: [], + compiledHooks: [], + compiledMcpApps: [], + compiledMcpEntries: [], + })).toEqual([{ kind: 'node-surfaces', outputs: [] }]); + }); +}); + +/** + * A stubbed Rslib resolution carrying what a real one would for every + * generated executable: the virtual-module plugin instance and the exact + * match alias of the framework identity module. + */ +const resolvedEnvironment = (outputRoot: string, entry: RslibEntry) => ({ + bundler: { + name: entryLibId(entry), + output: { asyncChunks: false, path: outputRoot }, + plugins: [new rspack.experiments.VirtualModulesPlugin({})], + resolve: { alias: { [`${metaModuleSpecifier}$`]: generatedMetaModulePath(outputRoot) } }, + target: 'node', + }, + environment: { output: { cleanDistPath: false } }, +}); + +const surfaceEntry = (name: string, outputRelativePath: string, source: string): RslibEntry => ({ + name, + outputRelativePath, + source, + sourceInputs: [source], +}); + +describe('buildRslibSurfaces', () => { + it('lowers every surface of a target through one Rslib instance and hands each surface its own evidence', async () => { + const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-target-stages-')); + const project = '/project'; + const surfaces = [ + { entries: [surfaceEntry('bin-tool', 'bin/tool.mjs', `${project}/src/cli/index.ts`)], ignoredSourcePaths: [`${project}/runtime/cli`] }, + { entries: [surfaceEntry('report', 'scripts/report.mjs', `${project}/src/scripts/report.ts`)] }, + { + entries: [ + surfaceEntry('hooks-event-route-stop', 'hooks/event-route-stop.mjs', `${project}/src/hooks/stop.ts`), + surfaceEntry('hooks-flight', 'hooks/hooks-flight.mjs', `${project}/src/hooks/stop.ts`), + ], + ignoredSourcePaths: [`${project}/runtime/events`], + logLevel: 'silent' as const, + }, + { entries: [surfaceEntry('mcp-server', 'mcp/server.mjs', `${project}/src/mcp/server.ts`)], logLevel: 'error' as const }, + ]; + const entries = surfaces.flatMap((surface) => surface.entries); + const instances: { readonly config: { readonly lib: readonly { readonly id?: string }[]; readonly logLevel?: string } }[] = []; + const rslib = { + build: async () => ({ + close: async () => undefined, + stats: { + toJson: () => ({ + // One child compilation per environment, exactly as a multi-compiler reports. + children: entries.map((entry) => ({ + assets: [{ chunks: [entry.name], name: entry.outputRelativePath }], + modules: [ + { chunks: [entry.name], nameForCondition: entry.source }, + // Runtime modules inlined into some surfaces but not others: + // each surface's own exclusion decides whether they count. + { chunks: [entry.name], nameForCondition: `${project}/runtime/cli/shell.ts` }, + { chunks: [entry.name], nameForCondition: `${project}/runtime/events/ipc.ts` }, + ], + })), + }), + }, + }), + inspectConfig: async () => ({ + origin: { + bundlerConfigs: entries.map((entry) => resolvedEnvironment(outputRoot, entry).bundler), + environmentConfigs: Object.fromEntries(entries.map((entry) => [ + entryLibId(entry), + resolvedEnvironment(outputRoot, entry).environment, + ])), + }, + }), + }; + try { + for (const entry of entries) { + await mkdir(join(outputRoot, entry.outputRelativePath, '..'), { recursive: true }); + await writeFile(join(outputRoot, entry.outputRelativePath), 'export default undefined;\n'); + } + const evidence = await buildRslibSurfaces({ cwd: project, meta, outputRoot }, surfaces, { + createRslib: async (options) => { + instances.push(options as never); + return rslib as never; + }, + }); + + // One instance, one environment per entry across all four surfaces, + // reporting at the most verbose level any surface asked for. + expect(instances).toHaveLength(1); + expect(instances[0]!.config.lib.map((lib) => lib.id)).toEqual(entries.map(entryLibId)); + expect(instances[0]!.config.logLevel).toBe('error'); + + // Evidence comes back per surface, and each surface's exclusions apply + // only to its own outputs. + expect(evidence).toEqual([ + [{ path: 'bin/tool.mjs', sourceInputs: [`${project}/runtime/events/ipc.ts`, `${project}/src/cli/index.ts`] }], + [{ + path: 'scripts/report.mjs', + sourceInputs: [`${project}/runtime/cli/shell.ts`, `${project}/runtime/events/ipc.ts`, `${project}/src/scripts/report.ts`], + }], + [ + { path: 'hooks/event-route-stop.mjs', sourceInputs: [`${project}/runtime/cli/shell.ts`, `${project}/src/hooks/stop.ts`] }, + { path: 'hooks/hooks-flight.mjs', sourceInputs: [`${project}/runtime/cli/shell.ts`, `${project}/src/hooks/stop.ts`] }, + ], + [{ path: 'mcp/server.mjs', sourceInputs: [`${project}/runtime/cli/shell.ts`, `${project}/runtime/events/ipc.ts`, `${project}/src/mcp/server.ts`] }], + ]); + } finally { + await rm(outputRoot, { force: true, recursive: true }); + } + }); + + it('creates no instance when no surface has entries and settles surfaces with nothing to compile', async () => { + let created = 0; + const [bins, evidence] = await Promise.all([ + compileRslibSurfaces({ cwd: '/project', meta, outputRoot: '/staged/claude' }, [settledRslibSurface(['settled'])]), + buildRslibSurfaces({ cwd: '/project', meta, outputRoot: '/staged/claude' }, [{ entries: [] }, { entries: [] }], { + createRslib: async () => { + created += 1; + throw new Error('unreachable'); + }, + }), + ]); + expect(created).toBe(0); + expect(bins).toEqual([['settled']]); + expect(evidence).toEqual([[], []]); + }); + + it('lets two surfaces reuse an entry name because lib ids derive from destinations, and refuses a shared destination', async () => { + const createRslib = async () => { throw new Error('unreachable'); }; + // A script authored as `hooks-flight` beside the hook surface's standalone + // worker: distinct destinations, distinct ids, one run. + expect(entryLibId(surfaceEntry('hooks-flight', 'scripts/hooks-flight.mjs', '/project/src/scripts/hooks-flight.ts'))) + .toBe('agent-bundle-scripts-hooks-flight'); + expect(entryLibId(surfaceEntry('hooks-flight', 'hooks/hooks-flight.mjs', '/project/src/hooks/stop.ts'))) + .toBe('agent-bundle-hooks-hooks-flight'); + expect(entryLibId(surfaceEntry('index', 'lib/index.js', '/project/src/index.ts'))).toBe('agent-bundle-lib-index'); + await expect(buildRslibSurfaces({ cwd: '/project', meta, outputRoot: '/staged/claude' }, [ + { entries: [surfaceEntry('tool', 'scripts/tool.mjs', '/project/src/tool.ts')] }, + { entries: [surfaceEntry('other', 'scripts/tool.mjs', '/project/src/hooks/tool.ts')] }, + ], { createRslib })) + .rejects.toThrow(/same lib id "agent-bundle-scripts-tool" for "scripts\/tool.mjs" and "scripts\/tool.mjs"/u); + }); +}); diff --git a/website/docs/en/guide/distribution/index.mdx b/website/docs/en/guide/distribution/index.mdx index badde219f..04edc90f8 100644 --- a/website/docs/en/guide/distribution/index.mdx +++ b/website/docs/en/guide/distribution/index.mdx @@ -24,6 +24,24 @@ The build already validates the project before it writes anything, so a separate against source is a fast pre-flight rather than a required stage. Validating the **artifact** is the interesting one, because it needs no project sources at all. +### How a target compiles + +The build plans every target first, then lowers each target's outputs in at most two stages +into one staged root, published atomically once the artifact validates: + +1. **MCP Apps** — the browser environment, compiled through `@rsbuild/core`. Present only for a + target whose project declares App routes, and always first: the MCP entries embed its HTML. +2. **Agent-host surfaces** — the routed CLI bin, bundled scripts, hook wrappers, MCP stdio + entries, and each surface's react-server Flight worker, lowered together through **one Rslib + instance per target** (one Rsbuild environment per output, one Rspack multi-compiler). A + surface reaches its worker by file name at run time, so nothing orders the two within the + stage, and each surface keeps its own source evidence for the manifest. + +Both stages and the `dist/` package build compose their bundler config the same way — profile, +`tools.rsbuild`, `tools.rspack`, then the framework invariants — as described under +[`tools`](../../reference/configuration.mdx#tools). `agent-bundle inspect --bundler` prints the +result. + ## What ships inside a target directory Every built target contains a generated `INSTALL.md` written with the bundle's **real** plugin diff --git a/website/docs/zh/guide/distribution/index.mdx b/website/docs/zh/guide/distribution/index.mdx index cae908dd1..86a636b3c 100644 --- a/website/docs/zh/guide/distribution/index.mdx +++ b/website/docs/zh/guide/distribution/index.mdx @@ -22,6 +22,22 @@ npx agent-bundle build --root . --output artifact 构建在写出任何东西之前就已经校验过项目,因此针对源码单独运行一次 `validate` 更像是快速的预检,而不是 必需的阶段。真正有意思的是校验**产物**,因为它完全不需要项目源码。 +### 一个 target 如何编译 + +构建先为每个 target 做规划,然后把该 target 的输出最多分两个阶段降级到同一个暂存根目录,待产物校验通过后 +原子地发布: + +1. **MCP Apps**——浏览器环境,通过 `@rsbuild/core` 编译。只有当项目声明了 App 路由时这一阶段才存在,并且 + 始终最先运行:MCP 入口会内嵌它产出的 HTML。 +2. **智能体宿主面**——路由式 CLI bin、打包的脚本、hook 包装器、MCP stdio 入口,以及每个面各自的 + react-server Flight worker,全部一起通过**每个 target 一个 Rslib 实例**降级(每个输出一个 Rsbuild + environment,一个 Rspack 多编译器)。宿主面在运行时按文件名找到它的 worker,因此阶段内二者无需排序; + 每个面为清单保留各自的源码证据。 + +两个阶段与 `dist/` 包构建以同样的方式合成打包器配置——profile、`tools.rsbuild`、`tools.rspack`,最后是 +框架不变量——见 [`tools`](../../reference/configuration.mdx#tools)。`agent-bundle inspect --bundler` 会 +打印合成结果。 + ## 一个 target 目录里发布了什么 每个已构建 target 都包含一份生成的 `INSTALL.md`,其中使用捆绑包**真实的**插件名与市场名——而不是