Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/xref-one-rslib-instance-per-target.md
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 26 additions & 0 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
151 changes: 82 additions & 69 deletions packages/agent-bundle/src/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -388,73 +390,84 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
// 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<readonly CompiledCliBin[]>(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) =>
({
Expand Down
75 changes: 35 additions & 40 deletions packages/agent-bundle/src/build/cli-bins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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/**`
Expand Down Expand Up @@ -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<readonly CompiledCliBin[]> => {
options: { readonly outDir: string; readonly target: string },
): RslibSurfacePlan<readonly CompiledCliBin[]> => {
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'),
}),
})));
};

/**
Expand Down
Loading
Loading