diff --git a/.changeset/render-lowered-bundler-config.md b/.changeset/render-lowered-bundler-config.md
new file mode 100644
index 000000000..1cab650ba
--- /dev/null
+++ b/.changeset/render-lowered-bundler-config.md
@@ -0,0 +1,8 @@
+---
+"agent-bundle": patch
+---
+
+Make `inspect --bundler` render lowered Rspack configurations, rename
+`BundlerInspectionEntry.kind` from `mcp-apps` to `mcp-app`, require `source`, surface the lowering
+reason in `AB7001`, and serve Workbench assets with production cache and content-type headers.
+(#649)
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index ba8530f23..a912ff186 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -61,6 +61,8 @@ jobs:
node-version: 22.19.0
# Website-only PRs skip every ci.yml job that lints, so lint here too.
- run: pnpm lint
+ # TypeDoc consumes the declarations shipped from package dist trees.
+ - run: pnpm build
# Typecheck the site config, run the locale-drift and diagnostics-
# coverage checks (website/scripts), build with the persistent Rspack
# cache off, then walk the emitted HTML for dead links and anchors.
diff --git a/AGENTS.md b/AGENTS.md
index 8fcb494bc..7fbc5cc3d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -148,7 +148,8 @@
dead-link, dead-anchor, dead-image, and language-parity checks. Parity
fails the build if one locale gains a page the other lacks. Run it before
pushing anything under `website/`, and after any change to public
- exports, since TypeDoc compiles `packages/agent-bundle/src` directly.
+ exports, after `pnpm build`, since TypeDoc compiles the declarations under
+ `packages/agent-bundle/dist`.
- The site is desktop-first, like the Workbench. Wide tables scroll; code
samples wrap at roughly 90 columns so they render without horizontal
overflow at the default content width.
diff --git a/README.md b/README.md
index 800c4e6d8..2762b8ee6 100644
--- a/README.md
+++ b/README.md
@@ -67,7 +67,7 @@ The same config also owns the npm package build — no second bundler config, bi
- `build` — validate the project and write the plugin root (plus the `bin`/`lib` package build when declared)
- `validate` — check project source, or a built artifact with `--artifact
`
-- `inspect` — show the normalized configuration and each selected host's projection plan; `--bundler` dumps the synthesized bundler configs (post-`tools`-hatch merge)
+- `inspect` — show the normalized configuration and each selected host's projection plan; `--bundler` dumps the lowered Rspack config of every compiled output (post-`tools`-hatch merge, as Rslib/Rsbuild hand it to the compiler)
- `dev` — serve the local development workbench and rebuild the `dist/` package build when its inputs change; `--install-host ` installs a development variant whose stable `dev proxy` MCP command hot-swaps epochs behind the host's open connection and re-syncs hooks and Skills on every adopted rebuild (see [Framework mode › Live development into hosts](docs/framework-mode.md#live-development-into-hosts))
- `mcp list` / `mcp invoke` / `mcp run` — list, invoke, or run an artifact's MCP servers locally
- `hooks list` / `hooks simulate` — inspect and simulate generated hooks
diff --git a/docs/diagnostics.md b/docs/diagnostics.md
index 78775e6cf..8d8f05d77 100644
--- a/docs/diagnostics.md
+++ b/docs/diagnostics.md
@@ -1898,7 +1898,7 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above.
| Code | Severity | Meaning | Recovery |
| --- | --- | --- | --- |
| `AB7000` | error | Install/uninstall: `Unsupported install host .` / `Unsupported uninstall host .` — the exhaustive host switch received a host that is not `claude`, `codex`, or `cursor`. Project preparation: `Unable to load project source.` — evaluating the configuration module or discovering source threw before validation. | Install: pass `--host claude`, `codex`, or `cursor`. Preparation: fix the Agent Bundle configuration and source files, then inspect again. |
-| `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 2` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has no `documents.plugin`, or whose `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose bytes differ from its `sha256`; a Cursor `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and `Unable to compose the bundler inspection.` | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. |
+| `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 2` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has no `documents.plugin`, or whose `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose bytes differ from its `sha256`; a Cursor `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: ` — loading entries, generating the declaration tsconfig, or lowering and asserting the build's own Rslib/Rsbuild configuration failed. The reason carries the underlying source, project-tsconfig, toolchain, or invariant error, including a `tools` value the build would refuse. | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: fix the source, project tsconfig, toolchain, or refused `tools` value named by the reason. |
| `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.` — the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. |
| `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken. | Install: use `--scope user`, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable and remain inside the project root, then inspect again. |
| `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; any non-diagnostic error thrown by a Cursor installer or reaching the `install-entry` CLI. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. |
diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md
index e604c84ee..8b5196698 100644
--- a/docs/effect-conventions.md
+++ b/docs/effect-conventions.md
@@ -11,7 +11,7 @@ docs, or examples' user code. The four-concept newcomer ledger is untouched.
| --- | --- |
| npm `effect` | **`4.0.0-rc.112`** (exact). Latest published `rc` dist-tag on 2026-09-01. The Wave 3.5 brief named `4.0.0-rc.113`; that version was not on the registry. Re-pin chores take the next published RC. |
| Vendored tree | `repos/effect` via `git subtree` from [Effect-TS/effect](https://github.com/Effect-TS/effect.git) `main` (v4). Squash commit tracks `packages/effect` version **4.0.0-rc.112**. |
-| `website` `typescript` | **`6.0.3`**, behind the root's TypeScript 7, because `typedoc@0.28` peers on `<= 6.0.x`. TypeDoc and twoslash compile `packages/agent-bundle/src` with it, so TS7-only syntax in the package breaks `pnpm docs:site:build` first. Re-pin chores check whether a newer `typedoc` lifts the ceiling. |
+| `website` `typescript` | **`6.0.3`**, behind the root's TypeScript 7, because `typedoc@0.28` peers on `<= 6.0.x`. TypeDoc compiles the built `packages/agent-bundle/dist` declarations with it, while Twoslash compiles documentation samples against package source. Re-pin chores check whether a newer `typedoc` lifts the ceiling. |
Application code imports the npm package. Never import from `repos/**`.
diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md
index e2d6c447c..728f11618 100644
--- a/docs/entry-conventions.md
+++ b/docs/entry-conventions.md
@@ -1425,24 +1425,42 @@ kind whose row is not `supported`. The full matrix is in
agent-bundle inspect --bundler [--target ] [--json]
```
-Dumps the synthesized bundler configuration for every output the build
-composes — artifact scripts, MCP entries, hook wrappers, the composite root's
-MCP Apps Rsbuild config, and the `dist/` package build — exactly as the build
-lowers it: in production mode whatever `NODE_ENV` says, the framework profile
-with the consumer `tools` hatch merged over it and the invariant hook appended
-last (functions render as `[function ]`). Entries the framework wraps also carry the generated
-wrapper module source (`generatedEntry`). The composition comes from the same
-functions the build uses, so the dump cannot drift from what compiles.
-
-Nothing is redacted (this is a local debugging surface), but two build-time
-values are replaced with stable tokens so output is deterministic for one
-project: the composite artifact root (chosen per build) appears as
-``, and the synthesized declaration tsconfig (a temporary
-file generated per package build) appears as ``. The
-package build's output root appears as its published destination, `dist`,
-although each real build stages outputs before publishing them atomically.
-Resolved post-bundler internals stay Rslib's domain; this surfaces
-agent-bundle's own composition, which is where the `tools` hatch lands.
+Dumps the **lowered Rspack configuration** of every output the build
+compiles — artifact scripts, MCP entries, hook wrappers, the routed CLI bin,
+each MCP App view, and the `dist/` package build's `bin` and `lib` entries —
+one entry per compiler. The framework profile, the consumer `tools` hatch,
+and the invariant layer are composed exactly as the build composes them, then
+handed to the build's own engine — Rslib for executables, Rsbuild for MCP App
+views — and stopped where the build would start compiling. What prints is
+what the compiler receives: resolved `resolve.alias` entries (the
+`agent-bundle/*` runtime modules and the project-rooted
+`.agent-bundle-virtual/` generated modules beside the consumer's own
+aliases), the `externals` list, the framework plugins
+(`[object VirtualModulesPlugin]`, `[object ArtifactDependencyAuditPlugin]`),
+`output.path`, module rules, and every default the engine fills in. The
+lowering runs in production mode whatever `NODE_ENV` says and restores it
+afterwards, and it runs the build's invariant assertions: a `tools` value the
+build would refuse (a reserved alias, an `externals` entry naming a framework
+runtime module) makes the inspection `invalid` with an `AB7001` diagnostic
+carrying the refusal, instead of a config that never compiles. Entries the
+framework wraps also carry the generated wrapper module source
+(`generatedEntry`).
+
+The JSON rendering keeps the config's shape without dropping values JSON
+cannot carry: functions render as `[function ]` (Rslib lowers each
+entry's file name to `[function jsFilename]`), plugin instances as
+`[object ]`, regular expressions as `[regexp //]`.
+
+Nothing is redacted (this is a local debugging surface; the lowered configs
+carry absolute paths of the project and of agent-bundle's installed
+toolchain), but two build-time values are replaced with stable tokens so the
+output is deterministic for one project: the composite artifact root (chosen
+per build) appears as `` — as `output.path` and inside any path
+beneath it — and the synthesized declaration tsconfig (a temporary file
+generated per package build) appears as ``. The
+package build's `output.path` is its published destination,
+`/dist`, although each real build stages outputs before
+publishing them atomically.
## Dev-watch of the package build
diff --git a/docs/framework-mode.md b/docs/framework-mode.md
index 65df08e6e..b2a75484d 100644
--- a/docs/framework-mode.md
+++ b/docs/framework-mode.md
@@ -564,7 +564,8 @@ Every synthesized bundler config — both stages plus the `dist/` package build
`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.
+reference). `agent-bundle inspect --bundler` prints the lowered Rspack
+configuration each engine receives from that composition.
Builds are byte-reproducible: two builds of one unchanged source tree emit
identical artifacts (same manifest, same digests, same bytes) regardless of
diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md
index 29e6fdc21..52f46a8ee 100644
--- a/packages/agent-bundle/README.md
+++ b/packages/agent-bundle/README.md
@@ -140,7 +140,7 @@ manifests at files inside those payloads without compiling them. Payload files c
| `agent-bundle doctor` | Read-only host inspection: host probes, installed inventory, effective and legacy state roots with existence and writability, store receipts cross-checked against the host, and, with `--from`, the installed copy compared against the built artifact by version and content hash (`current`, `stale`, `version-mismatch`, `foreign`, `not-installed`) plus the lifecycle stage (placed → registered → enabled → active, unobservable stages typed `unavailable`). |
| `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. |
| `agent-bundle inspect` | Inspect the normalized model and each selected host projection's plan from source, with per-host component accounting: which skills, commands, rules, hooks, MCP surfaces, and scripts each host emits and, for every omission, whether the author excluded it or the host's pinned capability judgment (`degraded`/`unavailable`/`prohibited`, with reason) ruled it out. |
-| `agent-bundle inspect --bundler` | Dump the synthesized Rslib/Rsbuild configs (post-`tools`-hatch merge) for every generated output. |
+| `agent-bundle inspect --bundler` | Dump the lowered Rspack config (post-`tools`-hatch merge, as Rslib/Rsbuild hand it to the compiler) for every generated output. |
| `agent-bundle mcp list` / `mcp invoke` | List or invoke one MCP tool from an artifact. |
| `agent-bundle mcp run` | Run one built stdio MCP server in the foreground, resolving its hashed entry, loading the project-root `.env` set (`--env-file`/`--no-env` to override), and expanding env state anchors to the project root (`--plugin-root` to override). Environment precedence: manifest env < `.env` files < operator `process.env`. |
| `agent-bundle hooks list` / `hooks simulate` | List generated hooks, or run one emitted wrapper. |
diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts
index ade1145d3..ace61c136 100644
--- a/packages/agent-bundle/src/api.ts
+++ b/packages/agent-bundle/src/api.ts
@@ -26,6 +26,7 @@ import {
featureCapabilityName,
type AgentComponentKind,
} from './core/components.ts';
+import { errorMessage } from './core/errors.ts';
import { isInsideOrEqual } from './core/paths.ts';
import {
stateDefinitionProjection,
@@ -1220,12 +1221,12 @@ export const inspect = async (options: InspectOptions): Promise =
projectRoot: prepared.root,
...(prepared.tools === undefined ? {} : { tools: prepared.tools }),
});
- } catch {
+ } catch (error) {
return invalidInspection(freezeDiagnostics([
...prepared.diagnostics,
projectDiagnostic(
'AB7001',
- 'Unable to compose the bundler inspection.',
+ `Unable to compose the bundler inspection: ${errorMessage(error)}`,
{ sourcePath: prepared.configPath },
),
]));
diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts
index 3415f47e8..8044c340b 100644
--- a/packages/agent-bundle/src/build/inspect-bundler.ts
+++ b/packages/agent-bundle/src/build/inspect-bundler.ts
@@ -1,3 +1,5 @@
+import { dirname, resolve } from 'node:path';
+
import {
eventIpcRuntimeSpecifier,
eventProjectRuntimeSpecifier,
@@ -26,46 +28,55 @@ import { launchEnvRuntimeSpecifier, operatorEnvLayerVirtualModule } from './laun
import { cliBinRslibEntries, planCompiledCliBins } from './cli-bins.ts';
import type { CompositePlan } from './compose.ts';
import { eventRuntimeHosting, eventRuntimeModulePath, planCompiledMcpEntries, selectedServerHosts } from './entries.ts';
-import { composeMcpAppsRsbuildConfig, planCompiledMcpApps } from './mcp-apps.ts';
+import { inspectMcpAppsConfig, planCompiledMcpApps } from './mcp-apps.ts';
import { projectMeta } from './meta.ts';
-import { planPackageEntries } from './package-build.ts';
-import { composeEntryLibConfig, type RslibEntry } from './rslib.ts';
+import { planPackageEntries, synthesizeDtsTsconfig } from './package-build.ts';
+import { inspectRslibEntries, type RslibEntry } from './rslib.ts';
import { deepFreeze } from '../core/freeze.ts';
import type { AgentBundleMeta } from '../meta.ts';
/**
- * `agent-bundle inspect --bundler` (RFC #50 §3.4): surfaces the internal
- * Rslib/Rsbuild configurations the build composes — the framework profile
- * with the consumer `tools` escape hatch merged over it and the invariant
- * hook appended last — for every synthesized output. The composition comes
- * from the same functions the build lowers (`composeEntryLibConfig`,
- * `composeMcpAppsRsbuildConfig`), so the inspection can never drift from
- * what actually compiles.
+ * `agent-bundle inspect --bundler` (RFC #50 §3.4): surfaces the lowered
+ * Rspack configuration of every output the build compiles — the framework
+ * profile with the consumer `tools` escape hatch merged over it and the
+ * invariant hook applied last, resolved by Rslib (executables) or Rsbuild
+ * (MCP App views) into what the compiler receives. The lowering is the
+ * build's own step (`inspectRslibEntries`, `inspectMcpAppsConfig`), run in
+ * production mode whatever `NODE_ENV` says and stopped where the build would
+ * start compiling, so the inspection can never drift from what actually
+ * compiles; a hatch value the invariants refuse fails the inspection the
+ * way it would fail the build.
*
* Two build-time-only values are replaced with stable tokens so the output
* is deterministic for one project: the composite artifact root (chosen by
* `build --output` and staged per build) appears as ``, and the
- * synthesized declaration tsconfig (a temporary file the package
- * build generates under `node_modules`) appears as
- * ``. Nothing else is redacted; this is a local
- * debugging surface. The generated-module namespace
+ * synthesized declaration tsconfig (a temporary file the package build
+ * generates under `node_modules`) appears as ``.
+ * Nothing else is redacted; this is a local debugging surface, and the
+ * lowered configs carry absolute paths of the project and of agent-bundle's
+ * installed toolchain. The generated-module namespace
* (`/.agent-bundle-virtual/...`) appears exactly as the build
* composes it: it derives from the project root, not from the output root.
*/
export interface BundlerInspectionEntry {
+ /** The engine that lowered this entry: Rslib for executables, Rsbuild for MCP App views. */
readonly bundler: 'rsbuild' | 'rslib';
- /** The composed config, JSON-rendered: functions appear as `[function ]`. */
+ /**
+ * The lowered Rspack configuration, JSON-rendered: functions appear as
+ * `[function ]`, class instances (plugins) as `[object ]`,
+ * regular expressions as `[regexp ]`.
+ */
readonly config: unknown;
/** The generated wrapper entry module, when the framework provides one. */
readonly generatedEntry?: string;
- readonly kind: 'bin' | 'hook' | 'lib' | 'mcp-apps' | 'mcp-entry' | 'script';
+ readonly kind: 'bin' | 'hook' | 'lib' | 'mcp-app' | 'mcp-entry' | 'script';
readonly name: string;
/** POSIX output path relative to the artifact root (artifact surfaces) or project root (package build). */
readonly outputPath: string;
- /** The authored entry module (absent for the MCP Apps config). */
- readonly source?: string;
+ /** The authored entry module. */
+ readonly source: string;
/** The composite identity of the selected projections; absent for package-build entries. */
readonly target?: string;
}
@@ -80,14 +91,26 @@ const artifactOutputToken = '';
const isPlainObject: (value: object) => boolean = isPlainRecord;
+type PathTokens = readonly (readonly [absolute: string, token: string])[];
+
+const tokenizePath = (value: string, tokens: PathTokens): string => {
+ for (const [absolute, token] of tokens) {
+ if (value === absolute) return token;
+ if (value.startsWith(`${absolute}/`)) return `${token}${value.slice(absolute.length)}`;
+ }
+ return value;
+};
+
/**
- * Renders a composed bundler config as JSON-safe data without dropping the
- * shape: functions (consumer `tools.rspack` mutators, the framework
- * invariant hook) become `[function ]`, class instances become
- * `[object ]`.
+ * Renders a lowered Rspack config as JSON-safe data without dropping the
+ * shape: functions (function-form externals, hatch callbacks) become
+ * `[function ]`, class instances (plugins, loaders' option objects)
+ * become `[object ]`, regular expressions (module rules,
+ * externals) become `[regexp ]`.
*/
-const renderConfigValue = (value: unknown, ancestors = new Set()): unknown => {
- if (value === null || typeof value === 'boolean' || typeof value === 'string') return value;
+const renderConfigValue = (value: unknown, tokens: PathTokens, ancestors = new Set()): unknown => {
+ if (value === null || typeof value === 'boolean') return value;
+ if (typeof value === 'string') return tokenizePath(value, tokens);
if (typeof value === 'number') return Number.isFinite(value) ? value : String(value);
if (typeof value === 'function') return `[function ${value.name.length === 0 ? 'anonymous' : value.name}]`;
if (typeof value !== 'object') return String(value);
@@ -95,61 +118,41 @@ const renderConfigValue = (value: unknown, ancestors = new Set()): unkno
ancestors.add(value);
try {
+ if (value instanceof RegExp) return `[regexp ${String(value)}]`;
if (Array.isArray(value)) {
- return value.map((item) => renderConfigValue(item, ancestors));
+ return value.map((item) => renderConfigValue(item, tokens, ancestors));
}
if (!isPlainObject(value)) {
- return `[object ${value.constructor?.name ?? 'unknown'}]`;
+ const constructor = value.constructor?.name;
+ return `[object ${constructor === undefined || constructor.length === 0 ? 'anonymous' : constructor}]`;
}
return Object.fromEntries(Object.entries(value)
.filter(([, item]) => item !== undefined)
- .map(([key, item]) => [key, renderConfigValue(item, ancestors)]));
+ .map(([key, item]) => [key, renderConfigValue(item, tokens, ancestors)]));
} finally {
ancestors.delete(value);
}
};
-const rslibInspectionEntry = (options: {
+/** One Rslib-compiled output the inspection lowers, before its config is known. */
+interface PlannedRslibInspection {
readonly entry: RslibEntry;
- readonly kind: BundlerInspectionEntry['kind'];
- readonly meta: AgentBundleMeta;
+ readonly kind: Exclude;
readonly name: string;
readonly outputPath: string;
- readonly outputRoot: string;
- readonly projectRoot: string;
- readonly source: string;
readonly target?: string;
- readonly tools?: AgentBundleToolsConfig;
-}): BundlerInspectionEntry => Object.freeze({
- bundler: 'rslib',
- config: renderConfigValue(composeEntryLibConfig(options.entry, {
- cwd: options.projectRoot,
- meta: options.meta,
- outputRoot: options.outputRoot,
- ...(options.tools === undefined ? {} : { tools: options.tools }),
- })),
- ...(options.entry.virtualSource === undefined ? {} : { generatedEntry: options.entry.virtualSource }),
- kind: options.kind,
- name: options.name,
- outputPath: options.outputPath,
- source: options.source,
- ...(options.target === undefined ? {} : { target: options.target }),
-});
+}
const scriptEntries = async (
model: NormalizedPlugin,
- projectRoot: string,
composite: CompositeSelection,
- tools: AgentBundleToolsConfig | undefined,
-): Promise => {
- const meta = projectMeta(model.metadata);
- const outputRoot = artifactOutputToken;
+): Promise => {
const target = composite.identity;
const scripts = model.scripts.filter((script) =>
script.mode === 'bundle' && script.targets.some((candidate) => composite.selected.includes(candidate)));
return Promise.all(scripts.map(async (script) => {
const exports = await scanEntryExports(script.source);
- return rslibInspectionEntry({
+ return {
entry: {
name: script.name,
outputRelativePath: `scripts/${script.name}.mjs`,
@@ -166,56 +169,36 @@ const scriptEntries = async (
}
: {}),
},
- kind: 'script',
- meta,
+ kind: 'script' as const,
name: script.name,
outputPath: `scripts/${script.name}.mjs`,
- outputRoot,
- projectRoot,
- source: script.source,
target,
- ...(tools === undefined ? {} : { tools }),
- });
+ };
}));
};
/** The artifact-hosted routed CLI bins of the composite root (#387), composed by the build's own planner. */
-const cliBinEntries = (
- model: NormalizedPlugin,
- projectRoot: string,
- target: string,
- tools: AgentBundleToolsConfig | undefined,
-): readonly BundlerInspectionEntry[] => {
- const meta = projectMeta(model.metadata);
- const outputRoot = artifactOutputToken;
- const planned = planCompiledCliBins(model, { outDir: outputRoot, target });
- return cliBinRslibEntries(planned, model).map((entry) => rslibInspectionEntry({
+const cliBinEntries = (model: NormalizedPlugin, outDir: string, target: string): readonly PlannedRslibInspection[] => {
+ const planned = planCompiledCliBins(model, { outDir, target });
+ return cliBinRslibEntries(planned, model).map((entry) => ({
entry,
- kind: 'bin',
- meta,
+ kind: 'bin' as const,
name: entry.name.replace(/^bin-/u, ''),
outputPath: entry.outputRelativePath,
- outputRoot,
- projectRoot,
- source: entry.source,
target,
- ...(tools === undefined ? {} : { tools }),
}));
};
const mcpEntryEntries = async (
model: NormalizedPlugin,
- projectRoot: string,
composite: CompositeSelection,
- tools: AgentBundleToolsConfig | undefined,
-): Promise => {
- const meta = projectMeta(model.metadata);
- const outputRoot = artifactOutputToken;
+ outDir: string,
+): Promise => {
const target = composite.identity;
const noticeDelivery = composite.noticeDelivery;
- const planned = planCompiledMcpEntries(model.mcpServers, { outDir: outputRoot, target, targets: composite.selected });
+ const planned = planCompiledMcpEntries(model.mcpServers, { outDir, target, targets: composite.selected });
const hosting = eventRuntimeHosting(model.mcpServers, composite.selected);
- const entries: BundlerInspectionEntry[] = [];
+ const entries: PlannedRslibInspection[] = [];
for (const entry of planned) {
const server = model.mcpServers.find((candidate) => candidate.id === entry.id);
const serverName = entry.id.startsWith('mcp:') ? entry.id.slice('mcp:'.length) : entry.name;
@@ -235,7 +218,7 @@ const mcpEntryEntries = async (
...(model.state === undefined ? {} : { state: model.state }),
workerFile,
});
- entries.push(rslibInspectionEntry({
+ entries.push({
entry: {
aliases: {
// Every stdio entry can import the operator `.env` layer (#469); the
@@ -271,17 +254,12 @@ const mcpEntryEntries = async (
],
},
kind: 'mcp-entry',
- meta,
name: serverName,
outputPath: `mcp/${entry.name}.mjs`,
- outputRoot,
- projectRoot,
- source: entry.source,
target,
- ...(tools === undefined ? {} : { tools }),
- }));
+ });
if (generatedRoutes !== undefined) {
- entries.push(rslibInspectionEntry({
+ entries.push({
entry: {
name: `${entry.name}-flight`,
outputRelativePath: `mcp/${workerFile}`,
@@ -301,29 +279,17 @@ const mcpEntryEntries = async (
}),
},
kind: 'mcp-entry',
- meta,
name: `${serverName}:flight`,
outputPath: `mcp/${workerFile}`,
- outputRoot,
- projectRoot,
- source: entry.source,
target,
- ...(tools === undefined ? {} : { tools }),
- }));
+ });
}
}
- return Object.freeze(entries);
+ return entries;
};
-const hookEntries = (
- entries: readonly TargetHookEntry[],
- meta: AgentBundleMeta,
- projectRoot: string,
- target: string,
- tools: AgentBundleToolsConfig | undefined,
-): readonly BundlerInspectionEntry[] => {
- const outputRoot = artifactOutputToken;
- return entries.map((entry) => rslibInspectionEntry({
+const hookEntries = (entries: readonly TargetHookEntry[], target: string): readonly PlannedRslibInspection[] =>
+ entries.map((entry) => ({
entry: {
aliases: {
[launchEnvRuntimeSpecifier]: launchEnvRuntimePath(),
@@ -341,25 +307,66 @@ const hookEntries = (
virtualSource: entry.virtualSource,
...(hookWrapperAppliesOperatorEnv(entry) ? { virtualModules: [operatorEnvLayerVirtualModule()] } : {}),
},
- kind: 'hook',
- meta,
+ kind: 'hook' as const,
name: entry.hook.name,
outputPath: entry.relativePath,
- outputRoot,
- projectRoot,
- source: entry.hook.source,
target,
- ...(tools === undefined ? {} : { tools }),
}));
+
+const packageBuildEntries = async (
+ model: NormalizedPlugin,
+ dtsTsconfigPath: string | undefined,
+): Promise => {
+ const packageBuild = model.packageBuild;
+ if (packageBuild === undefined) return [];
+ const planned = await planPackageEntries(model, dtsTsconfigPath);
+ return planned.map((entry) => {
+ const bin = entry.executable;
+ return {
+ entry,
+ kind: bin ? 'bin' as const : 'lib' as const,
+ name: bin ? entry.name.replace(/^bin-/u, '') : entry.name,
+ outputPath: `${packageBuild.outputDir}/${entry.outputRelativePath}`,
+ };
+ });
};
-const mcpAppsEntry = (
+const loweredRslibEntries = async (
+ planned: readonly PlannedRslibInspection[],
+ run: {
+ readonly meta: AgentBundleMeta;
+ readonly outputRoot: string;
+ readonly projectRoot: string;
+ readonly tokens: PathTokens;
+ readonly tools?: AgentBundleToolsConfig;
+ },
+): Promise => {
+ const configs = await inspectRslibEntries({
+ cwd: run.projectRoot,
+ meta: run.meta,
+ outputRoot: run.outputRoot,
+ ...(run.tools === undefined ? {} : { tools: run.tools }),
+ }, planned.map((item) => item.entry));
+ return planned.map((item, index) => Object.freeze({
+ bundler: 'rslib' as const,
+ config: renderConfigValue(configs[index], run.tokens),
+ ...(item.entry.virtualSource === undefined ? {} : { generatedEntry: item.entry.virtualSource }),
+ kind: item.kind,
+ name: item.name,
+ outputPath: item.outputPath,
+ source: item.entry.source,
+ ...(item.target === undefined ? {} : { target: item.target }),
+ }));
+};
+
+const mcpAppEntries = async (
model: NormalizedPlugin,
projectRoot: string,
composite: CompositeSelection,
+ outputRoot: string,
+ tokens: PathTokens,
tools: AgentBundleToolsConfig | undefined,
-): readonly BundlerInspectionEntry[] => {
- const outputRoot = artifactOutputToken;
+): Promise => {
const target = composite.identity;
const apps = model.mcpApps ?? [];
const planned = planCompiledMcpApps(apps, { outDir: outputRoot, selected: composite.selected, target });
@@ -371,45 +378,21 @@ const mcpAppsEntry = (
}
return source;
});
- return [Object.freeze({
+ const configs = await inspectMcpAppsConfig(planned, sources, {
+ cwd: projectRoot,
+ meta: projectMeta(model.metadata),
+ outDir: outputRoot,
+ ...(tools === undefined ? {} : { tools }),
+ });
+ return planned.map((app, index) => Object.freeze({
bundler: 'rsbuild' as const,
- config: renderConfigValue(composeMcpAppsRsbuildConfig(sources, {
- cwd: projectRoot,
- meta: projectMeta(model.metadata),
- outDir: outputRoot,
- ...(tools === undefined ? {} : { tools }),
- })),
- kind: 'mcp-apps' as const,
- name: 'mcp-apps',
- outputPath: 'mcp-apps',
+ config: renderConfigValue(configs[index], tokens),
+ kind: 'mcp-app' as const,
+ name: app.name,
+ outputPath: `mcp-apps/${app.name}.html`,
+ source: app.source,
target,
- })];
-};
-
-const packageBuildEntries = async (
- model: NormalizedPlugin,
- projectRoot: string,
- tools: AgentBundleToolsConfig | undefined,
-): Promise => {
- const packageBuild = model.packageBuild;
- if (packageBuild === undefined) return [];
- const dtsTsconfig = packageBuild.lib?.dts === true ? generatedDtsTsconfigToken : undefined;
- const planned = await planPackageEntries(model, dtsTsconfig);
- const meta = projectMeta(model.metadata);
- return planned.map((entry) => {
- const bin = entry.executable;
- return rslibInspectionEntry({
- entry,
- kind: bin ? 'bin' : 'lib',
- meta,
- name: bin ? entry.name.replace(/^bin-/u, '') : entry.name,
- outputPath: `${packageBuild.outputDir}/${entry.outputRelativePath}`,
- outputRoot: packageBuild.outputDir,
- projectRoot,
- source: entry.source,
- ...(tools === undefined ? {} : { tools }),
- });
- });
+ }));
};
const entryOrder = (left: BundlerInspectionEntry, right: BundlerInspectionEntry): number =>
@@ -429,15 +412,52 @@ export const composeBundlerInspection = async (options: {
}): Promise => {
const { composite, model, projectRoot, tools } = options;
const meta = projectMeta(model.metadata);
- const entries: BundlerInspectionEntry[] = [
- ...(composite.cliBin ? cliBinEntries(model, projectRoot, composite.identity, tools) : []),
- ...(await scriptEntries(model, projectRoot, composite, tools)),
- ...(await mcpEntryEntries(model, projectRoot, composite, tools)),
- ...hookEntries(composite.hookEntries, meta, projectRoot, composite.identity, tools),
- ...mcpAppsEntry(model, projectRoot, composite, tools),
- ...(await packageBuildEntries(model, projectRoot, tools)),
+ const packageLib = model.packageBuild?.lib;
+ const dtsTsconfig = packageLib?.dts === true
+ ? await synthesizeDtsTsconfig({ projectRoot, sourceDir: dirname(packageLib.source) })
+ : undefined;
+ // The output roots are absolute, as the build passes them and as the
+ // resolved-config assertions expect them; the rendering folds the token
+ // root back to its token.
+ const artifactOutputRoot = resolve(projectRoot, artifactOutputToken);
+ const tokens: PathTokens = [
+ [artifactOutputRoot, artifactOutputToken],
+ ...(dtsTsconfig === undefined
+ ? []
+ : [[dtsTsconfig.path, generatedDtsTsconfigToken] as const]),
];
- return deepFreeze({
- entries: entries.sort(entryOrder),
- });
+ try {
+ // The artifact surfaces ride one Rslib run, as the build stages them; the
+ // package build is its own run with its own output root.
+ const artifactSurfaces: readonly PlannedRslibInspection[] = [
+ ...(composite.cliBin ? cliBinEntries(model, artifactOutputRoot, composite.identity) : []),
+ ...(await scriptEntries(model, composite)),
+ ...(await mcpEntryEntries(model, composite, artifactOutputRoot)),
+ ...hookEntries(composite.hookEntries, composite.identity),
+ ];
+ const entries: BundlerInspectionEntry[] = [
+ ...(await loweredRslibEntries(artifactSurfaces, {
+ meta,
+ outputRoot: artifactOutputRoot,
+ projectRoot,
+ tokens,
+ ...(tools === undefined ? {} : { tools }),
+ })),
+ ...(await mcpAppEntries(model, projectRoot, composite, artifactOutputRoot, tokens, tools)),
+ ...(model.packageBuild === undefined
+ ? []
+ : await loweredRslibEntries(await packageBuildEntries(model, dtsTsconfig?.path), {
+ meta,
+ outputRoot: resolve(projectRoot, model.packageBuild.outputDir),
+ projectRoot,
+ tokens,
+ ...(tools === undefined ? {} : { tools }),
+ })),
+ ];
+ return deepFreeze({
+ entries: entries.sort(entryOrder),
+ });
+ } finally {
+ await dtsTsconfig?.cleanup();
+ }
};
diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts
index fea917179..a7b0cf8fe 100644
--- a/packages/agent-bundle/src/build/mcp-apps.ts
+++ b/packages/agent-bundle/src/build/mcp-apps.ts
@@ -43,7 +43,7 @@ import {
virtualModulesPluginConstructor,
} from './meta.ts';
import { collectBundledOutputEvidence } from './provenance.ts';
-import { compileResultOf } from './rslib.ts';
+import { compileResultOf, inspectProductionConfig } from './rslib.ts';
import { runtimeIgnoredRoot } from './runtime-path.ts';
export type { McpAppCompileMode, McpAppOutputSize } from './mcp-app-diagnostics.ts';
@@ -461,6 +461,54 @@ const assertViewsSelfContained = (
return Object.freeze(records);
};
+/**
+ * The Rsbuild instance every view of one composite root compiles through —
+ * the composed profile plus the compile-time plugins the build adds beside
+ * it — lowered to its resolved environments and Rspack configurations and
+ * judged by {@link assertResolvedViewConfig} before anything compiles. The
+ * build and `inspect --bundler` share this step, so what the inspection
+ * renders is exactly what the build compiles.
+ */
+const lowerViews = async (
+ compiled: readonly PlannedMcpApp[],
+ sources: readonly Pick[],
+ options: Parameters[1],
+ collectedStats: Map,
+): Promise<{
+ readonly inspection: Awaited>['inspectConfig']>>;
+ readonly rsbuild: Awaited>;
+}> => {
+ const rsbuild = await createRsbuild({ cwd: options.cwd, config: composeMcpAppsRsbuildConfig(sources, options) });
+ rsbuild.addPlugins([mcpAppStatsCollectorPlugin(collectedStats), mcpAppHtmlDefaultsPlugin()]);
+ const inspection = await inspectProductionConfig(rsbuild);
+ assertResolvedViewConfig(
+ inspection,
+ compiled.map((app) => app.name),
+ options.outDir,
+ generatedMetaModulePath(options.cwd),
+ appRuntimePath(),
+ );
+ return { inspection, rsbuild };
+};
+
+/**
+ * The lowered Rspack configuration of every planned view, in plan order,
+ * from one Rsbuild instance that never builds: the same composition, mode,
+ * and invariant assertions as {@link compileMcpApps}, stopping where the
+ * build would start compiling. Rsbuild reads every view entry from disk
+ * while resolving, exactly as the build does.
+ */
+export const inspectMcpAppsConfig = async (
+ compiled: readonly PlannedMcpApp[],
+ sources: readonly Pick[],
+ options: Parameters[1],
+): Promise => {
+ if (compiled.length === 0) return Object.freeze([]);
+ const { inspection } = await lowerViews(compiled, sources, options, new Map());
+ // `assertResolvedViewConfig` has matched the compiler names to the app names.
+ return Object.freeze(compiled.map((app) => inspection.origin.bundlerConfigs.find((config) => config.name === app.name)!));
+};
+
export const compileMcpApps = async (
apps: readonly NormalizedMcpApp[],
options: Readonly<{
@@ -493,28 +541,15 @@ export const compileMcpApps = async (
const mode: McpAppCompileMode = options.mode ?? 'production';
const compilationEvidence: CompilationEvidence[] = [];
- const rsbuild = await createRsbuild({
- cwd: options.cwd,
- config: composeMcpAppsRsbuildConfig(sources, {
- cwd: options.cwd,
- meta: options.meta,
- mode,
- onCompilationEvidence: (evidence) => compilationEvidence.push(evidence),
- outDir: options.outDir,
- ...(options.tools === undefined ? {} : { tools: options.tools }),
- }),
- });
const collectedStats = new Map();
- rsbuild.addPlugins([mcpAppStatsCollectorPlugin(collectedStats), mcpAppHtmlDefaultsPlugin()]);
- const inspection = await rsbuild.inspectConfig({ mode: 'production' });
- const runtimePath = appRuntimePath();
- assertResolvedViewConfig(
- inspection,
- compiled.map((app) => app.name),
- options.outDir,
- generatedMetaModulePath(options.cwd),
- runtimePath,
- );
+ const { rsbuild } = await lowerViews(compiled, sources, {
+ cwd: options.cwd,
+ meta: options.meta,
+ mode,
+ onCompilationEvidence: (evidence) => compilationEvidence.push(evidence),
+ outDir: options.outDir,
+ ...(options.tools === undefined ? {} : { tools: options.tools }),
+ }, collectedStats);
const contexts: readonly McpAppDiagnosticContext[] = compiled.map((app) => ({
appName: app.name,
entrySource: app.source,
@@ -553,7 +588,7 @@ export const compileMcpApps = async (
// stats as a module under this reserved namespace.
ignoredSourcePaths: [
resolve(generatedModulesRoot(options.cwd)),
- runtimeIgnoredRoot(runtimePath),
+ runtimeIgnoredRoot(appRuntimePath()),
],
projectRoot: options.cwd,
stats: result.stats,
diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts
index 924b3007c..e10f10e69 100644
--- a/packages/agent-bundle/src/build/package-build.ts
+++ b/packages/agent-bundle/src/build/package-build.ts
@@ -84,7 +84,7 @@ export interface PlannedPackageEntry extends RslibEntry {
* package output, exactly like the dedicated build tsconfig consumers
* previously maintained by hand.
*/
-const synthesizeDtsTsconfig = async (options: {
+export const synthesizeDtsTsconfig = async (options: {
readonly projectRoot: string;
readonly sourceDir: string;
}): Promise<{ readonly cleanup: () => Promise; readonly path: string }> => {
diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts
index c6f5b3581..343c9bb5d 100644
--- a/packages/agent-bundle/src/build/rslib.ts
+++ b/packages/agent-bundle/src/build/rslib.ts
@@ -79,22 +79,22 @@ const preserveResourceReferences: RslibBundlerChain = (chain) => {
};
/**
- * Lowers the composed lib configs to bundler configs, always as the
- * production build `rslib.build()` runs. Rslib 1.x otherwise infers the mode
- * from `NODE_ENV`, and under `development` inspects only `mf` libs — none
- * here — so `assertExecutableConfig` and `inspect --bundler` would fail. Rslib
- * writes the inspected mode back to `NODE_ENV`; the process gets its own value
- * back — set or unset — whether the inspection succeeded or threw, so a
- * development server or test runner that inspects a config is not left
- * running as `production`. (`rslib.build()` sets `production` itself when it
- * finds `NODE_ENV` unset; that is the build's business, not the inspection's.)
+ * Lowers composed configs to bundler configs, always as the production build
+ * runs. Rslib 1.x otherwise infers the mode from `NODE_ENV`, and under
+ * `development` inspects only `mf` libs — none here — so
+ * `assertExecutableConfig` and `inspect --bundler` would fail. Rslib and
+ * Rsbuild write the inspected mode back to `NODE_ENV`; the process gets its
+ * own value back — set or unset — whether the inspection succeeded or threw,
+ * so a development server or test runner that inspects a config is not left
+ * running as `production`. (`build()` sets `production` itself when it finds
+ * `NODE_ENV` unset; that is the build's business, not the inspection's.)
*/
-const inspectProductionConfig = async (
- rslib: Pick,
-): Promise>> => {
+export const inspectProductionConfig = async (
+ instance: { readonly inspectConfig: (options: { readonly mode: 'production' }) => Promise },
+): Promise => {
const nodeEnv = process.env.NODE_ENV;
try {
- return await rslib.inspectConfig({ mode: 'production' });
+ return await instance.inspectConfig({ mode: 'production' });
} finally {
if (nodeEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = nodeEnv;
@@ -484,8 +484,9 @@ const assertExecutableConfig = (
* shared `composeToolsLayers` order — the framework profile, the consumer
* `tools` escape hatch over it, the invariant enforcer hook last — with
* Rslib's own `mergeRslibConfig` keyed by the synthesized lib id.
- * `buildWithRslib` lowers exactly this composition and `inspect --bundler`
- * surfaces it, so the two can never drift.
+ * `buildRslibSurfaces` and `inspectRslibEntries` lower exactly this
+ * composition through one shared step, so the build and `inspect --bundler`
+ * can never drift.
*/
export const composeEntryLibConfig = (
entry: RslibEntry,
@@ -795,6 +796,67 @@ const moduleKindOf = (
return 'authored';
};
+/**
+ * Creates the Rslib instance for one run — every entry composed through
+ * {@link composeEntryLibConfig} — and lowers it to the resolved Rsbuild
+ * environments and Rspack configurations, judged by
+ * {@link assertExecutableConfig} before anything compiles. The build and
+ * `inspect --bundler` share this step, so what the inspection renders is
+ * exactly what the build compiles.
+ */
+const lowerEntries = async (
+ options: RslibRunOptions,
+ entries: readonly RslibEntry[],
+ lowering: {
+ readonly createRslib: NonNullable;
+ readonly logLevel: 'error' | 'silent';
+ readonly onCompilationEvidence?: (evidence: CompilationEvidence) => void;
+ readonly onReservedExternal?: (specifier: string) => void;
+ },
+): Promise<{
+ readonly inspection: Awaited>;
+ readonly rslib: Awaited>>;
+}> => {
+ const rslib = await lowering.createRslib({
+ cwd: options.cwd,
+ config: {
+ logLevel: lowering.logLevel,
+ lib: entries.map((entry) => composeEntryLibConfig(entry, {
+ cwd: options.cwd,
+ meta: options.meta,
+ ...(lowering.onCompilationEvidence === undefined ? {} : { onCompilationEvidence: lowering.onCompilationEvidence }),
+ ...(lowering.onReservedExternal === undefined ? {} : { onReservedExternal: lowering.onReservedExternal }),
+ outputRoot: options.outputRoot,
+ ...(options.tools === undefined ? {} : { tools: options.tools }),
+ })),
+ },
+ });
+ const inspection = await inspectProductionConfig(rslib);
+ assertExecutableConfig(entries, inspection.origin, options);
+ return { inspection, rslib };
+};
+
+/**
+ * The lowered Rspack configuration of every entry, in entry order, from one
+ * Rslib instance that never builds: the same composition, mode, and
+ * invariant assertions as {@link buildRslibSurfaces}, stopping where the
+ * build would start compiling. Rslib reads every authored entry from disk
+ * while resolving, exactly as the build does.
+ */
+export const inspectRslibEntries = async (
+ options: RslibRunOptions,
+ entries: readonly RslibEntry[],
+): Promise => {
+ if (entries.length === 0) return Object.freeze([]);
+ assertDistinctLibIds(entries);
+ const { inspection } = await lowerEntries(options, entries, { createRslib, logLevel: 'silent' });
+ // `assertExecutableConfig` has matched exactly one lowered config per entry by lib id.
+ return Object.freeze(entries.map((entry) => {
+ const id = entryLibId(entry);
+ return inspection.origin.bundlerConfigs.find((config) => config.name === id)!;
+ }));
+};
+
/**
* Lowers every surface's entries through one Rslib instance and returns the
* compiler result per surface, in surface order. A surface without entries
@@ -820,24 +882,13 @@ export const buildRslibSurfaces = async (
const reservedExternalViolations: string[] = [];
const compilationEvidence: CompilationEvidence[] = [...(dependencies.compilationEvidence ?? [])];
- const rslib = await (dependencies.createRslib ?? createRslib)({
- cwd: options.cwd,
- config: {
- // 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, {
- cwd: options.cwd,
- meta: options.meta,
- onCompilationEvidence: (evidence) => compilationEvidence.push(evidence),
- onReservedExternal: (specifier) => reservedExternalViolations.push(specifier),
- outputRoot: options.outputRoot,
- ...(options.tools === undefined ? {} : { tools: options.tools }),
- })),
- },
+ const { rslib } = await lowerEntries(options, entries, {
+ createRslib: dependencies.createRslib ?? createRslib,
+ // The run reports at the most verbose level any surface asks for.
+ logLevel: surfaces.some((surface) => surface.logLevel === 'error') ? 'error' : 'silent',
+ onCompilationEvidence: (evidence) => compilationEvidence.push(evidence),
+ onReservedExternal: (specifier) => reservedExternalViolations.push(specifier),
});
-
- const inspection = await inspectProductionConfig(rslib);
- assertExecutableConfig(entries, inspection.origin, options);
let result: Awaited> | undefined;
try {
try {
diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts
index 03dae97f8..d45970fed 100644
--- a/packages/agent-bundle/src/cli.ts
+++ b/packages/agent-bundle/src/cli.ts
@@ -536,7 +536,7 @@ const humanInspect = (result: Awaited>): string => {
return out.join('');
}
if (result.selected?.bundler !== undefined) {
- // The bundler focus is a debugging dump: the full synthesized
+ // The bundler focus is a debugging dump: the full lowered
// configuration is the human output, not a one-line summary.
out.push(`${JSON.stringify(result.selected.bundler, null, 2)}\n`);
return out.join('');
@@ -1099,7 +1099,7 @@ export const runCli = async (
const inspectCommand = configureInspectOptions(
program.command('inspect').description('Inspect normalized targets and adapter plans'),
)
- .option('--bundler', 'Include the synthesized bundler configuration focus')
+ .option('--bundler', 'Include the lowered bundler configuration focus')
.option('--hooks', 'Include the hook focus')
.option('--routes', 'Include the compiled route-graph focus')
.option('--skills', 'Include the skill focus')
diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts
index 023bf7fd3..111eece8d 100644
--- a/packages/agent-bundle/src/dev/foreground-server.ts
+++ b/packages/agent-bundle/src/dev/foreground-server.ts
@@ -29,6 +29,7 @@ import { RouteManifestRoutes, type RouteManifestRouteService } from './routes/ro
import { SkillDocumentError, type SkillDocumentService } from './skill-document-service.ts';
import type { Invalidation, ProjectEventMessage, ProjectStatus } from './types.ts';
import { WebHostRoutes, type WebHostEpochSource, type WebHostLaunchOptions } from './web-host-routes.ts';
+import { workbenchAssetCacheControl } from './workbench-assets.ts';
import { isWorkbenchShellPath } from './workbench-shell-paths.ts';
import {
diagnostic,
@@ -1058,7 +1059,10 @@ export class ForegroundServer {
: decodedAssetPath(request.url);
const asset = await this.#assets?.read(path);
if (asset === undefined) return responseDiagnostic(response, diagnostic('AB8007', 'Route was not found.', 404));
- response.writeHead(200, { 'content-type': asset.contentType });
+ response.writeHead(200, {
+ 'cache-control': workbenchAssetCacheControl(path),
+ 'content-type': asset.contentType,
+ });
response.end(method === 'HEAD' ? undefined : asset.body);
}
}
diff --git a/packages/agent-bundle/src/dev/workbench-assets.ts b/packages/agent-bundle/src/dev/workbench-assets.ts
index 0cc20caab..bca351507 100644
--- a/packages/agent-bundle/src/dev/workbench-assets.ts
+++ b/packages/agent-bundle/src/dev/workbench-assets.ts
@@ -39,7 +39,25 @@ const packageRoot = basename(import.meta.dirname) === 'dist'
const defaultRoot = (): string => resolve(packageRoot, 'dist', 'workbench');
-const contentTypeFor = (path: string): string => contentTypes[extname(path).toLowerCase()] ?? 'application/octet-stream';
+// The Workbench build copies its attribution files into the asset tree without
+// an extension (`THIRD_PARTY_NOTICES`, `src/mcp/APP-RENDERER-LICENSE`). Those
+// conventional names are plain text a browser should render; every other
+// extensionless file keeps the binary fallback.
+const noticeFileName = /^(?:[a-z0-9]+[-_])*(?:licen[cs]e|notices?|copying)$/iu;
+
+const contentTypeFor = (path: string): string => {
+ const extension = extname(path).toLowerCase();
+ if (extension === '') return noticeFileName.test(basename(path)) ? 'text/plain; charset=utf-8' : 'application/octet-stream';
+ return contentTypes[extension] ?? 'application/octet-stream';
+};
+
+const contentHashedAsset = /(?:^|\/)[^/]+\.[a-f0-9]{8}(?:\.[^./]+)+$/iu;
+
+export const workbenchDocumentCacheControl = 'no-store';
+export const workbenchHashedAssetCacheControl = 'public, max-age=31536000, immutable';
+
+export const workbenchAssetCacheControl = (servedPath: string): string =>
+ contentHashedAsset.test(servedPath) ? workbenchHashedAssetCacheControl : workbenchDocumentCacheControl;
/**
* Reads only regular files from the fixed prebuilt workbench tree. The HTTP
diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts
index e4a00183f..e9c5d5d45 100644
--- a/packages/agent-bundle/tests/cli.test.ts
+++ b/packages/agent-bundle/tests/cli.test.ts
@@ -925,7 +925,7 @@ it('reports an unselected inspect target on JSON and human output', async () =>
}
}, 30_000 * timeScale);
-it('dumps the synthesized bundler configuration with inspect --bundler', async () => {
+it('dumps the lowered Rspack configuration of every output with inspect --bundler', async () => {
const project = await createCliProject();
try {
await mkdir(join(project.root, 'src'), { recursive: true });
@@ -936,7 +936,7 @@ it('dumps the synthesized bundler configuration with inspect --bundler', async (
" plugin: { name: 'cli-fixture', version: '1.0.0' },",
" targets: ['portable'],",
" scripts: { tool: './src/tool.ts' },",
- " tools: { rspack: { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } } },",
+ " tools: { rspack: { resolve: { alias: { '@fixture': './src' } } } },",
'};',
'',
].join('\n'),
@@ -949,7 +949,12 @@ it('dumps the synthesized bundler configuration with inspect --bundler', async (
readonly selected: {
readonly bundler: {
readonly entries: readonly {
- readonly config: { readonly tools: { readonly rspack: readonly unknown[] } };
+ readonly config: {
+ readonly name: string;
+ readonly output: { readonly path: string };
+ readonly plugins: readonly string[];
+ readonly resolve: { readonly alias: Readonly> };
+ };
readonly kind: string;
readonly name: string;
}[];
@@ -957,18 +962,24 @@ it('dumps the synthesized bundler configuration with inspect --bundler', async (
};
};
const script = document.selected.bundler.entries.find((entry) => entry.kind === 'script');
+ // The dump is what Rslib resolved for the compiler, not the authored
+ // config: the hatch alias sits beside the framework's reserved ones, and
+ // the per-build artifact root appears as its stable token.
expect(script).toMatchObject({
config: {
- output: { distPath: { root: '' } },
- tools: {
- rspack: [
- { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } },
- '[function enforceInvariants]',
- ],
+ name: 'agent-bundle-scripts-tool',
+ output: { filename: 'scripts/tool.mjs', path: '' },
+ resolve: {
+ alias: {
+ '@fixture': './src',
+ 'agent-bundle/meta$': `${project.root}/.agent-bundle-virtual/meta.mjs`,
+ },
},
},
name: 'tool',
});
+ expect(script?.config.plugins).toContain('[object VirtualModulesPlugin]');
+ expect(json.stdout).not.toContain(`${project.root}/`);
const repeated = await runSourceCliWithOutput(['inspect', '--root', project.root, '--bundler', '--json']);
expect(repeated.stdout).toBe(json.stdout);
@@ -976,7 +987,8 @@ it('dumps the synthesized bundler configuration with inspect --bundler', async (
const human = await runSourceCliWithOutput(['inspect', '--root', project.root, '--bundler']);
expect(human).toMatchObject({ code: 0, stderr: '' });
expect(human.stdout).toContain('"kind": "script"');
- expect(human.stdout).toContain('[function enforceInvariants]');
+ expect(human.stdout).toContain('"name": "agent-bundle-scripts-tool"');
+ expect(human.stdout).toContain('[object ArtifactDependencyAuditPlugin]');
const ambiguous = await runSourceCliWithOutput(['inspect', '--root', project.root, '--bundler', '--skills']);
expect(ambiguous.code).toBe(1);
diff --git a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts
index 1705ba166..376027bf6 100644
--- a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts
+++ b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts
@@ -23,11 +23,17 @@ const buildPackage = async (): Promise => {
};
describe.sequential('workbench package build', () => {
-it('copies stable prebuilt workbench assets and the exact app-renderer license into the package distribution', async () => {
+const hashedWorkbenchBundle = (kind: 'css' | 'js'): RegExp =>
+ kind === 'css' ? /^index\.[a-f0-9]{8}\.css$/u : /^index\.[a-f0-9]{8}\.js$/u;
+
+it('copies content-hashed prebuilt workbench assets and the exact app-renderer license into the package distribution', async () => {
await buildPackage();
await expect(access(join(packageRoot, 'dist', 'workbench', 'index.html'))).resolves.toBeUndefined();
- await expect(readFile(join(packageRoot, 'dist', 'workbench', 'static', 'js', 'index.js'), 'utf8')).resolves.toContain('Workbench navigation');
+ const jsRoot = join(packageRoot, 'dist', 'workbench', 'static', 'js');
+ const hashedJs = (await readdir(jsRoot)).find((name) => hashedWorkbenchBundle('js').test(name));
+ if (hashedJs === undefined) throw new Error('Expected a content-hashed workbench index.js.');
+ await expect(readFile(join(jsRoot, hashedJs), 'utf8')).resolves.toContain('Workbench navigation');
await expect(readFile(join(packageRoot, 'dist', 'workbench', 'THIRD_PARTY_NOTICES'), 'utf8')).resolves.toContain('MCP Inspector');
await expect(readFile(join(packageRoot, 'dist', 'workbench', appRendererLicense), 'utf8')).resolves.toBe(
await readFile(join(workbenchRoot, appRendererLicense), 'utf8'),
@@ -71,7 +77,8 @@ it('serves prebuilt workbench assets from an installed tarball without the repos
expect(listing.stdout).toContain('package/dist/workbench/THIRD_PARTY_NOTICES');
expect(listing.stdout).toContain('package/dist/workbench/src/mcp/APP-RENDERER-LICENSE');
expect(listing.stdout).not.toMatch(/package\/dist\/workbench\/.*\.map$/mu);
- expect(listing.stdout).not.toMatch(/package\/dist\/workbench\/.*-[a-f0-9]{8,}/iu);
+ expect(listing.stdout).toMatch(/package\/dist\/workbench\/static\/js\/index\.[a-f0-9]{8}\.js$/mu);
+ expect(listing.stdout).toMatch(/package\/dist\/workbench\/static\/css\/index\.[a-f0-9]{8}\.css$/mu);
await writeFile(join(consumer, 'package.json'), '{"type":"module"}\n');
await execFile('npm', ['install', ...cachedNpmInstallArguments, tarball], { cwd: consumer, env: installedEnvironment() });
diff --git a/packages/agent-bundle/tests/dev-workbench.test.ts b/packages/agent-bundle/tests/dev-workbench.test.ts
index fb2838fac..d416cd791 100644
--- a/packages/agent-bundle/tests/dev-workbench.test.ts
+++ b/packages/agent-bundle/tests/dev-workbench.test.ts
@@ -1,5 +1,5 @@
import { access, mkdtemp, mkdir, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
-import { get as httpGet } from 'node:http';
+import { get as httpGet, request as httpRequest } from 'node:http';
import { tmpdir } from 'node:os';
import { join, relative } from 'node:path';
@@ -294,6 +294,97 @@ it('contains prebuilt workbench asset reads to their declared root', async () =>
}
});
+it('types extensionless notice and license files as text and keeps the binary fallback for other extensionless files', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-notices-'));
+ try {
+ await Promise.all([mkdir(join(root, 'src', 'mcp'), { recursive: true }), mkdir(join(root, 'static'), { recursive: true })]);
+ await Promise.all([
+ writeFile(join(root, 'THIRD_PARTY_NOTICES'), 'Agent Bundle workbench includes an MCP App renderer.\n'),
+ writeFile(join(root, 'src', 'mcp', 'APP-RENDERER-LICENSE'), 'MIT License\n'),
+ writeFile(join(root, 'LICENSE'), 'Apache License\n'),
+ writeFile(join(root, 'NOTICE'), 'Agent Bundle\n'),
+ writeFile(join(root, 'static', 'payload'), Buffer.from([0x00, 0xff, 0x10, 0x80])),
+ writeFile(join(root, 'static', 'licensed-fixture'), 'not a notice\n'),
+ ]);
+ const assets = createWorkbenchAssetSource({ root });
+
+ const text = { contentType: 'text/plain; charset=utf-8' };
+ await expect(assets.read('THIRD_PARTY_NOTICES')).resolves.toMatchObject(text);
+ await expect(assets.read('src/mcp/APP-RENDERER-LICENSE')).resolves.toMatchObject(text);
+ await expect(assets.read('LICENSE')).resolves.toMatchObject(text);
+ await expect(assets.read('NOTICE')).resolves.toMatchObject(text);
+ await expect(assets.read('static/payload')).resolves.toMatchObject({ contentType: 'application/octet-stream' });
+ await expect(assets.read('static/licensed-fixture')).resolves.toMatchObject({ contentType: 'application/octet-stream' });
+ } finally {
+ await rm(root, { force: true, recursive: true });
+ }
+});
+
+it('serves packaged notice files over HTTP as text without loosening asset path checks', async () => {
+ const project = await createProjectFixture();
+ const assetsRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-notice-server-'));
+ await Promise.all([mkdir(join(assetsRoot, 'src', 'mcp'), { recursive: true }), mkdir(join(assetsRoot, 'static'), { recursive: true })]);
+ await Promise.all([
+ writeFile(join(assetsRoot, 'index.html'), 'Agent Bundle workbench '),
+ writeFile(join(assetsRoot, 'THIRD_PARTY_NOTICES'), 'Agent Bundle workbench includes an MCP App renderer.\n'),
+ writeFile(join(assetsRoot, 'src', 'mcp', 'APP-RENDERER-LICENSE'), 'MIT License\n'),
+ writeFile(join(assetsRoot, 'static', 'payload'), Buffer.from([0x00, 0xff, 0x10, 0x80])),
+ ]);
+ try {
+ const server = await startDevServer({
+ assets: createWorkbenchAssetSource({ root: assetsRoot }),
+ open: false,
+ openBrowser: async () => {},
+ port: 0,
+ root: project.root,
+ });
+ // `fetch` collapses `%2e%2e` into `..` before the request leaves, so the
+ // traversal probes go over `node:http`, which sends the path verbatim.
+ const address = new URL(server.url);
+ const probe = (path: string, method: 'GET' | 'HEAD' = 'GET'): Promise> => new Promise((resolvePromise, rejectPromise) => {
+ const request = httpRequest({ host: address.hostname, method, path, port: address.port }, (response) => {
+ let body = '';
+ response.setEncoding('utf8');
+ response.on('data', (chunk: string) => {
+ body += chunk;
+ });
+ response.once('end', () => resolvePromise({ body, contentType: response.headers['content-type'], status: response.statusCode ?? 0 }));
+ response.once('error', rejectPromise);
+ });
+ request.once('error', rejectPromise);
+ request.end();
+ });
+
+ await expect(probe('/THIRD_PARTY_NOTICES')).resolves.toEqual({
+ body: 'Agent Bundle workbench includes an MCP App renderer.\n',
+ contentType: 'text/plain; charset=utf-8',
+ status: 200,
+ });
+ await expect(probe('/src/mcp/APP-RENDERER-LICENSE')).resolves.toEqual({
+ body: 'MIT License\n',
+ contentType: 'text/plain; charset=utf-8',
+ status: 200,
+ });
+ await expect(probe('/src/mcp/APP-RENDERER-LICENSE', 'HEAD')).resolves.toEqual({
+ body: '',
+ contentType: 'text/plain; charset=utf-8',
+ status: 200,
+ });
+ await expect(probe('/static/payload')).resolves.toMatchObject({ contentType: 'application/octet-stream', status: 200 });
+ await expect(probe('/src/mcp/%2e%2e/%2e%2e/THIRD_PARTY_NOTICES')).resolves.toMatchObject({ status: 400 });
+ await expect(probe('/src/mcp/APP-RENDERER-LICENSE%00')).resolves.toMatchObject({ status: 400 });
+ await expect(probe('/src/mcp')).resolves.toMatchObject({ status: 404 });
+ await expect(probe('/LICENSE')).resolves.toMatchObject({ status: 404 });
+ await server.close();
+ } finally {
+ await Promise.all([removeProjectFixture(project.root), rm(assetsRoot, { force: true, recursive: true })]);
+ }
+}, 30_000);
+
it('starts a loopback server with prebuilt assets, does not open on --no-open, and closes its coordinator', async () => {
const project = await createProjectFixture();
const assetsRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-page-'));
diff --git a/packages/agent-bundle/tests/inspect-bundler.test.ts b/packages/agent-bundle/tests/inspect-bundler.test.ts
index f2a78f6bc..2542b70f4 100644
--- a/packages/agent-bundle/tests/inspect-bundler.test.ts
+++ b/packages/agent-bundle/tests/inspect-bundler.test.ts
@@ -1,4 +1,4 @@
-import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
+import { mkdir, mkdtemp, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -6,10 +6,13 @@ import { afterEach, expect, it } from '@rstest/core';
import { planHooks } from '../src/adapters/hook-contract.ts';
import { inspect, type BundlerInspectionEntry, type ReadyInspectResult } from '../src/api.ts';
+import { appRuntimePath } from '../src/build/app-runtime.ts';
+import { launchEnvRuntimePath, mcpEntryRuntimePath, terminalCapabilityRuntimePath } from '../src/build/entry-shell.ts';
import { composeBundlerInspection } from '../src/build/inspect-bundler.ts';
import { stableJson } from '../src/core/digest.ts';
import type { NormalizedHook, NormalizedPlugin } from '../src/core/types.ts';
import type { CompiledEventPreflight } from '../src/routes/types.ts';
+import { workspaceNodeModules } from './helpers/workspace-paths.ts';
const roots: string[] = [];
@@ -17,13 +20,23 @@ afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true })));
});
+/**
+ * The lowering runs the same Rslib/Rsbuild config resolution the build runs,
+ * which reads every authored entry from disk and — for the `dts` lib entry —
+ * resolves the project's own TypeScript, so the fixture links one in.
+ */
const createProject = async (): Promise => {
const parent = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-inspect-bundler-')));
roots.push(parent);
const root = join(parent, 'project');
await mkdir(join(root, 'src', 'mcp'), { recursive: true });
+ await mkdir(join(root, 'node_modules'), { recursive: true });
await Promise.all([
+ symlink(join(workspaceNodeModules, 'typescript'), join(root, 'node_modules', 'typescript'), 'dir'),
writeFile(join(root, 'package.json'), '{"type":"module"}\n'),
+ writeFile(join(root, 'tsconfig.json'), JSON.stringify({
+ compilerOptions: { experimentalDecorators: true },
+ })),
writeFile(
join(root, 'agent-bundle.config.ts'),
[
@@ -41,7 +54,7 @@ const createProject = async (): Promise => {
' },',
' },',
' tools: {',
- " rsbuild: { output: { legalComments: 'linked' } },",
+ " rsbuild: { resolve: { alias: { '@fixture': './src' } } },",
" rspack: { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } },",
' },',
'};',
@@ -76,7 +89,27 @@ const entryOf = (
return entry;
};
-it('surfaces every synthesized bundler config with the tools hatch merged over the profile', async () => {
+/** The lowered Rspack configuration of one entry, typed loosely for the assertions. */
+interface LoweredConfig {
+ readonly context?: string;
+ readonly entry?: Readonly>;
+ readonly externals?: readonly unknown[];
+ readonly mode?: string;
+ readonly module?: { readonly parser?: { readonly javascript?: Readonly> } };
+ readonly name?: string;
+ readonly output?: Readonly>;
+ readonly plugins?: readonly string[];
+ readonly resolve?: {
+ readonly alias?: Readonly>;
+ readonly extensionAlias?: Readonly>;
+ readonly tsConfig?: { readonly configFile?: string };
+ };
+ readonly target?: readonly string[];
+}
+
+const loweredConfig = (entry: BundlerInspectionEntry): LoweredConfig => entry.config as LoweredConfig;
+
+it('renders the lowered Rspack configuration of every compiled output with the tools hatch applied', async () => {
const root = await createProject();
const result = await inspect({ focus: 'bundler', root });
expect(result.state).toBe('ready');
@@ -86,10 +119,17 @@ it('surfaces every synthesized bundler config with the tools hatch merged over t
expect(entries.map((entry) => `${entry.target ?? 'package'}:${entry.kind}:${entry.name}`)).toEqual([
'package:bin:bundler-fixture',
'package:lib:index',
- 'portable:mcp-apps:mcp-apps',
+ 'portable:mcp-app:dashboard',
'portable:mcp-entry:curator',
'portable:script:tool',
]);
+ expect(entries.map((entry) => loweredConfig(entry).name)).toEqual([
+ 'agent-bundle-bin-bundler-fixture',
+ 'agent-bundle-index',
+ 'dashboard',
+ expect.stringMatching(/^agent-bundle-mcp-mcp-curator-[a-f\d]{8}$/u),
+ 'agent-bundle-scripts-tool',
+ ]);
const script = entryOf(entries, 'script', 'tool');
expect(script).toMatchObject({
@@ -98,98 +138,125 @@ it('surfaces every synthesized bundler config with the tools hatch merged over t
source: `${root}/src/tool.ts`,
target: 'portable',
});
- // The generated executable envelope wraps the `main` export.
+ // The generated executable envelope wraps the `main` export, and the
+ // lowered entry is redirected to it in the reserved virtual namespace.
expect(script.generatedEntry).toContain('process.argv.slice(2)');
- expect(script.config).toMatchObject({
- 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.
- plugins: [{ name: 'rsbuild:react' }],
+ const scriptConfig = loweredConfig(script);
+ expect(scriptConfig).toMatchObject({
+ context: root,
+ entry: { tool: [`${root}/.agent-bundle-virtual/tool-entry.mjs`] },
+ mode: 'production',
+ // Generated code spells run-time paths as `new URL(…, import.meta.url)`;
+ // the invariant layer keeps the bundler from turning them into assets.
+ module: { parser: { javascript: { url: false, worker: false } } },
output: {
- distPath: { root: '' },
- filename: { js: 'scripts/tool.mjs' },
- // The consumer rsbuild hatch merges over the framework profile value.
- legalComments: 'linked',
- target: 'node',
+ asyncChunks: false,
+ // Rslib lowers the per-entry filename to its own resolver function.
+ filename: '[function jsFilename]',
+ module: true,
+ // The per-build artifact root appears as the stable token.
+ path: '',
},
- syntax: 'es2022',
- tools: {
- // Rslib's `new URL()` rule is switched off so filesystem URLs in
- // generated and plugin code survive verbatim.
- bundlerChain: '[function preserveResourceReferences]',
- // The consumer rspack hatch is merged before the framework invariant
- // hook, which always runs last.
- rspack: [
- { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } },
- '[function enforceInvariants]',
- ],
+ resolve: {
+ alias: {
+ // The consumer rsbuild hatch merges over the framework profile.
+ '@fixture': `${root}/src`,
+ // The framework's reserved aliases land as exact-match keys.
+ 'agent-bundle/meta$': `${root}/.agent-bundle-virtual/meta.mjs`,
+ 'agent-bundle/terminal-capability$': terminalCapabilityRuntimePath(),
+ },
},
});
+ expect(scriptConfig.target).toContain('node');
+ // The consumer rspack hatch is merged before the invariant hook and lands in
+ // the lowered config after Rsbuild's own defaults.
+ expect(scriptConfig.resolve?.extensionAlias?.['.js']?.slice(-2)).toEqual(['.js', '.ts']);
+ // Rslib's node target leaves only the Node built-ins (and pnpapi) external.
+ expect(scriptConfig.externals).toEqual(expect.arrayContaining(['fs', '[regexp /^node:/]', 'pnpapi']));
+ // Framework plugins survive the hatch: the generated sources are served
+ // from memory and the dependency audit reads the final module graph.
+ expect(scriptConfig.plugins).toEqual(expect.arrayContaining([
+ '[object VirtualModulesPlugin]',
+ '[object ArtifactDependencyAuditPlugin]',
+ ]));
const mcpEntry = entryOf(entries, 'mcp-entry', 'curator');
expect(mcpEntry.generatedEntry).toContain('runGeneratedStdioMcpEntry');
expect(mcpEntry.source).toBe(`${root}/src/mcp/curator.ts`);
expect(mcpEntry.outputPath).toMatch(/^mcp\/mcp-curator-[a-f\d]{8}\.mjs$/u);
+ expect(loweredConfig(mcpEntry)).toMatchObject({
+ output: { filename: '[function jsFilename]', path: '' },
+ resolve: {
+ alias: {
+ 'agent-bundle/launch-env$': launchEnvRuntimePath(),
+ 'agent-bundle/mcp-entry$': mcpEntryRuntimePath(),
+ },
+ },
+ });
const bin = entryOf(entries, 'bin', 'bundler-fixture');
expect(bin).toMatchObject({
- config: {
- banner: { js: '#!/usr/bin/env node' },
- output: { distPath: { root: 'dist' } },
- },
outputPath: 'dist/bin/bundler-fixture.js',
source: `${root}/src/cli.ts`,
});
expect(bin.generatedEntry).toContain('process.argv.slice(2)');
+ const binConfig = loweredConfig(bin);
+ // The package build's output root is its published destination.
+ expect(binConfig.output).toMatchObject({ filename: '[function jsFilename]', path: `${root}/dist` });
+ // The shebang banner lowers to Rspack's banner plugin.
+ expect(binConfig.plugins).toContain('[object BannerPlugin]');
const lib = entryOf(entries, 'lib', 'index');
- expect(lib).toMatchObject({
- config: {
- dts: true,
- source: { tsconfigPath: '' },
- },
- outputPath: 'dist/index.js',
- });
+ expect(lib.outputPath).toBe('dist/index.js');
+ expect(loweredConfig(lib).resolve?.tsConfig).toEqual({ configFile: '', references: 'auto' });
+ const legacyDecorators = (entry: BundlerInspectionEntry): boolean =>
+ stableJson(entry.config).includes('"legacyDecorator":true');
+ expect(legacyDecorators(lib)).toBe(true);
+ expect(legacyDecorators(lib)).toBe(legacyDecorators(script));
+ expect((await readdir(join(root, 'node_modules')))
+ .filter((name) => name.startsWith('.agent-bundle-dts-'))).toEqual([]);
- const apps = entryOf(entries, 'mcp-apps', 'mcp-apps');
- expect(apps.bundler).toBe('rsbuild');
- expect(apps.config).toMatchObject({
- environments: {
- dashboard: {
- // Every view carries the React plugin and the document defaults
- // (mount point, title = the App name), whatever its entry extension.
- html: { inject: 'body', mountId: 'root', title: 'dashboard' },
- plugins: [{ name: 'rsbuild:react' }],
- source: { entry: { dashboard: `${root}/src/view.tsx` } },
+ const app = entryOf(entries, 'mcp-app', 'dashboard');
+ expect(app).toMatchObject({
+ bundler: 'rsbuild',
+ outputPath: 'mcp-apps/dashboard.html',
+ source: `${root}/src/view.tsx`,
+ target: 'portable',
+ });
+ expect(app.generatedEntry).toBeUndefined();
+ const appConfig = loweredConfig(app);
+ expect(appConfig).toMatchObject({
+ entry: { dashboard: [`${root}/src/view.tsx`] },
+ mode: 'production',
+ output: { asyncChunks: false, path: '' },
+ resolve: {
+ alias: {
+ // The consumer rsbuild hatch also merges over the view profile.
+ '@fixture': `${root}/src`,
+ 'agent-bundle/app$': appRuntimePath(),
+ 'agent-bundle/meta$': `${root}/.agent-bundle-virtual/meta.mjs`,
},
},
- output: {
- distPath: { html: 'mcp-apps', root: '' },
- inlineScripts: true,
- // The consumer rsbuild hatch also merges over the view profile.
- legalComments: 'linked',
- // The inspection renders the production profile: no source maps.
- sourceMap: false,
- },
- tools: {
- rspack: [
- { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } },
- '[function enforceInvariants]',
- ],
- },
});
+ expect(appConfig.target).toContain('web');
+ expect(appConfig.plugins).toEqual(expect.arrayContaining([
+ '[object NormalModuleReplacementPlugin]',
+ '[object VirtualModulesPlugin]',
+ '[object ArtifactDependencyAuditPlugin]',
+ ]));
});
-it('keeps the bundler inspection deterministic and JSON-serializable', async () => {
+it('keeps the bundler inspection deterministic, JSON-serializable, and free of the staged output path', async () => {
const root = await createProject();
const [first, second] = await Promise.all([
inspect({ focus: 'bundler', root }),
inspect({ focus: 'bundler', root }),
]);
expect(first.state).toBe('ready');
- expect(stableJson((first as ReadyInspectResult).selected))
- .toBe(stableJson((second as ReadyInspectResult).selected));
+ const rendered = stableJson((first as ReadyInspectResult).selected);
+ expect(rendered).toBe(stableJson((second as ReadyInspectResult).selected));
+ expect(rendered).not.toContain(`${root}/`);
+ expect(rendered).not.toContain(`${root}/`);
});
it('keeps the bundler focus out of unfocused inspections', async () => {
@@ -201,18 +268,53 @@ it('keeps the bundler focus out of unfocused inspections', async () => {
expect((result as ReadyInspectResult).output.manifest).toBeUndefined();
});
+it('reports a tools hatch the lowering refuses as an invalid inspection naming the refusal', async () => {
+ const root = await createProject();
+ await writeFile(
+ join(root, 'agent-bundle.config.ts'),
+ [
+ 'export default {',
+ " plugin: { name: 'bundler-fixture', version: '1.0.0' },",
+ " targets: ['portable'],",
+ " scripts: { tool: './src/tool.ts' },",
+ // Aliasing a reserved specifier is refused only once the invariant hook
+ // runs over the lowered config, so it surfaces from the inspection.
+ " tools: { rspack: { resolve: { alias: { 'agent-bundle/meta': './src/index.ts' } } } },",
+ '};',
+ '',
+ ].join('\n'),
+ );
+ const result = await inspect({ focus: 'bundler', root });
+ expect(result.state).toBe('invalid');
+ expect(result.diagnostics).toEqual([expect.objectContaining({
+ code: 'AB7001',
+ message: expect.stringContaining('The tools escape hatch must not alias the reserved specifier matched by "agent-bundle/meta"'),
+ })]);
+ expect((await readdir(join(root, 'node_modules')))
+ .filter((name) => name.startsWith('.agent-bundle-dts-'))).toEqual([]);
+});
+
it('inspects the per-host preflight wrapper under the composite identity', async () => {
+ const parent = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-inspect-hooks-')));
+ roots.push(parent);
+ const root = join(parent, 'project');
+ await mkdir(join(root, 'src', 'events', 'tool'), { recursive: true });
+ await Promise.all([
+ writeFile(join(root, 'package.json'), '{"type":"module"}\n'),
+ writeFile(join(root, 'src', 'events', 'tool', 'before.tsx'), 'export default () => undefined;\n'),
+ writeFile(join(root, 'src', 'events', 'tool', 'before.preflight.ts'), 'export default () => true;\n'),
+ ]);
const preflight: CompiledEventPreflight = Object.freeze({
provenance: Object.freeze({ kind: 'conventional' as const, relativePath: 'src/events/tool/before.preflight.ts' }),
- source: '/project/src/events/tool/before.preflight.ts',
+ source: `${root}/src/events/tool/before.preflight.ts`,
});
const hook: NormalizedHook = {
event: 'beforeTool',
eventRoute: { event: 'tool/before', fallback: 'none', preflight, runtime: 'shared' },
id: 'hook:event-route:tool-before',
name: 'event-route-tool-before',
- provenance: { kind: 'conventional', sourcePath: '/project/src/events/tool/before.tsx' },
- source: '/project/src/events/tool/before.tsx',
+ provenance: { kind: 'conventional', sourcePath: `${root}/src/events/tool/before.tsx` },
+ source: `${root}/src/events/tool/before.tsx`,
targets: ['claude', 'codex'],
tools: [],
};
@@ -223,7 +325,7 @@ it('inspects the per-host preflight wrapper under the composite identity', async
metadata: {
id: 'plugin:preflight-inspect',
name: 'preflight-inspect',
- provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' },
+ provenance: { kind: 'config', sourcePath: `${root}/agent-bundle.config.ts` },
version: '1.0.0',
},
runtime: { node: '22.12.0' },
@@ -232,7 +334,7 @@ it('inspects the per-host preflight wrapper under the composite identity', async
targets: ['claude', 'codex'].map((name) => ({
id: `target:${name}`,
name,
- provenance: { kind: 'config' as const, sourcePath: '/project/agent-bundle.config.ts' },
+ provenance: { kind: 'config' as const, sourcePath: `${root}/agent-bundle.config.ts` },
})),
};
const hookEntries = ['claude', 'codex'].flatMap((host) => planHooks(model, host, {
@@ -256,7 +358,7 @@ it('inspects the per-host preflight wrapper under the composite identity', async
selected: ['claude', 'codex'],
},
model,
- projectRoot: '/project',
+ projectRoot: root,
});
const hooks = inspection.entries.filter((entry) => entry.kind === 'hook');
expect(hooks.map((entry) => entry.outputPath).sort()).toEqual([
@@ -270,5 +372,10 @@ it('inspects the per-host preflight wrapper under the composite identity', async
expect(entry.generatedEntry).toContain('agent-bundle/event-project');
expect(entry.generatedEntry).toContain('.execute.mjs');
expect(entry.generatedEntry).not.toContain('AGENT_BUNDLE_HOOK_HOST');
+ expect(loweredConfig(entry)).toMatchObject({
+ name: `agent-bundle-${entry.outputPath.replace(/\.mjs$/u, '').replaceAll('/', '-')}`,
+ output: { filename: '[function jsFilename]', path: '' },
+ resolve: { alias: { 'agent-bundle/launch-env$': launchEnvRuntimePath() } },
+ });
}
});
diff --git a/packages/agent-bundle/tests/workbench-asset-cache.test.ts b/packages/agent-bundle/tests/workbench-asset-cache.test.ts
new file mode 100644
index 000000000..661ee0ac5
--- /dev/null
+++ b/packages/agent-bundle/tests/workbench-asset-cache.test.ts
@@ -0,0 +1,88 @@
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { expect, it } from '@rstest/core';
+
+import { ProjectEventHub } from '../src/dev/events.ts';
+import { startForegroundServer, type ForegroundCoordinator } from '../src/dev/foreground-server.ts';
+import type { Invalidation, ProjectStatus } from '../src/dev/types.ts';
+import {
+ createWorkbenchAssetSource,
+ workbenchAssetCacheControl,
+ workbenchDocumentCacheControl,
+ workbenchHashedAssetCacheControl,
+} from '../src/dev/workbench-assets.ts';
+
+const status = (): ProjectStatus => ({
+ artifact: { state: 'missing' },
+ build: { state: 'idle' },
+ source: { diagnostics: [], state: 'unknown' },
+});
+
+const coordinator: ForegroundCoordinator = {
+ close: async () => undefined,
+ rebuild: async (_invalidation: Invalidation) => undefined,
+ start: async () => undefined,
+ status,
+};
+
+it('treats only content-hashed workbench filenames as immutable', () => {
+ expect(workbenchAssetCacheControl('index.html')).toBe(workbenchDocumentCacheControl);
+ expect(workbenchAssetCacheControl('THIRD_PARTY_NOTICES')).toBe(workbenchDocumentCacheControl);
+ expect(workbenchAssetCacheControl('src/mcp/APP-RENDERER-LICENSE')).toBe(workbenchDocumentCacheControl);
+ expect(workbenchAssetCacheControl('static/js/index.js')).toBe(workbenchDocumentCacheControl);
+ expect(workbenchAssetCacheControl('static/js/index.deadbeef.js')).toBe(workbenchHashedAssetCacheControl);
+ expect(workbenchAssetCacheControl('static/css/index.feedface.css')).toBe(workbenchHashedAssetCacheControl);
+ expect(workbenchAssetCacheControl('static/image/logo.abcdef12.png')).toBe(workbenchHashedAssetCacheControl);
+ expect(workbenchDocumentCacheControl).toBe('no-store');
+ expect(workbenchHashedAssetCacheControl).toBe('public, max-age=31536000, immutable');
+});
+
+it('serves hashed workbench assets as immutable and documents as no-store', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-asset-cache-'));
+ await Promise.all([
+ mkdir(join(root, 'static', 'js'), { recursive: true }),
+ mkdir(join(root, 'static', 'css'), { recursive: true }),
+ mkdir(join(root, 'static', 'image'), { recursive: true }),
+ ]);
+ await Promise.all([
+ writeFile(join(root, 'index.html'), 'Workbench '),
+ writeFile(join(root, 'THIRD_PARTY_NOTICES'), 'notice\n'),
+ writeFile(join(root, 'static', 'js', 'index.js'), 'export {};\n'),
+ writeFile(join(root, 'static', 'js', 'index.deadbeef.js'), 'export {};\n'),
+ writeFile(join(root, 'static', 'css', 'index.feedface.css'), 'body{}\n'),
+ writeFile(join(root, 'static', 'image', 'logo.abcdef12.png'), 'png\n'),
+ ]);
+ const server = await startForegroundServer({
+ assets: createWorkbenchAssetSource({ root }),
+ coordinator,
+ eventHub: new ProjectEventHub(),
+ port: 0,
+ sessionToken: 'test-session-token',
+ });
+ try {
+ const [document, shell, hashedJs, hashedCss, hashedImage, unhashedJs, notices] = await Promise.all([
+ fetch(server.url),
+ fetch(`${server.url}/routes/mcp/curator/tool/search`),
+ fetch(`${server.url}/static/js/index.deadbeef.js`),
+ fetch(`${server.url}/static/css/index.feedface.css`),
+ fetch(`${server.url}/static/image/logo.abcdef12.png`),
+ fetch(`${server.url}/static/js/index.js`),
+ fetch(`${server.url}/THIRD_PARTY_NOTICES`),
+ ]);
+ expect(document.status).toBe(200);
+ expect(document.headers.get('cache-control')).toBe('no-store');
+ expect(shell.status).toBe(200);
+ expect(shell.headers.get('cache-control')).toBe('no-store');
+ expect(await shell.text()).toContain('Workbench');
+ expect(hashedJs.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
+ expect(hashedCss.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
+ expect(hashedImage.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
+ expect(unhashedJs.headers.get('cache-control')).toBe('no-store');
+ expect(notices.headers.get('cache-control')).toBe('no-store');
+ } finally {
+ await server.close();
+ await rm(root, { force: true, recursive: true });
+ }
+});
diff --git a/packages/workbench/rsbuild.config.ts b/packages/workbench/rsbuild.config.ts
index 27cca975d..38acb747a 100644
--- a/packages/workbench/rsbuild.config.ts
+++ b/packages/workbench/rsbuild.config.ts
@@ -13,12 +13,18 @@ const sourceRoot = resolve(import.meta.dirname, 'src');
* so the foreground must allowlist this dev origin; `strictPort` fails loudly
* on a busy port instead of silently moving the UI to one it has not allowed.
*/
-export const createWorkbenchConfig = (apiProxyTarget = process.env.AGENT_BUNDLE_WORKBENCH_API_PROXY) => ({
+export const createWorkbenchConfig = (
+ apiProxyTarget = process.env.AGENT_BUNDLE_WORKBENCH_API_PROXY,
+ mode: 'development' | 'production' = 'production',
+) => ({
html: {
template: resolve(import.meta.dirname, 'index.html'),
},
output: {
assetPrefix: '/',
+ // Desktop floor: Electron 28 embeds Chromium 120.
+ // https://releases.electronjs.org/release/v28.0.0
+ overrideBrowserslist: ['chrome >= 120'],
copy: [
{ from: resolve(import.meta.dirname, 'THIRD_PARTY_NOTICES'), to: 'THIRD_PARTY_NOTICES', toType: 'file' },
{ from: resolve(sourceRoot, 'mcp', 'APP-RENDERER-LICENSE'), to: 'src/mcp/APP-RENDERER-LICENSE', toType: 'file' },
@@ -26,12 +32,18 @@ export const createWorkbenchConfig = (apiProxyTarget = process.env.AGENT_BUNDLE_
distPath: {
root: 'dist',
},
- filenameHash: false,
- filename: {
- assets: '[name][ext]',
- css: '[name].css',
- js: '[name].js',
- },
+ filenameHash: mode === 'production',
+ filename: mode === 'production'
+ ? {
+ assets: '[name].[contenthash:8][ext]',
+ css: '[name].[contenthash:8].css',
+ js: '[name].[contenthash:8].js',
+ }
+ : {
+ assets: '[name][ext]',
+ css: '[name].css',
+ js: '[name].js',
+ },
},
plugins: [pluginReact()],
root: import.meta.dirname,
@@ -55,7 +67,10 @@ export const createWorkbenchConfig = (apiProxyTarget = process.env.AGENT_BUNDLE_
// runner), which disables the NODE_ENV define and minification and ships a
// bundle that crashes in the browser with "process is not defined". Pinning
// the mode to the CLI command keeps builds hermetic regardless of caller env.
-export default defineConfig(({ command }) => ({
- mode: command === 'dev' ? ('development' as const) : ('production' as const),
- ...createWorkbenchConfig(),
-}));
+export default defineConfig(({ command }) => {
+ const mode = command === 'dev' ? ('development' as const) : ('production' as const);
+ return {
+ mode,
+ ...createWorkbenchConfig(undefined, mode),
+ };
+});
diff --git a/packages/workbench/tests/contributor-hmr.e2e.test.ts b/packages/workbench/tests/contributor-hmr.e2e.test.ts
index 98a6a5eba..2c725e86d 100644
--- a/packages/workbench/tests/contributor-hmr.e2e.test.ts
+++ b/packages/workbench/tests/contributor-hmr.e2e.test.ts
@@ -94,7 +94,7 @@ const startContributorLoop = async (project: ProjectFixture): Promise {
+it('targets the desktop Chromium floor in production and development', () => {
+ for (const command of ['build', 'dev'] as const) {
+ expect(resolveConfig(command)).toMatchObject({
+ output: {
+ overrideBrowserslist: ['chrome >= 120'],
+ },
+ });
+ }
+});
+
+it('hashes production JS, CSS, and assets so the foreground can cache them immutably', () => {
expect(resolveConfig('build')).toMatchObject({
+ mode: 'production',
output: {
copy: [
{ from: join(workbenchRoot, 'THIRD_PARTY_NOTICES'), to: 'THIRD_PARTY_NOTICES', toType: 'file' },
{ from: join(workbenchRoot, 'src/mcp/APP-RENDERER-LICENSE'), to: 'src/mcp/APP-RENDERER-LICENSE', toType: 'file' },
],
+ filenameHash: true,
+ filename: {
+ assets: '[name].[contenthash:8][ext]',
+ css: '[name].[contenthash:8].css',
+ js: '[name].[contenthash:8].js',
+ },
+ },
+ });
+});
+
+it('keeps contributor HMR filenames unhashed', () => {
+ expect(resolveConfig('dev')).toMatchObject({
+ mode: 'development',
+ output: {
filenameHash: false,
filename: {
+ assets: '[name][ext]',
+ css: '[name].css',
+ js: '[name].js',
+ },
+ },
+ });
+ expect(createWorkbenchConfig(undefined, 'development')).toMatchObject({
+ output: {
+ filenameHash: false,
+ filename: {
+ assets: '[name][ext]',
css: '[name].css',
js: '[name].js',
},
@@ -63,13 +99,20 @@ it('configures no dev server block when no foreground target is supplied', () =>
}
});
+const hashedBundleName = (kind: 'css' | 'js'): RegExp =>
+ kind === 'css' ? /^index\.[a-f0-9]{8}\.css$/u : /^index\.[a-f0-9]{8}\.js$/u;
+
it('publishes the workbench application at the foreground server index asset', async () => {
+ const jsRoot = join(workbenchRoot, 'dist', 'static', 'js');
await expect(access(join(workbenchRoot, 'dist', 'index.html'))).resolves.toBeUndefined();
- await expect(access(join(workbenchRoot, 'dist', 'static', 'js', 'index.js'))).resolves.toBeUndefined();
+ const js = (await readdir(jsRoot)).filter((name) => name.endsWith('.js'));
+ expect(js.some((name) => hashedBundleName('js').test(name))).toBe(true);
+ expect(js).not.toContain('index.js');
});
-it('emits browser-safe JS from the prepared production build', async () => {
+it('emits browser-safe content-hashed JS that index.html references', async () => {
const jsRoot = join(workbenchRoot, 'dist', 'static', 'js');
+ const cssRoot = join(workbenchRoot, 'dist', 'static', 'css');
const files = await readdir(jsRoot);
const contents = await Promise.all(
files.filter((name) => name.endsWith('.js')).map(async (name) => ({
@@ -84,5 +127,11 @@ it('emits browser-safe JS from the prepared production build', async () => {
const indexHtml = await readFile(join(workbenchRoot, 'dist', 'index.html'), 'utf8');
expect(indexHtml).toContain('id="root"');
- expect(indexHtml).toContain('/static/js/index.js');
+ const referencedJs = files.filter((name) => indexHtml.includes(`/static/js/${name}`));
+ expect(referencedJs.length).toBeGreaterThan(0);
+ expect(referencedJs.every((name) => /\.[a-f0-9]{8}\.js$/u.test(name))).toBe(true);
+ expect(referencedJs.some((name) => hashedBundleName('js').test(name))).toBe(true);
+ const css = (await readdir(cssRoot)).filter((name) => name.endsWith('.css'));
+ const referencedCss = css.filter((name) => indexHtml.includes(`/static/css/${name}`));
+ expect(referencedCss).toEqual(expect.arrayContaining([expect.stringMatching(hashedBundleName('css'))]));
});
diff --git a/packages/workbench/tests/support/workbench-fixture-config.ts b/packages/workbench/tests/support/workbench-fixture-config.ts
index 0706c529f..3d522a376 100644
--- a/packages/workbench/tests/support/workbench-fixture-config.ts
+++ b/packages/workbench/tests/support/workbench-fixture-config.ts
@@ -43,10 +43,10 @@ const workbenchBrowserAliases = {
* carry no manual `source.define` copy.
* - `pluginReact()` and `workbenchBrowserAliases`.
* - A flat, unhashed `assets/` layout (`.html`, `assets/.js`,
- * `assets/.css`). It differs from the production `static/` tree on
- * purpose: it is the layout the fixtures have always emitted and their
- * servers have always served, and `cleanDistPath: false` because the dist is
- * a fresh `mkdtemp` child the test removes itself.
+ * `assets/.css`). Production emits a content-hashed `static/` tree;
+ * fixtures keep the layout their servers have always served, and
+ * `cleanDistPath: false` because the dist is a fresh `mkdtemp` child the
+ * test removes itself.
*
* Anything a fixture needs beyond this is an explicit, typed option here — a
* test never mutates the returned config.
diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx
index 4dbc98c88..a08cd7020 100644
--- a/website/docs/en/guide/authoring/package-entries.mdx
+++ b/website/docs/en/guide/authoring/package-entries.mdx
@@ -542,9 +542,15 @@ npx agent-bundle inspect --bundler --root .
npx agent-bundle inspect --bundler --root . --target claude --json
```
-That dumps the synthesized configuration for every output the build composes, exactly as the
-build lowers it, using the same functions the build uses — so the dump cannot drift from what
-compiles. Entries the framework wraps also carry the generated wrapper module source.
+That dumps the lowered Rspack configuration of every output the build compiles — one entry per
+compiler, produced by the build's own Rslib (executables) or Rsbuild (MCP App views) lowering and
+stopped where the build would start compiling — so the dump cannot drift from what compiles. You
+see the resolved `resolve.alias` map (framework runtime modules beside your own aliases), the
+`externals` list, the plugins as `[object ]`, functions as `[function ]`, and
+`output.path` as `` (the artifact root) or `/dist` (the package build). A
+hatch value the build's invariants refuse — aliasing a reserved `agent-bundle/*` specifier, for
+instance — makes the inspection invalid with `AB7001` naming the refusal. Entries the framework
+wraps also carry the generated wrapper module source.
The full contract for everything on this page is in
[Entry conventions](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/entry-conventions.md).
diff --git a/website/docs/en/guide/distribution/index.mdx b/website/docs/en/guide/distribution/index.mdx
index 8cef7d7e1..753db93ca 100644
--- a/website/docs/en/guide/distribution/index.mdx
+++ b/website/docs/en/guide/distribution/index.mdx
@@ -46,7 +46,7 @@ hosts sorted and joined by `+`, such as `claude+codex` — never once per host.
`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; its `distPath.root` is the artifact output itself.
+lowered Rspack configuration of every compiled output; its `output.path` is the artifact output itself.
## What ships inside the root
diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx
index a7b73303f..f60586890 100644
--- a/website/docs/en/reference/cli.mdx
+++ b/website/docs/en/reference/cli.mdx
@@ -295,7 +295,7 @@ manifest fails with `AB7001`.
| Flag | Focus |
| --- | --- |
-| `--bundler` | The synthesized bundler configuration, lowered as the production build lowers it regardless of `NODE_ENV`. Its `distPath.root` is the artifact output, ``, because every selected projection compiles into the one composite root. |
+| `--bundler` | The lowered Rspack configuration of every compiled output — what Rslib (executables) or Rsbuild (MCP App views) hands the compiler after the `tools` hatch and the framework invariants, lowered in production mode regardless of `NODE_ENV`. Resolved aliases, externals, plugins (`[object ]`), and functions (`[function ]`) are rendered in place; `output.path` is the artifact output, ``, because every selected projection compiles into the one composite root. A `tools` value the build would refuse makes the inspection invalid with `AB7001`. |
| `--hooks` | Hooks. |
| `--routes` | The compiled route graph. |
| `--skills` | Skills. |
diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx
index 1940a1593..4106b9a8a 100644
--- a/website/docs/en/reference/targets-artifacts.mdx
+++ b/website/docs/en/reference/targets-artifacts.mdx
@@ -105,7 +105,7 @@ artifact validation (`AB6018`) read those rows.
MCP entries, scripts, routed CLI bins, and MCP Apps are compiled **once** and attributed to the
**composite identity** — the selected host names, sorted and joined by `+`, such as
`claude+codex` — rather than to any one host. `agent-bundle inspect --bundler` shows the same
-thing: its `distPath.root` is the artifact output, ``, with no host segment beneath it.
+thing: its `output.path` is the artifact output, ``, with no host segment beneath it.
### One root, one set of bytes
diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx
index 51e992340..fd7abfaea 100644
--- a/website/docs/zh/guide/authoring/package-entries.mdx
+++ b/website/docs/zh/guide/authoring/package-entries.mdx
@@ -483,8 +483,13 @@ npx agent-bundle inspect --bundler --root .
npx agent-bundle inspect --bundler --root . --target claude --json
```
-它会把构建所组合的每个输出的合成配置,按构建实际降级的样子原样导出;由于用的就是构建所用的同一批
-函数,这份导出不可能与真实编译结果发生漂移。被框架包装的入口还会附带生成的包装模块源码。
+它会导出构建所编译的每个输出降级后的 Rspack 配置——每个编译器一条记录,由构建自己的 Rslib(可执行
+文件)或 Rsbuild(MCP App 视图)降级流程产出,并在构建即将开始编译的位置停下——因此这份导出不可能与
+真实编译结果发生漂移。你能看到解析后的 `resolve.alias` 映射(框架运行时模块与你自己的别名并列)、
+`externals` 列表、以 `[object ]` 渲染的插件、以 `[function ]` 渲染的函数,以及
+`output.path`:产物根目录显示为 ``,包构建显示为 `<项目根>/dist`。被构建不变量拒绝的逃生舱值
+——例如给保留的 `agent-bundle/*` 说明符设置别名——会让检查变为无效,并以 `AB7001` 指出被拒绝的原因。
+被框架包装的入口还会附带生成的包装模块源码。
本页所有内容的完整契约见
[Entry conventions](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/entry-conventions.md)。
diff --git a/website/docs/zh/guide/distribution/index.mdx b/website/docs/zh/guide/distribution/index.mdx
index a1783cc89..1cee726b3 100644
--- a/website/docs/zh/guide/distribution/index.mdx
+++ b/website/docs/zh/guide/distribution/index.mdx
@@ -39,7 +39,7 @@ npx agent-bundle build --root . --output artifact
编译产出面只构建一次,归属于**组合身份**——所选宿主按名称排序并以 `+` 连接,例如 `claude+codex`——
绝不会逐宿主各编译一次。两个阶段与 `dist/` 包构建以同样的方式合成打包器配置——profile、`tools.rsbuild`、
`tools.rspack`,最后是框架不变量——见 [`tools`](../../reference/configuration.mdx#tools)。
-`agent-bundle inspect --bundler` 会打印合成结果;它的 `distPath.root` 就是产物输出本身。
+`agent-bundle inspect --bundler` 会打印每个编译输出降级后的 Rspack 配置;它的 `output.path` 就是产物输出本身。
## 根目录里发布了什么
diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx
index 6b3f5a396..fdaf0b72c 100644
--- a/website/docs/zh/reference/cli.mdx
+++ b/website/docs/zh/reference/cli.mdx
@@ -272,7 +272,7 @@ Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内
| 标志 | 聚焦于 |
| --- | --- |
-| `--bundler` | 合成出来的打包器配置,无论 `NODE_ENV` 为何,都按生产构建的方式降级。它的 `distPath.root` 就是产物输出 ``,因为每个所选投影都编译进同一个组合根目录。 |
+| `--bundler` | 每个编译输出降级后的 Rspack 配置——即 `tools` 逃生舱与框架不变量应用之后,Rslib(可执行文件)或 Rsbuild(MCP App 视图)真正交给编译器的内容,无论 `NODE_ENV` 为何都按生产模式降级。解析后的别名、externals、插件(`[object ]`)和函数(`[function ]`)就地渲染;`output.path` 就是产物输出 ``,因为每个所选投影都编译进同一个组合根目录。构建会拒绝的 `tools` 值会让检查变为无效并报告 `AB7001`。 |
| `--hooks` | 钩子。 |
| `--routes` | 编译后的路由图。 |
| `--skills` | Skill。 |
diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx
index 800daf3c1..d5fadd5d4 100644
--- a/website/docs/zh/reference/targets-artifacts.mdx
+++ b/website/docs/zh/reference/targets-artifacts.mdx
@@ -93,7 +93,7 @@ wrapper 各记一行——没有单独的钩子索引文件——`hooks list`、
MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属于**组合身份**——所选宿主名按名称排序、以
`+` 连接,例如 `claude+codex`——而不是归属于任何单个宿主。`agent-bundle inspect --bundler` 显示的是同一
-件事:它的 `distPath.root` 就是产物输出 ``,其下没有宿主段。
+件事:它的 `output.path` 就是产物输出 ``,其下没有宿主段。
### 一个根目录,一套字节
diff --git a/website/rspress.config.ts b/website/rspress.config.ts
index 8192420c9..f3bfa954d 100644
--- a/website/rspress.config.ts
+++ b/website/rspress.config.ts
@@ -15,16 +15,14 @@ const docsDir = path.join(websiteDir, 'docs');
const repoRoot = path.join(websiteDir, '..');
const packageSource = path.join(repoRoot, 'packages', 'agent-bundle', 'src');
-const typedocTsconfigPath = path.join(websiteDir, 'tsconfig.typedoc.json');
+const packageDeclarations = path.join(repoRoot, 'packages', 'agent-bundle', 'dist');
+const twoslashTsconfigPath = path.join(websiteDir, 'tsconfig.twoslash.json');
/**
- * The `paths` that resolve the workspace packages `packages/agent-bundle/src`
+ * The `paths` that resolve the workspace packages which documentation samples
* imports (`@agent-bundle/runtime`, `rsc-markdown-stream`) to their sources.
- * Their published declarations only exist after `pnpm build`, which the docs
- * build never runs, so a compiler that resolves them through `package.json`
- * types every one of those imports as `any`. TypeDoc reads the tsconfig
- * itself; twoslash takes its compiler options programmatically, so the same
- * map is read here rather than copied.
+ * Twoslash takes its compiler options programmatically, so the map is read
+ * from its dedicated tsconfig rather than copied.
*
* The file is JSONC and the values are relative to the tsconfig, which is how
* TypeScript resolves them; twoslash gets no `baseUrl` or config directory,
@@ -61,26 +59,26 @@ const twoslashPaths: Record = {
'agent-bundle/config': [path.join(packageSource, 'config/index.ts')],
'agent-bundle/test': [path.join(packageSource, 'test/index.ts')],
'agent-bundle/eval': [path.join(packageSource, 'eval/index.ts')],
- ...readSourceMappedPaths(typedocTsconfigPath),
+ ...readSourceMappedPaths(twoslashTsconfigPath),
};
const publicApiEntryPoints = [
- 'index.ts',
- 'api.ts',
- 'app/index.ts',
- 'cli-entry.ts',
- 'config/index.ts',
- 'eval/index.ts',
- 'launch-env.ts',
- 'mcp-apps.ts',
- 'meta.ts',
- 'mcp-entry.ts',
- 'routes/public.ts',
- 'rstest/index.ts',
- 'test/index.ts',
- 'test/browser.ts',
- 'web-host.ts',
-].map(entry => path.join(packageSource, entry));
+ 'index.d.ts',
+ 'api.d.ts',
+ 'app.d.ts',
+ 'cli-entry.d.ts',
+ 'config/index.d.ts',
+ 'eval/index.d.ts',
+ 'launch-env.d.ts',
+ 'mcp-apps.d.ts',
+ 'meta.d.ts',
+ 'mcp-entry.d.ts',
+ 'routes/public.d.ts',
+ 'rstest/index.d.ts',
+ 'test/index.d.ts',
+ 'test/browser.d.ts',
+ 'web-host.d.ts',
+].map(entry => path.join(packageDeclarations, entry));
const generatedApiDir = 'en/api';
const mirroredApiTargets = [
@@ -192,11 +190,10 @@ export default defineConfig({
},
},
plugins: [
- // TypeDoc and twoslash compile packages/agent-bundle/src with the
- // `typescript` pinned in website/package.json. That pin is TypeScript 6
- // because typedoc 0.28 peers on `<= 6.0.x` while the repo root is on
- // TypeScript 7, so TS7-only syntax in the package fails here first, with
- // an error that names the docsite rather than the cause.
+ // TypeDoc reads packages/agent-bundle/dist declarations produced by the
+ // package build. Twoslash still compiles documentation samples against
+ // package sources. Both use the TypeScript 6 pin in website/package.json
+ // because typedoc 0.28 peers on `<= 6.0.x`.
pluginTypeDoc({
entryPoints: publicApiEntryPoints,
outDir: generatedApiDir,
diff --git a/website/tsconfig.twoslash.json b/website/tsconfig.twoslash.json
new file mode 100644
index 000000000..6a5e4ecf9
--- /dev/null
+++ b/website/tsconfig.twoslash.json
@@ -0,0 +1,24 @@
+{
+ // Twoslash compiles documentation samples against package source so editor
+ // examples do not depend on the declarations TypeDoc consumes.
+ "extends": "../packages/agent-bundle/tsconfig.build.json",
+ "compilerOptions": {
+ "rootDir": "../packages",
+ "paths": {
+ "@agent-bundle/runtime": ["../packages/rsc-runtime/src/index.ts"],
+ "@agent-bundle/runtime/plugin": ["../packages/rsc-runtime/src/plugin.ts"],
+ "@agent-bundle/runtime/flight/server": ["../packages/rsc-runtime/src/flight/server.ts"],
+ "@agent-bundle/runtime/lineage": ["../packages/rsc-runtime/src/lineage/index.ts"],
+ "@agent-bundle/runtime/mount": ["../packages/rsc-runtime/src/mount/index.ts"],
+ "@agent-bundle/runtime/notices": ["../packages/rsc-runtime/src/notices/index.ts"],
+ "@agent-bundle/runtime/notices/inbox-route": ["../packages/rsc-runtime/src/notices/inbox-route.ts"],
+ "@agent-bundle/runtime/state": ["../packages/rsc-runtime/src/state/index.ts"],
+ "@agent-bundle/runtime/state/sqlite": ["../packages/rsc-runtime/src/state/sqlite.ts"],
+ "rsc-markdown-stream": ["../packages/rsc-markdown-stream/src/index.d.ts"]
+ }
+ },
+ "include": [
+ "../packages/agent-bundle/src/**/*.ts",
+ "../packages/rsc-runtime/src/react-server-dom-rspack.d.ts"
+ ]
+}
diff --git a/website/tsconfig.typedoc.json b/website/tsconfig.typedoc.json
index c7ddc4897..f19aff6fb 100644
--- a/website/tsconfig.typedoc.json
+++ b/website/tsconfig.typedoc.json
@@ -1,38 +1,7 @@
{
- // TypeDoc consumes the package's own build settings, but the documentation
- // build must not depend on `pnpm build`: `packages/agent-bundle/src` imports
- // `@agent-bundle/runtime`, whose declarations only exist after that package
- // is compiled. Resolving those specifiers to `packages/rsc-runtime/src`
- // keeps `pnpm docs:site:build` self-sufficient. The runtime's own workspace
- // dependency, `rsc-markdown-stream`, publishes its declarations from `dist`
- // too, so it resolves to the hand-written source declaration file.
- //
- // `paths` is also the source of truth for the Twoslash plugin:
- // `rspress.config.ts` reads this file and passes the same map (made
- // absolute) as twoslash `compilerOptions.paths`, so a new source-mapped
- // specifier is added here once and both compilers see it.
- "extends": "../packages/agent-bundle/tsconfig.build.json",
- "compilerOptions": {
- // Widened from the package's `src` so the resolved runtime sources are
- // allowed in the program. TypeDoc never emits, so this only affects checks.
- "rootDir": "../packages",
- "paths": {
- "@agent-bundle/runtime": ["../packages/rsc-runtime/src/index.ts"],
- "@agent-bundle/runtime/plugin": ["../packages/rsc-runtime/src/plugin.ts"],
- "@agent-bundle/runtime/flight/server": ["../packages/rsc-runtime/src/flight/server.ts"],
- "@agent-bundle/runtime/lineage": ["../packages/rsc-runtime/src/lineage/index.ts"],
- "@agent-bundle/runtime/mount": ["../packages/rsc-runtime/src/mount/index.ts"],
- "@agent-bundle/runtime/notices": ["../packages/rsc-runtime/src/notices/index.ts"],
- "@agent-bundle/runtime/notices/inbox-route": ["../packages/rsc-runtime/src/notices/inbox-route.ts"],
- "@agent-bundle/runtime/state": ["../packages/rsc-runtime/src/state/index.ts"],
- "@agent-bundle/runtime/state/sqlite": ["../packages/rsc-runtime/src/state/sqlite.ts"],
- "rsc-markdown-stream": ["../packages/rsc-markdown-stream/src/index.d.ts"]
- }
- },
- "include": [
- "../packages/agent-bundle/src/**/*.ts",
- // Ambient declarations for `react-server-dom-rspack`, which the runtime
- // sources rely on and which an import graph alone will not pull in.
- "../packages/rsc-runtime/src/react-server-dom-rspack.d.ts"
- ]
+ // TypeDoc documents the declarations users consume, not package sources.
+ // Run the package build before the documentation build so this tree is
+ // present and current.
+ "extends": "../tsconfig.base.json",
+ "include": ["../packages/agent-bundle/dist/**/*.d.ts"]
}