diff --git a/.changeset/572-mcp-apps-compiler.md b/.changeset/572-mcp-apps-compiler.md new file mode 100644 index 000000000..b3b8c7ae3 --- /dev/null +++ b/.changeset/572-mcp-apps-compiler.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Fix the MCP App view compiler path. `@rsbuild/plugin-react` is registered on every App view, so a `.ts` entry importing `.tsx` components compiles JSX with the automatic runtime instead of leaving a free `React.createElement` in the view, and the reserved `agent-bundle/meta` specifier is rewritten to the generated identity module before resolution, so a `tsconfig.json` `paths` entry can no longer shadow it (other `paths` entries keep resolving inside views). Compile failures now report one `AB4770` per Rspack error carrying the project-relative file, `line:column`, and the bundler's message (warnings not on the documented ignore list are `AB4771`) in place of the `AB5000` catch-all and the Workbench's `AB7100 "Unable to compile the build: Rspack build failed."`; the Overview Diagnostics table shows the same rows with the failing file as their source, and the last good epoch stays active. `agent-bundle build` prints one `MCP App (): mcp-apps/.html ( gzip)` line per view after `Built …` and carries the measured sizes in `--json` as `build.compiledMcpApps[].size`; `AB4772` warns when a production view reaches 1 MiB or any view exceeds the 2 MiB bound the Workbench and `serve-app` hosts accept, naming the largest modules — the author's own concatenated ESM modules included. `agent-bundle/api` exports the stats formatters `rspackStatsErrors`, `describeRspackStatsError`, and `formatRspackStatsError` so tools driving their own Rsbuild compile render errors the same way. Template-less Apps ship ``, a `` equal to the App name, and the `#root` mount point (a template that sets its own is left alone). `agent-bundle dev` compiles views unminified — still one self-contained HTML per App, falling back to the production profile when the readable document would not render in the hosts — and `AB7100`–`AB7102` are documented. Resolves the MCP Apps compiler-path and dev-loop findings of #572. (#585) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 4503ce2dd..9ba6cfeb2 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -28,6 +28,7 @@ even when no error diagnostic was reported. | `AB474x`/`AB4750` | Prebuilt payloads and prebuilt entries (see below). | | `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). | | `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/<name>.mjs`; a host-emitted file collides with it (see below). | +| `AB477x` | MCP App view compilation (`AB4770`: compile error with file, line, column and the bundler message; `AB4771`: compile warning; `AB4772`: emitted-size advisory; see below). | | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. | | `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures. | @@ -35,7 +36,7 @@ even when no error diagnostic was reported. | `AB700x` | Host installation and uninstallation: bundle identity, host availability, scope, command failure, and collision checks (`AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). | | `AB7010`–`AB7015` | npm prepack inventory, artifact freshness, package bin targets, release-version agreement, and installed-dependency hygiene (`AB7014`: a dependency no packed file references; `AB7015`: a git, remote-tarball, path, or unrewritten workspace-protocol dependency specifier). | | `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | -| `AB7xxx` | Project preparation and development rebuilds. | +| `AB7xxx` | Project preparation and development rebuilds (`AB7100`–`AB7102`: a development rebuild's compilation, publication, and cleanup; `AB7103`: the development package build; see below). | | `AB7300`–`AB7331` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), lifecycle receipts and activation states (`AB7328`–`AB7330`; see below), and the operator `.env` layer of an installed pack (`AB7331`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | | `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation or MCP session revision (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. | | `AB8210`–`AB8214` | Workbench semantic lifecycle replay routes (`/api/lifecycles`, `/api/lifecycles/replays`): `AB8210` invalid path, `AB8211` malformed replay request or native envelope (400, carries the shared validator message), `AB8212` replay unavailable or could not be completed, `AB8213` stale manifest binding (409; the page repairs it with refresh → explicit re-run), `AB8214` replay over the 16 MiB budget (413). | @@ -261,6 +262,84 @@ its module does not export) are invisible to `tsc --noEmit`, so a green `tsc --declaration --emitDeclarationOnly` over the lib entry source directory. +## MCP App view compilation (`AB4770`–`AB4772`) + +MCP App views compile through Rsbuild with its logging silenced +(`logLevel: 'silent'`), so the bundler never prints on its own. The framework +reads the Rspack stats of every App environment instead and reports **one +`AB4770` error per Rspack error**, each carrying the failing module as a +project-relative path (forward slashes; absolute when the module lives outside +the project root), the `line:column` the bundler reported, and the bundler's +message — ANSI colours, the miette frame glyphs, and code-frame lines +stripped, the remaining lines joined into one — plus a `sourcePath` naming the +failing module: + +```text +[AB4770] MCP App "status" failed to compile: views/status.ts:1:10: Module build failed + (from builtin:swc-loader): Syntax Error: Expression expected +[AB4770] MCP App "status" failed to compile: views/status.ts:1:1: Module not found: + Can't resolve './missing-module' in '…/views' +[AB4770] MCP App "status" failed to compile: Tsconfig not found …/does-not-exist.json +``` + +The third line is the shape without a location: Rspack attributes a +`tsconfig.json` whose `extends` target is missing to no module, so the message +carries only the bundler text and `sourcePath` falls back to the App's entry +source. A compile that fails without a single stats error still reports one +`AB4770` with the bundler's own message. More than 20 errors on one App are +cut at 20, and the last diagnostic ends with `… and N more errors (run the +compile with logLevel error via tools.rsbuild for the full list)`. App compile +failures never fall through to the `AB5000` catch-all, and `agent-bundle dev` +shows the same `AB4770` rows in the Workbench Overview's Diagnostics table — +the Source column is the failing file — instead of +`AB7100 "Unable to compile the build: Rspack build failed."`. + +Rspack warnings that are not on the framework's ignore list report as +`AB4771` **warnings** of the same shape, with `produced a warning while +compiling` in place of `failed to compile`. They never fail the build and are +returned beside the compiled Apps (`build.diagnostics` in +`agent-bundle build --json`). The ignore list is the documented constant in +`packages/agent-bundle/src/build/mcp-app-diagnostics.ts` — one comment per +entry citing the warning text it drops and why it is noise; it may be empty. + +Every App is measured after it is emitted: the UTF-8 bytes of the +self-contained HTML and their gzip size, what a compressing transport would +carry. `AB4772` is the size advisory, one **warning** per App. Any view that +imports `@modelcontextprotocol/ext-apps` starts at about 437 kB (104 kB gzip) +— `zod` v3 and v4, `@modelcontextprotocol/sdk`, `zod-to-json-schema`, and +`ext-apps` itself — so the advisory bound of 1 MiB (1,048,576 bytes) sits at +roughly 2.4× that floor and at half the 2 MiB (2,097,152 bytes) bound above +which the Workbench and `serve-app` hosts refuse the resource and the Rstest +browser harness refuses to mount it. The advisory fires when a production +build emits 1 MiB or more, and in either compile mode when the document +exceeds 2 MiB (the view will not render in those hosts). The message names +the raw and gzip sizes, the bound that was crossed, and the five largest +modules from the stats (project-relative, or `node_modules/<package>/…`), with +sizes 1024-based to one decimal, a trailing `.0` dropped (`427.1 KiB`, +`1.3 MiB`, `2 MiB`). Both thresholds are fixed; no configuration key moves +them. + +`agent-bundle dev` compiles views unminified so the Workbench preview is +readable — about 2.7× the production bytes. A view whose readable document +would exceed the 2 MiB host bound is recompiled with the production profile +so the preview still renders it, and one `AB4772` reports the substitution +instead: `MCP App "<name>" readable development output compiled to <size>, +above the 2 MiB bound the Workbench and serve-app hosts accept; the preview +renders the production build (<size>, <gzip> gzip) instead; largest modules: +…`. The production sizes in that notice stand in for the 1 MiB advisory, so +a substituted view never carries two size advisories. When the production +build itself exceeds 2 MiB the substitution buys nothing: the notice is not +emitted, the preview receives that production document, and the ordinary +over-bound `AB4772` names its sizes so the author knows the view does not +render. The 1 MiB advisory is a production concern and never fires on +readable output. + +| Code | Severity | Trigger | Recovery | +| --- | --- | --- | --- | +| `AB4770` | error (build) | One Rspack error while compiling an App view — a syntax error, an unresolved import, a `tsconfig.json` whose `extends` target is missing, or any other module failure. `MCP App "<name>" failed to compile: <file>:<line>:<column>: <message>`, without the location prefix when Rspack attributes the error to no module; `sourcePath` is the failing module, else the App's entry. | Fix the reported error in the named file and rebuild; run `agent-bundle build` for the full message. | +| `AB4771` | warning | One Rspack warning while compiling an App view that the framework's ignore list does not cover; `MCP App "<name>" produced a warning while compiling: <file>:<line>:<column>: <message>`. | Address the warning in the named file; a warning that is bundler noise inside the framework's own dependency graph belongs on the documented ignore list. | +| `AB4772` | warning | The emitted App HTML is 1 MiB or larger in a production build, or larger than 2 MiB in any build; `MCP App "<name>" compiled to <size> (<gzip> gzip), above the … bound; largest modules: …`. In `agent-bundle dev`, a view whose readable output would exceed 2 MiB was recompiled with the production profile for the preview and that production build fits: `MCP App "<name>" readable development output compiled to <size>, above the 2 MiB bound …; the preview renders the production build (…) instead; largest modules: …` — the only size advisory that view receives; a production build that is itself over 2 MiB gets the ordinary over-bound message instead. | Trim the largest modules the message names — usually a dependency imported whole; a view over 2 MiB does not render in the Workbench or `serve-app` and must shrink before it ships. The development substitution costs only the readable source in the preview. | + ## Release identity (`AB4001`, `AB4008`–`AB4011`, `AB4013`) `package.json` is authoritative for release identity (issue #94): its `name` @@ -586,8 +665,8 @@ object, a mutator function, or an array of both). `AB4724` checks `tools.rsbuild.plugins` against the Rsbuild plugins the framework registers itself — currently `@rsbuild/plugin-react` -(`rsbuild:react`), which every synthesized Rslib entry and every React-syntax -MCP App view carries. The hatch merges *beside* the framework profile +(`rsbuild:react`), which every synthesized Rslib entry and every MCP App +view carries, whatever the view's entry extension. The hatch merges *beside* the framework profile (`mergeRslibConfig` / `mergeRsbuildConfig` concatenate `plugins` arrays), and Rsbuild's plugin manager appends every plugin it is handed without deduping by name, so re-adding `pluginReact()` would register it twice. The check is @@ -1171,6 +1250,25 @@ host-facing build together with the failed checks. | `AB8024` | error (MCP) | The epoch a live host connection was serving vanished from the epoch store mid-session. The connection is invalidated and the typed MCP error carries `{ code, epochId }`. | Reconnect from the host; the proxy binds to the currently adopted epoch. | | `AB8025` | error (MCP) | `agent-bundle dev proxy` found no running development server for the project (cold start or shutdown), so the host-facing connection fails closed rather than serving stale bytes. | Start `agent-bundle dev` for that project root; installed hooks and Skills remain in place. | +## Development rebuild compilation and publication (`AB7100`–`AB7102`) + +Every `agent-bundle dev` rebuild compiles the project into a build attempt, +validates the artifact, proves the project source did not change underneath +it, and publishes the result as an immutable epoch. Structured diagnostics +thrown along that path — the `AB4770` compile errors of an MCP App view, the +artifact validation codes — pass through to the failed attempt unchanged, so +the Workbench Overview and the `build.failed` Logs entry show the real +finding. `AB7100` is only what remains: the fallback for a throw in that pass +that carried no structured diagnostics, and the code of a cleanup failure +after the attempt settled. `sourcePath` on all three codes is the project's +config file. + +| Code | Severity | Trigger | Recovery | +| --- | --- | --- | --- | +| `AB7100` | error / warning | `Unable to compile the build: <error>` — the compile, validate, or publish pass of a rebuild threw something that was not a `DiagnosticError` carrying diagnostics. Also `Unable to clean up build attempt after the build: <error>` or `Unable to clean up staging epoch after the build: <error>` when removing the attempt directory or closing an unpublished staging epoch fails: a **warning** on a succeeded attempt (the epoch is live), an error on a failed one. | Read the wrapped error; a structured cause reports under its own code instead. A cleanup failure names a path under `.agent-bundle/attempts` or `.agent-bundle/epochs` to repair or remove. | +| `AB7101` | error | `Project source changed while the artifact was compiling; publication was rejected.` — the source snapshot taken after compilation differs from the inputs the build read, so the attempt is discarded rather than published as an epoch built from mixed inputs. | Nothing to fix: the change that raced the build is already queued as the follow-up rebuild, and the last-good epoch stays active until it succeeds. | +| `AB7102` | warning | `Artifact epoch was committed, but follow-up work was incomplete: <error>` — the epoch is published and active, but the work after the commit failed: retention cleanup of older epochs (`Epoch publication committed, but retention cleanup failed.`) or confirming the active-epoch metadata reached disk (`… active metadata durability could not be confirmed.`). | The epoch itself is valid and serving. Check the epoch store under `.agent-bundle/epochs` for the retained or unsynced files the wrapped error names; the next publication runs the same follow-up work again. | + ## Development package build (`AB7103`) `agent-bundle dev` rebuilds the framework-owned package build (`dist/` bin diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 14329ee16..c0130d064 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1222,8 +1222,8 @@ The hatch merges *beside* the framework profile, not over it: `plugins` arrays concatenate, and Rsbuild's plugin manager appends every plugin it is handed without deduping by name. So a `tools.rsbuild.plugins` entry that re-adds a plugin the framework already registers — `@rsbuild/plugin-react` -(`rsbuild:react`), carried by every synthesized Rslib entry and every -React-syntax MCP App view — would run it twice. `agent-bundle validate` +(`rsbuild:react`), carried by every synthesized Rslib entry and every MCP +App view — would run it twice. `agent-bundle validate` reports that as `AB4724` (an error, like the other `tools` shape checks) with the plugin and package name; remove the entry, the framework already registers it. diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index 1fdfa79a7..2a30fa844 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -69,6 +69,27 @@ node packages/workbench/scripts/capture-runtime-playground.mjs \ --evidence /tmp/rsc-runtime-delivery/evidence.json ``` +The `--compile-error` capture shows the Workbench diagnostic the provider +publishes when a source change fails to compile: code `AB8206`, phase +`source/build`, and a message that carries the Rspack errors themselves — one +`file:line:col: message` line per error, with the path relative to the example +root, ANSI colour and the SWC code frame stripped. Breaking `src/rsc/worker.tsx` +produces, for example: + +```text +RSC runtime source build failed: RSC runtime compile reported 1 error(s): +src/rsc/worker.tsx:177:6: Module build failed (from builtin:swc-loader): Syntax Error: Unexpected token `=`. Expected yield, an identifier, [ or { +``` + +The active generation stays served while the diagnostic is shown, and the next +successful compile clears it. The development session runs Rsbuild at +`logLevel: 'silent'`, so the diagnostic — not the provider's console — is the +one place the message lands; the errors are read with the `agent-bundle/api` +helpers (`rspackStatsErrors`, `formatRspackStatsError`) the framework's own +`AB4770` App diagnostics use. The production `rsbuild build` has no diagnostic +channel, so it keeps `logLevel: 'error'` and prints the same errors to the +console before rejecting. + The published Agent Bundle library is built with Rslib. This example's separate production RSC/runtime artifacts are built by its explicit Rsbuild production command (`pnpm --filter @agent-bundle/rsc-agent-runtime-demo build`); its provider @@ -86,7 +107,7 @@ Installing [the optional RSC Runtime topology](../../docs/architecture/rsc-runtime-workbench.md) for the full ownership boundary. -The build emits `dist/runtime` (including `dist/runtime/agent-runtime.manifest.json`), self-contained `dist/app` MCP App documents, and self-contained native plugin artifacts under `dist/plugins`. The packaging step can also be rerun directly against the current Rsbuild output: +The build emits `dist/runtime` (including `dist/runtime/agent-runtime.manifest.json`), self-contained `dist/app` MCP App documents, and self-contained native plugin artifacts under `dist/plugins`. `dist/app` holds exactly one HTML file per App entry (`edit-timeline-v1.html`, `standalone.html`) with every script, style, asset, and licence comment inlined — the same invariants the framework's MCP App compiler enforces (`splitChunks: false`, unbounded `dataUriLimit`, no async chunks, `legalComments: 'inline'`); the build fails if the resolved configuration drifts from them or any sibling file would be emitted. The packaging step can also be rerun directly against the current Rsbuild output: ```bash pnpm --filter @agent-bundle/rsc-agent-runtime-demo exec agent-bundle build --json --output dist/plugins diff --git a/examples/rsc-agent-runtime/rsbuild.config.ts b/examples/rsc-agent-runtime/rsbuild.config.ts index 7c8201e72..b537b7dd2 100644 --- a/examples/rsc-agent-runtime/rsbuild.config.ts +++ b/examples/rsc-agent-runtime/rsbuild.config.ts @@ -14,6 +14,23 @@ import { Layers, pluginRSC } from 'rsbuild-plugin-rsc'; import { emitRuntimeArtifacts } from './src/build/emit-artifacts.js'; +/** + * A completed development cohort that Rspack rejected. The observer receives + * it through `failAttempt(…, 'source-build')` carrying the cohort's stats + * JSON — errors, children, module traces — and renders them itself (see + * `src/dev/compile-diagnostics.ts`); this config stays free of the framework + * API so a plain `rsbuild build` never loads it. + */ +export class RscRuntimeCompileError extends Error { + readonly stats: Rspack.StatsCompilation; + + constructor(stats: Rspack.StatsCompilation) { + super('RSC runtime compile reported errors.'); + this.name = 'RscRuntimeCompileError'; + this.stats = stats; + } +} + export interface RscRuntimeCompileSnapshot { readonly attemptId: string; readonly candidateId: string; @@ -154,6 +171,47 @@ const emitRuntimeManifest = (): RsbuildPlugin => ({ }, }); +/** + * The App environment ships each entry as exactly one self-contained HTML + * document (the framework's MCP App compiler invariant): scripts, styles, + * licence comments, and every asset inline, no sibling files. The resolved + * configuration is checked before the compiler exists, and the emitted asset + * set after inlining, so a config drift fails the compile instead of quietly + * emitting a sibling file the host never serves. + */ +const selfContainedAppPlugin = (): RsbuildPlugin => ({ + name: 'agent-bundle:rsc-runtime-self-contained-app', + setup(api) { + api.onBeforeCreateCompiler(({ bundlerConfigs }) => { + const config = api.getNormalizedConfig({ environment: 'app' }); + const bundler = bundlerConfigs.find((candidate) => candidate.name === 'app'); + if ( + config.output.inlineScripts !== true || + config.output.inlineStyles !== true || + config.output.dataUriLimit !== Number.MAX_SAFE_INTEGER || + config.output.legalComments !== 'inline' || + config.output.filenameHash !== false || + config.splitChunks !== false || + bundler?.output?.asyncChunks !== false + ) { + throw new Error('RSC runtime App environment resolved an invalid self-contained configuration.'); + } + }); + // `report` runs after Rsbuild's `rsbuild:inline-chunk` deletes the inlined + // script and style assets at `summarize`, so what remains is what lands on + // disk. A compilation error (rather than a thrown hook) is what + // `stats.hasErrors()` sees: the production build rejects and the dev + // session reports the stray files through its `AB8206` diagnostic. + api.processAssets({ environments: ['app'], stage: 'report' }, ({ assets, compilation, compiler }) => { + const stray = Object.keys(assets).filter((name) => !name.endsWith('.html')).sort(); + if (stray.length === 0) return; + compilation.errors.push(new compiler.webpack.WebpackError( + `RSC runtime App environment emitted files beyond its self-contained HTML documents: ${stray.join(', ')}`, + )); + }); + }, +}); + const runtimeCompileObserverPlugin = ( observer: NonNullable<RscRuntimeRsbuildConfigOptions['onCompile']>, ): RsbuildPlugin => { @@ -202,7 +260,15 @@ const runtimeCompileObserverPlugin = ( try { if (stats.hasErrors()) { capturedCohort = undefined; - observer.failAttempt(attemptId, new Error('RSC runtime compile reported errors.'), 'source-build'); + // The console is not the diagnostic channel (development runs + // Rsbuild silent, see `logLevel` below): the Rspack errors ride + // the failure into the session's `AB8206` diagnostic, which + // renders them as `file:line:col: message` lines. + observer.failAttempt( + attemptId, + new RscRuntimeCompileError(stats.toJson({ all: false, children: true, errors: true, moduleTrace: true })), + 'source-build', + ); return; } const json = stats.toJson({ all: false, children: true, hash: true }); @@ -286,6 +352,12 @@ export const createRscRuntimeRsbuildConfig = ( // session URLs are built for. `options.mode` still selects the compile // topology (dev entries, compiler roots) independently of this flavor. mode: 'production', + // Compile errors reach consumers as diagnostics: the dev session's + // `AB8206` carries every Rspack error, so Rsbuild's own console output — + // which would print the same errors to the provider's stderr — is + // silenced there; a production `rsbuild build` rejects, and its console + // shows the errors (only those) since no diagnostic channel exists. + logLevel: development ? 'silent' : 'error', ...(development ? { dev: { writeToDisk: true }, // Port 0 lets the OS assign the listener. Rsbuild's default (3000 with an @@ -299,6 +371,7 @@ export const createRscRuntimeRsbuildConfig = ( pluginReact(), pluginRSC({ environments: { server: 'rsc', client: 'widget' } }), emitRuntimeManifest(), + selfContainedAppPlugin(), ...(options.onAppReload === undefined ? [] : [runtimeAppReloadPlugin(options.onAppReload)]), ...(options.onCompile === undefined ? [] : [runtimeCompileObserverPlugin(options.onCompile)]), ], @@ -370,8 +443,12 @@ export const createRscRuntimeRsbuildConfig = ( }, } : {}), html: { inject: 'body' }, + // Self-contained documents, asserted by `selfContainedAppPlugin`: every + // script, style, licence comment, and asset of any size is inlined, + // and nothing may split into a sibling chunk or file. output: { cleanDistPath: false, + dataUriLimit: Number.MAX_SAFE_INTEGER, distPath: { ...(development ? {} : { js: './' }), root: root('app', 'dist/app'), @@ -382,11 +459,11 @@ export const createRscRuntimeRsbuildConfig = ( css: '[name].css', js: '[name].js', }, - filenameHash: false, - legalComments: 'linked', }), + filenameHash: false, inlineScripts: true, inlineStyles: true, + legalComments: 'inline', target: 'web', }, source: { @@ -395,10 +472,12 @@ export const createRscRuntimeRsbuildConfig = ( standalone: './src/widget/index.tsx', }, }, + splitChunks: false, tools: { rspack: { name: 'app', module: { parser: { javascript: { dynamicImportMode: 'eager' } } }, + output: { asyncChunks: false }, }, }, }, diff --git a/examples/rsc-agent-runtime/src/dev/compile-diagnostics.ts b/examples/rsc-agent-runtime/src/dev/compile-diagnostics.ts new file mode 100644 index 000000000..c91447d2e --- /dev/null +++ b/examples/rsc-agent-runtime/src/dev/compile-diagnostics.ts @@ -0,0 +1,15 @@ +import type { Rspack } from '@rsbuild/core'; +import { formatRspackStatsError, rspackStatsErrors } from 'agent-bundle/api'; + +/** + * The `AB8206` detail for a rejected development compile: a headline with the + * error count, then one `file:line:col: message` line per Rspack error — the + * path relative to the project root, the position as far as the stats entry + * knows it, ANSI colour and the SWC code frame stripped — read by the same + * `agent-bundle/api` helpers the framework's own `AB4770` diagnostics use. + */ +export const describeRspackCompileErrors = (stats: Rspack.StatsCompilation, projectRoot: string): string => { + const lines = rspackStatsErrors(stats).map((error) => formatRspackStatsError(error, projectRoot)); + if (lines.length === 0) return 'RSC runtime compile reported errors, but Rspack stats carried no error details.'; + return `RSC runtime compile reported ${String(lines.length)} error(s):\n${lines.join('\n')}`; +}; diff --git a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts index c20a9aa81..06d67ea3d 100644 --- a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts +++ b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts @@ -8,11 +8,13 @@ import { createRsbuild, type StartDevServerResult } from '@rsbuild/core'; import { createRscRuntimeRsbuildConfig, + RscRuntimeCompileError, type RscRuntimeCompileEnvironmentHashes, type RscRuntimeCompileFailureKind, type RscRuntimeCompileSnapshot, } from '../../rsbuild.config.js'; import { projectName, projectVersion } from '../project-identity.js'; +import { describeRspackCompileErrors } from './compile-diagnostics.js'; import { createRscEnvironmentCheckpointStore, type RscEnvironmentCheckpointStore, @@ -560,9 +562,19 @@ const lifecycleDiagnostic = (error: unknown): DevRuntimeDiagnostic => Object.fre severity: 'error', }); -const sourceBuildDiagnostic = (): DevRuntimeDiagnostic => Object.freeze({ +/** + * The compile observer's `RscRuntimeCompileError` carries the rejected + * cohort's stats; they render here as `file:line:col: message` lines (see + * `src/dev/compile-diagnostics.ts`). The protocol diagnostic has no location + * field, so the lines ride in the message. + */ +const sourceBuildDiagnostic = (error: unknown, projectRoot: string): DevRuntimeDiagnostic => Object.freeze({ code: 'AB8206', - message: 'RSC runtime source build failed.', + message: `RSC runtime source build failed: ${ + error instanceof RscRuntimeCompileError + ? describeRspackCompileErrors(error.stats, projectRoot) + : error instanceof Error ? error.message : 'RSC runtime compile reported errors.' + }`, phase: 'source/build', severity: 'error', }); @@ -2294,7 +2306,14 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { } if (input.hasErrors) { this.#settleCompileObservation(); - await this.#failAttempt(input.attemptId, new Error('RSC runtime compilation failed.'), 'source-build'); + // The observer plugin fails erroring cohorts before capture with the + // Rspack error detail; a caller that still reports `hasErrors` here has + // no stats to quote. + await this.#failAttempt( + input.attemptId, + new Error('RSC runtime compile reported errors, but Rspack stats carried no error details.'), + 'source-build', + ); return undefined; } if (input.sourceRevision.length === 0) { @@ -2389,7 +2408,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { } if (!this.#closed) this.#setStatus( this.#active === undefined ? 'degraded' : 'active', - [kind === 'source-build' ? sourceBuildDiagnostic() : lifecycleDiagnostic(error)], + [kind === 'source-build' ? sourceBuildDiagnostic(error, this.#context.projectRoot) : lifecycleDiagnostic(error)], ); this.#emit(Object.freeze({ type: 'runtime.generation.failed' })); } diff --git a/examples/rsc-agent-runtime/tests/compile-diagnostics.test.ts b/examples/rsc-agent-runtime/tests/compile-diagnostics.test.ts new file mode 100644 index 000000000..08cf9252f --- /dev/null +++ b/examples/rsc-agent-runtime/tests/compile-diagnostics.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from '@rstest/core'; + +import { describeRspackCompileErrors } from '../src/dev/compile-diagnostics.js'; + +const projectRoot = '/work/example'; + +// Captured verbatim from Rspack 2.2.1 (`stats.toJson({ all: false, errors: true, children: true, moduleTrace: true })`). +const syntaxError = Object.freeze({ + code: 'ModuleBuildError', + message: ' × Module build failed (from builtin:swc-loader):\n' + + ' ╰─▶ × Syntax Error: Unexpected token `=`. Expected yield, an identifier, [ or {\n' + + ' ╭─[3:6]\n' + + " 1 │ import { nope } from './missing-module';\n" + + ' 2 │ export const x = 1;\n' + + ' 3 │ const = ;\n' + + ' · ─\n' + + ' ╰────\n' + + ' \n', + moduleIdentifier: `builtin:swc-loader??ruleSet[1].rules[2].oneOf[3].use[0]!${projectRoot}/src/bad.ts`, + moduleName: './src/bad.ts', + moduleTrace: [], +}); + +const unresolvedImport = Object.freeze({ + loc: '1:1-41', + message: ` × Module not found: Can't resolve './missing-module' in '${projectRoot}/src'\n` + + ' ╭─[1:0]\n' + + " 1 │ import { nope } from './missing-module';\n" + + ' · ────────────────────────────────────────\n' + + ' 2 │ console.log(nope);\n' + + ' ╰────\n', + moduleIdentifier: `builtin:swc-loader??ruleSet[1].rules[2].oneOf[3].use[0]!${projectRoot}/src/ok.ts`, + moduleName: './src/ok.ts', + moduleTrace: [], +}); + +test('renders one project-relative file:line:col line per error under a counted headline', () => { + // A MultiStats document lists its children's errors once at the top level. + const multi = { + children: [ + { errors: [syntaxError], name: 'a' }, + { errors: [unresolvedImport], name: 'b' }, + ], + errors: [{ ...syntaxError, compilerPath: 'a' }, { ...unresolvedImport, compilerPath: 'b' }], + }; + const expected = [ + 'RSC runtime compile reported 2 error(s):', + 'src/bad.ts:3:6: Module build failed (from builtin:swc-loader): Syntax Error: Unexpected token `=`. Expected yield, an identifier, [ or {', + `src/ok.ts:1:1: Module not found: Can't resolve './missing-module' in '${projectRoot}/src'`, + ].join('\n'); + expect(describeRspackCompileErrors(multi, projectRoot)).toBe(expected); + // An empty top-level list falls back to the children. + expect(describeRspackCompileErrors({ children: multi.children, errors: [] }, projectRoot)).toBe(expected); +}); + +test('names a module without a location, and a compilation-level error without a module', () => { + expect(describeRspackCompileErrors({ + errors: [ + { message: ' × Something failed\n', moduleName: './src/c.ts' }, + { message: '\u001B[31m × Tsconfig not found: ./does-not-exist.json\u001B[0m\n' }, + ], + }, projectRoot)).toBe([ + 'RSC runtime compile reported 2 error(s):', + 'src/c.ts: Something failed', + 'Tsconfig not found: ./does-not-exist.json', + ].join('\n')); +}); + +test('says so when a rejected compile left no stats errors behind', () => { + expect(describeRspackCompileErrors({ errors: [] }, projectRoot)) + .toBe('RSC runtime compile reported errors, but Rspack stats carried no error details.'); + expect(describeRspackCompileErrors({}, projectRoot)) + .toBe('RSC runtime compile reported errors, but Rspack stats carried no error details.'); +}); diff --git a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts index 4055338db..30d94c2cf 100644 --- a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts @@ -1,6 +1,7 @@ import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; +import { stripVTControlCharacters } from 'node:util'; import { expect, test } from '@rstest/core'; import { createRsbuild, type StartDevServerResult } from '@rsbuild/core'; @@ -13,9 +14,11 @@ import { EpochStore } from '../../../packages/agent-bundle/src/dev/epoch-store.t import { resolveDevRuntimeProvider } from '../../../packages/agent-bundle/src/dev/runtime-provider-loader.ts'; import { createRscRuntimeRsbuildConfig, + RscRuntimeCompileError, type RscRuntimeActivationOutcome, type RscRuntimeCompileSnapshot, } from '../rsbuild.config.js'; +import { describeRspackCompileErrors } from '../src/dev/compile-diagnostics.js'; import { createDevRuntimeProvider } from '../src/dev/provider.js'; import { ResourceLedger, RsbuildRuntimeSession } from '../src/dev/rsbuild-runtime-session.js'; import { copyExample, type CopiedExample } from './support/copy-example.ts'; @@ -49,6 +52,9 @@ const deferred = <T>() => { type CompileObserverContract = NonNullable<Parameters<typeof createRscRuntimeRsbuildConfig>[0]['onCompile']>; +/** The project root the session would render a failed cohort's errors relative to. */ +const compileObserverRoot = join(tmpdir(), 'rsc-provider-observer-project'); + const compileObserver = ( onCompile: Omit<CompileObserverContract, 'stageEnvironmentCheckpoint'> & Partial<Pick<CompileObserverContract, 'stageEnvironmentCheckpoint'>>, ) => { @@ -76,6 +82,7 @@ const compileObserver = ( }; const completeAttempt = async (input: Readonly<{ readonly children?: readonly unknown[]; + readonly errors?: readonly unknown[]; readonly hasErrors?: boolean; }> = {}): Promise<void> => { if (after === undefined) throw new Error('RSC compiler observer after hook is unavailable.'); @@ -88,6 +95,7 @@ const compileObserver = ( { hash: 'widget-hash', name: 'widget' }, { hash: 'app-hash', name: 'app' }, ], + ...(input.errors === undefined ? {} : { errors: input.errors }), }), }, }); @@ -95,6 +103,7 @@ const compileObserver = ( return Object.freeze({ async compile(input: Readonly<{ readonly children?: readonly unknown[]; + readonly errors?: readonly unknown[]; readonly hasErrors?: boolean; }> = {}): Promise<void> { observeCompileStart(); @@ -246,12 +255,20 @@ const changeWorkerImplementation = async (projectRoot: string, marker: string): ); }; -const introduceWorkerSyntaxError = async (projectRoot: string): Promise<void> => { +/** Appends an invalid statement to the RSC worker; resolves to its 1-based line. */ +const introduceWorkerSyntaxError = async (projectRoot: string): Promise<number> => { + let line = 0; await replaceSource( projectRoot, join(projectRoot, 'src', 'rsc', 'worker.tsx'), - (source) => `${source}\nconst = ;\n`, + (source) => { + // The source ends with a newline, so the blank separator line precedes + // the statement: original line count + 1 (blank) + 1 (statement). + line = source.split('\n').length + 1; + return `${source}\nconst = ;\n`; + }, ); + return line; }; test('requires the App environment through the public Rsbuild compiler hook', async () => { @@ -712,12 +729,27 @@ test('classifies direct compiler errors as source build failures without capture observeCompileStart: () => undefined, }); - await observer.compile({ hasErrors: true }); + await observer.compile({ + errors: [{ + loc: '4:1-27', + message: " × Module not found: Can't resolve './missing' in '/src'\n ╭─[4:0]\n 4 │ import { x } from './missing';\n ╰────\n", + moduleIdentifier: `builtin:swc-loader??ruleSet[1]!${join(compileObserverRoot, 'src', 'rsc', 'worker.tsx')}`, + }], + hasErrors: true, + }); expect(captured).toEqual([]); expect(enqueued).toEqual([]); expect(failures).toHaveLength(1); expect(failures[0]?.[0]).toBe('attempt-source-build'); + // The failure carries the cohort's stats; the session renders them as + // `file:line:col: message` lines relative to its project root. + const failure = failures[0]?.[1]; + expect(failure).toBeInstanceOf(RscRuntimeCompileError); + expect(describeRspackCompileErrors((failure as RscRuntimeCompileError).stats, compileObserverRoot)).toBe( + 'RSC runtime compile reported 1 error(s):\n' + + "src/rsc/worker.tsx:4:1: Module not found: Can't resolve './missing' in '/src'", + ); expect(failures[0]?.[2]).toBe('source-build'); }); @@ -930,7 +962,7 @@ test('keeps the active generation while publishing a source build diagnostic bef const beforeSurfaces = session.surfaces(); const beforeRuns = session.runs(50); - await introduceWorkerSyntaxError(copied.projectRoot); + const brokenLine = await introduceWorkerSyntaxError(copied.projectRoot); await waitFor(() => events.filter((event) => event.type === 'runtime.generation.failed').length === 1); expect(failedStatuses).toHaveLength(1); @@ -938,13 +970,23 @@ test('keeps the active generation while publishing a source build diagnostic bef activeVector: beforeStatus.activeVector, diagnostics: [{ code: 'AB8206', - message: 'RSC runtime source build failed.', + message: expect.stringMatching(/^RSC runtime source build failed: RSC runtime compile reported \d+ error\(s\):\n/u), phase: 'source/build', severity: 'error', }], lastGoodVector: beforeStatus.lastGoodVector, state: 'active', }); + // The diagnostic carries the Rspack error as `file:line:col: message`: + // the broken module, project-relative, with the SWC frame location and + // the syntax error text, so the Workbench shows where to look. + const [, ...errorLines] = failedStatuses[0]!.diagnostics[0]!.message.split('\n'); + expect(errorLines.length).toBeGreaterThan(0); + for (const line of errorLines) { + expect(line).toMatch(new RegExp(`^src/rsc/worker\\.tsx:${String(brokenLine)}:\\d+: Module build failed \\(from builtin:swc-loader\\): Syntax Error: Unexpected token \`=\``, 'u')); + expect(line).not.toMatch(/[│╭╰×·]/u); + expect(stripVTControlCharacters(line)).toBe(line); + } expect(session.status()).toEqual(failedStatuses[0]); expect(session.surfaces()).toEqual(beforeSurfaces); expect(session.runs(50)).toEqual(beforeRuns); diff --git a/examples/rsc-agent-runtime/tests/host-artifacts.test.ts b/examples/rsc-agent-runtime/tests/host-artifacts.test.ts index 6157df314..d79eba23c 100644 --- a/examples/rsc-agent-runtime/tests/host-artifacts.test.ts +++ b/examples/rsc-agent-runtime/tests/host-artifacts.test.ts @@ -4,7 +4,7 @@ import { createHash } from 'node:crypto'; import { access, chmod, cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { once } from 'node:events'; import { tmpdir } from 'node:os'; -import { dirname, join, normalize } from 'node:path'; +import { dirname, join } from 'node:path'; import type { Readable } from 'node:stream'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; @@ -170,48 +170,37 @@ test('materializes self-contained Claude and Codex native plugin artifacts', asy expect(appHtml).not.toMatch(/<script[^>]+src=|<link[^>]+rel=["']stylesheet["']/iu); } // A skill-less plugin emits no `skills/` directory, while the manifest's - // `./skills/` pointer stays — as in every framework-built Codex artifact. + // `./skills/` pointer stays � as in every framework-built Codex artifact. for (const relative of ['.agents/plugins/marketplace.json', '.codex-plugin/plugin.json', '.mcp.json', 'hooks/hooks.json']) { await access(join(codexRoot, relative)); } }); -test('keeps fresh production App legal payload names stable and package-identical', async () => { +test('emits each production App as one self-contained HTML document carrying its legal comments', async () => { await runProductionBuild(); const appDigest = await artifactDigest(join(exampleRoot, 'dist/app')); + // Exactly the HTML documents: no sibling `*.LICENSE.txt`, chunk, or asset + // file. `selfContainedAppPlugin` in rsbuild.config.ts fails the compile + // otherwise; this pins the packaged shape it protects. expect(appDigest.map((entry) => entry.path)).toEqual([ 'edit-timeline-v1.html', - 'lib-react.js.LICENSE.txt', 'standalone.html', ]); - const legalNotice = appDigest.find((entry) => entry.path === 'lib-react.js.LICENSE.txt'); - expect(legalNotice).toMatchObject({ path: 'lib-react.js.LICENSE.txt' }); - const legalNoticeContent = await readFile(join(exampleRoot, 'dist/app/lib-react.js.LICENSE.txt'), 'utf8'); - expect(legalNoticeContent).toContain('LICENSE file'); - for (const entry of appDigest) { - expect(entry.path).not.toMatch(/(?:^|\/)[^/]*\.[a-f\d]{8,}\.(?:js|css)(?:\.LICENSE\.txt)?$/iu); - } for (const appRoot of [join(exampleRoot, 'dist/app'), ...['claude', 'codex'].map((host) => join(pluginsRoot, host, 'app'))]) { const payload = await artifactDigest(appRoot); expect(payload).toEqual(appDigest); - let legalReferences = 0; - for (const artifact of payload.filter((entry) => /\.(?:css|html|js)$/iu.test(entry.path))) { + for (const artifact of payload) { const source = await readFile(join(appRoot, artifact.path), 'utf8'); - for (const match of source.matchAll(/\/\*!\s*LICENSE:\s*([^*\r\n]+?)\s*\*\//gu)) { - legalReferences += 1; - const target = normalize(join(dirname(artifact.path), match[1]!.trim())); - expect(target).not.toMatch(/^(?:\.\.\/|\/)/u); - expect(payload.some((entry) => entry.path === target)).toBe(true); - expect(await readFile(join(appRoot, target), 'utf8')).toBe(legalNoticeContent); - } - if (artifact.path.endsWith('.html')) { - expect(source).toContain('<script'); - expect(source).toContain('<style'); - expect(source).not.toMatch(/<script[^>]+src=|<link[^>]+rel=["']stylesheet["']/iu); - } + expect(source).toContain('<script'); + expect(source).toContain('<style'); + expect(source).not.toMatch(/<script[^>]+src=|<link[^>]+rel=["']stylesheet["']/iu); + // `legalComments: 'inline'` keeps the React licence text inside the + // document instead of an extracted `/*! LICENSE: � */` link. + expect(source).toContain('@license React'); + expect(source).toContain('LICENSE file'); + expect(source).not.toMatch(/\/\*!\s*LICENSE:/u); } - expect(legalReferences).toBeGreaterThan(0); } }); diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index ec95a201c..1bf116788 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -121,6 +121,8 @@ export { import { composeBundlerInspection, type BundlerInspection } from './build/inspect-bundler.ts'; import { defaultPackageArtifactDistPath } from './config/normalize.ts'; export type { BundlerInspection, BundlerInspectionEntry } from './build/inspect-bundler.ts'; +export { describeRspackStatsError, formatRspackStatsError, rspackStatsErrors } from './build/rspack-stats-errors.ts'; +export type { RspackStatsErrorDetail, RspackStatsErrorLocation } from './build/rspack-stats-errors.ts'; import { validateArtifact, validateArtifactWithSnapshot } from './build/validate-artifact.ts'; import { freezeDiagnostics, hasErrors, DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; export type { Diagnostic, DiagnosticSeverity } from './core/diagnostics.ts'; @@ -572,7 +574,12 @@ export interface BuildOptions extends ProjectOptions { export interface BuildProjectResult { readonly build: BuildResult; - /** Project diagnostics followed by the host-validation findings (`AB6019`–`AB6022`) when `hostValidation` ran. */ + /** + * Project diagnostics, then the artifact compiler's non-fatal findings — + * MCP App view compile warnings (`AB4771`) and size advisories (`AB4772`), + * the same list as `build.diagnostics` — then the host-validation findings + * (`AB6019`–`AB6022`) when `hostValidation` ran. + */ readonly diagnostics: readonly Diagnostic[]; /** One report per built `claude`/`plugin` target; present only when `hostValidation` was requested. */ readonly hostValidation?: readonly ClaudePluginValidationReport[]; @@ -1227,9 +1234,11 @@ export const build = async (options: BuildOptions): Promise<BuildProjectResult> : undefined; return Object.freeze({ build: result, - diagnostics: hostValidation === undefined - ? prepared.diagnostics - : freezeDiagnostics([...prepared.diagnostics, ...hostValidation.diagnostics]), + diagnostics: freezeDiagnostics([ + ...prepared.diagnostics, + ...result.diagnostics, + ...(hostValidation === undefined ? [] : hostValidation.diagnostics), + ]), ...(hostValidation === undefined ? {} : { hostValidation: hostValidation.reports }), model, ...(packageBuild === undefined ? {} : { packageBuild }), diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index be26fff1f..192b11faf 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -29,7 +29,13 @@ import { type CompiledCliBin, } from './cli-bins.ts'; import { projectMeta } from './meta.ts'; -import { compileMcpApps, planCompiledMcpApps, type CompiledMcpApp } from './mcp-apps.ts'; +import { + compileMcpApps, + planCompiledMcpApps, + type CompiledMcpApp, + type McpAppCompileMode, + type PlannedMcpApp, +} from './mcp-apps.ts'; import { compileRslibSurfaces, settledRslibSurface } from './rslib.ts'; import { planTargetStages } from './target-stages.ts'; import { @@ -60,12 +66,24 @@ export interface BuildResult { readonly compiledHooks: readonly CompiledHookEntry[]; readonly compiledMcpApps: readonly CompiledMcpApp[]; readonly compiledMcpEntries: readonly CompiledMcpEntry[]; + /** + * Non-fatal compiler findings the artifact survived — MCP App view compile + * warnings and size advisories. Errors never reach here: a failing compile + * throws a `DiagnosticError` carrying them. + */ + readonly diagnostics: readonly Diagnostic[]; readonly manifest: ArtifactManifest; readonly outputProvenance: readonly ArtifactOutputProvenance[]; readonly outputRoot: string; } export interface BuildOptions { + /** + * The MCP App view compile profile; defaults to `production`. Only the + * Workbench dev loop passes `development` (readable output, inline source + * maps); artifact and Rslib surfaces are unaffected. + */ + readonly mode?: McpAppCompileMode; readonly model: NormalizedPlugin; readonly outputRoot: string; readonly projectContext: ProjectContext; @@ -87,7 +105,7 @@ interface StagedTarget extends PlannedTarget { readonly compiledCliBins: readonly CompiledCliBin[]; readonly compiledEntries: readonly CompiledEntry[]; readonly compiledHooks: readonly CompiledHookEntry[]; - readonly compiledMcpApps: readonly CompiledMcpApp[]; + readonly compiledMcpApps: readonly PlannedMcpApp[]; readonly compiledMcpEntries: readonly CompiledMcpEntry[]; readonly root: string; } @@ -382,6 +400,7 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => { const compiledHooks: CompiledHookEntry[] = []; const compiledMcpApps: CompiledMcpApp[] = []; const compiledMcpEntries: CompiledMcpEntry[] = []; + const compileDiagnostics: Diagnostic[] = []; const tools = options.tools === undefined ? {} : { tools: options.tools }; // The resolved `notices.retention`; generated ledgers fall back to the runtime defaults without it. const noticePolicy = options.model.notices === undefined @@ -399,14 +418,19 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => { // The optional browser stage, always first: the MCP entries // embed its HTML, and its Rsbuild pass asserts the target root // holds nothing but that HTML. - targetMcpApps = await compileMcpApps(options.model.mcpApps ?? [], { - cwd: options.projectRoot, - meta, - outDir: target.root, - target: target.name, - ...tools, - }); - compiledMcpApps.push(...targetMcpApps); + { + const views = await compileMcpApps(options.model.mcpApps ?? [], { + cwd: options.projectRoot, + meta, + ...(options.mode === undefined ? {} : { mode: options.mode }), + outDir: target.root, + target: target.name, + ...tools, + }); + targetMcpApps = views.apps; + compiledMcpApps.push(...views.apps); + compileDiagnostics.push(...views.diagnostics); + } break; case 'node-surfaces': { await emitPlanEntries({ entries: target.entries, root: target.root }); @@ -561,6 +585,7 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => { output: publishedOutput(entry), ...(entry.workerOutput === undefined ? {} : { workerOutput: publishedOutput({ output: entry.workerOutput }) }), }))), + diagnostics: deepFreeze(deduplicateDiagnostics(compileDiagnostics)), manifest, outputProvenance, outputRoot, diff --git a/packages/agent-bundle/src/build/framework-plugins.ts b/packages/agent-bundle/src/build/framework-plugins.ts index bd5e52f6e..88ef2a4a5 100644 --- a/packages/agent-bundle/src/build/framework-plugins.ts +++ b/packages/agent-bundle/src/build/framework-plugins.ts @@ -13,8 +13,9 @@ * each literal to the plugin it names. */ export const frameworkOwnedRsbuildPlugins: ReadonlyMap<string, string> = new Map([ - // `pluginReact()` from rslib.ts (every synthesized entry) and mcp-apps.ts - // (every React-syntax view): automatic JSX runtime, fast refresh off. + // `pluginReact({ fastRefresh: false })` from rslib.ts (every synthesized + // entry) and mcp-apps.ts (every MCP App view, whatever its entry + // extension): automatic JSX runtime, fast refresh off. ['rsbuild:react', '@rsbuild/plugin-react'], ]); diff --git a/packages/agent-bundle/src/build/mcp-app-diagnostics.ts b/packages/agent-bundle/src/build/mcp-app-diagnostics.ts new file mode 100644 index 000000000..4ecab3a28 --- /dev/null +++ b/packages/agent-bundle/src/build/mcp-app-diagnostics.ts @@ -0,0 +1,279 @@ +import type { Rspack } from '@rsbuild/core'; +import { isAbsolute, resolve } from 'node:path'; + +import type { Diagnostic } from '../core/diagnostics.ts'; +import { MAX_APP_HTML_BYTES } from '../core/mcp-app-limits.ts'; +import { formatByteSize } from '../core/strings.ts'; +import { + describeRspackStatsError, + displayPath, + loaderChainTarget, + normalizeStatsMessage, + renderRspackStatsErrorDetail, +} from './rspack-stats-errors.ts'; + +/** + * The `AB477x` family: what the MCP App view compiler reports about one + * Rspack environment (one App) after reading its stats. `AB4770` errors fail + * the compile through a `DiagnosticError`; `AB4771` warnings and the + * `AB4772` size advisory ride `CompiledMcpAppsResult.diagnostics`. The + * mapping is pure so the message shapes are unit-testable without a build; + * `mcp-apps.ts` feeds it the stats its compile-time collector recorded. + */ +export const mcpAppCompileErrorCode = 'AB4770'; +export const mcpAppCompileWarningCode = 'AB4771'; +export const mcpAppSizeAdvisoryCode = 'AB4772'; + +/** + * Which profile the view compiler emits. `production` is the artifact + * profile every `agent-bundle build` ships; `development` is the Workbench + * dev-loop profile (readable, unminified output), still self-contained. + */ +export type McpAppCompileMode = 'development' | 'production'; + +/** The emitted size of one self-contained MCP App HTML document. */ +export interface McpAppOutputSize { + /** UTF-8 bytes of the emitted HTML as written to the artifact. */ + readonly bytes: number; + /** Bytes of the same document after gzip, the size a compressing transport would carry. */ + readonly gzipBytes: number; +} + +/** + * Emitted bytes at which a production view draws the `AB4772` advisory. The + * framework floor for any view using `@modelcontextprotocol/ext-apps` (its + * SDK, both `zod` generations, `zod-to-json-schema`) measured 437 kB; this is + * roughly 2.4× that floor and half the {@link MAX_APP_HTML_BYTES} host bound. + */ +export const MCP_APP_HTML_ADVISORY_BYTES = 1_048_576; + +/** How many `AB4770` diagnostics one App renders before the tail summarises the rest. */ +export const MCP_APP_COMPILE_ERROR_CAP = 20; + +/** How many modules the `AB4772` advisory names. */ +const largestModuleCount = 5; + +/** + * Rspack warnings the App compile does not surface as `AB4771`. Every entry + * cites the warning text it matches and why that text is noise for a + * self-contained view. Empty today: Rsbuild already switches the + * `performance.hints` asset-size warnings off, and no other warning has been + * observed on a view that compiles. + */ +export const ignoredMcpAppCompileWarnings: readonly RegExp[] = Object.freeze([]); + +const compileErrorRecovery = + 'Fix the reported error in the named file and rebuild; run `agent-bundle build` for the full message.'; +const compileWarningRecovery = + 'Address the reported warning in the named file and rebuild; run `agent-bundle build` for the full message.'; +const sizeAdvisoryRecovery = + 'Trim the largest modules listed and rebuild; the Workbench and serve-app hosts refuse a view above ' + + `${String(MAX_APP_HTML_BYTES / 1_048_576)} MiB.`; +const readableFallbackRecovery = + 'The preview shows the minified production build; trim the view to read its source in the Workbench.'; + +/** What the compiler knows about one App independent of any stats entry. */ +export interface McpAppDiagnosticContext { + /** The App name; also the name of its Rsbuild environment in stats. */ + readonly appName: string; + /** The App's absolute browser entry source: the `sourcePath` when no module is known. */ + readonly entrySource: string; + /** The project root, the bundler `context` every relative stats module name is anchored to. */ + readonly projectRoot: string; +} + +/** Ranked entry of the `AB4772` advisory: a leaf module and its stats size. */ +export interface RankedModule { + /** Project-relative, `node_modules/<package>/…`, or absolute when outside both. */ + readonly name: string; + readonly size: number; +} + +/** + * The leaves of a module list — what the emitted document is made of. A + * concatenated module reports its parts under `modules` and its own size as + * their sum, so only the parts are ranked. Those parts are also orphans of + * the chunk graph, and the stats (recorded with `orphanModules`) list each of + * them a second time at the top level; a top-level orphan is skipped, since + * it is either already ranked through the module that absorbed it or emitted + * nowhere at all (an export the bundler inlined at every use). + */ +const leafModules = (modules: readonly Rspack.StatsModule[], topLevel = true): readonly Rspack.StatsModule[] => + modules.flatMap((module) => { + if (topLevel && module.orphan === true) return []; + return module.modules !== undefined && module.modules.length > 0 ? leafModules(module.modules, false) : [module]; + }); + +/** + * The display name of one module: everything from the last `node_modules` + * segment on for a dependency (`node_modules/react-dom/cjs/…`, whatever the + * package manager's layout above it), project-relative for authored source, + * absolute for anything outside both. + */ +const moduleDisplayName = (module: Rspack.StatsModule, projectRoot: string): string | undefined => { + const named = module.nameForCondition ?? module.name ?? module.identifier; + if (named === undefined || named.length === 0) return undefined; + const path = loaderChainTarget(named); + const segments = path.split(/[\\/]/u); + const dependencyRoot = segments.lastIndexOf('node_modules'); + if (dependencyRoot !== -1) return segments.slice(dependencyRoot).join('/'); + return displayPath(projectRoot, isAbsolute(path) ? path : resolve(projectRoot, path)); +}; + +/** The `count` largest leaf modules by stats size, ties broken by name for a stable rendering. */ +export const largestModules = ( + modules: readonly Rspack.StatsModule[], + projectRoot: string, + count = largestModuleCount, +): readonly RankedModule[] => Object.freeze(leafModules(modules) + .flatMap((module): RankedModule[] => { + const name = moduleDisplayName(module, projectRoot); + return name === undefined ? [] : [{ name, size: module.size }]; + }) + .sort((left, right) => right.size - left.size || left.name.localeCompare(right.name)) + .slice(0, count)); + +type StatsSeverity = 'error' | 'warning'; + +const statsSeverityText: Readonly<Record<StatsSeverity, { readonly code: string; readonly recovery: string; readonly verb: string }>> = { + error: { code: mcpAppCompileErrorCode, recovery: compileErrorRecovery, verb: 'failed to compile' }, + warning: { code: mcpAppCompileWarningCode, recovery: compileWarningRecovery, verb: 'produced a warning while compiling' }, +}; + +/** + * `MCP App "<name>" <verb>: <file>:<line>:<column>: <message>` — the file and + * position only as far as the stats entry knows them. `sourcePath` is the + * failing module, else the App's entry. + */ +const statsDiagnostic = (context: McpAppDiagnosticContext, entry: Rspack.StatsError, severity: StatsSeverity): Diagnostic => { + const { code, recovery, verb } = statsSeverityText[severity]; + const detail = describeRspackStatsError(entry, context.projectRoot); + return { + code, + message: `MCP App ${JSON.stringify(context.appName)} ${verb}: ${renderRspackStatsErrorDetail(detail, context.projectRoot)}`, + recovery, + severity, + sourcePath: detail.file ?? context.entrySource, + }; +}; + +/** + * One `AB4770` per Rspack error of the App's environment, capped at + * {@link MCP_APP_COMPILE_ERROR_CAP}: past the cap the last diagnostic counts + * the rest and names the way to see them all. + */ +export const mcpAppCompileErrorDiagnostics = ( + context: McpAppDiagnosticContext, + errors: readonly Rspack.StatsError[], +): readonly Diagnostic[] => { + const capped = errors.length > MCP_APP_COMPILE_ERROR_CAP; + const rendered = capped ? errors.slice(0, MCP_APP_COMPILE_ERROR_CAP - 1) : errors; + const diagnostics = rendered.map((error) => statsDiagnostic(context, error, 'error')); + if (capped) { + const remaining = errors.length - rendered.length; + diagnostics.push({ + code: mcpAppCompileErrorCode, + message: `MCP App ${JSON.stringify(context.appName)} failed to compile: … and ${String(remaining)} more ` + + `${remaining === 1 ? 'error' : 'errors'} (run the compile with logLevel error via tools.rsbuild for the full list)`, + recovery: compileErrorRecovery, + severity: 'error', + sourcePath: context.entrySource, + }); + } + return Object.freeze(diagnostics); +}; + +/** + * The `AB4770` for a bundler rejection that left no stats error behind (a + * compiler-level failure rather than a module's): the bundler's own message, + * attributed to the App's entry. + */ +export const mcpAppBundlerFailureDiagnostic = (context: McpAppDiagnosticContext, failure: string): Diagnostic => ({ + code: mcpAppCompileErrorCode, + message: `MCP App ${JSON.stringify(context.appName)} failed to compile: ${normalizeStatsMessage(failure)}`, + recovery: compileErrorRecovery, + severity: 'error', + sourcePath: context.entrySource, +}); + +/** + * One `AB4771` per Rspack warning of the App's environment that no + * ignore-list pattern matches; the patterns see the normalised one-line text + * the diagnostic would carry, so an entry reads like the message it silences. + */ +export const mcpAppCompileWarningDiagnostics = ( + context: McpAppDiagnosticContext, + warnings: readonly Rspack.StatsError[], + ignored: readonly RegExp[] = ignoredMcpAppCompileWarnings, +): readonly Diagnostic[] => Object.freeze(warnings + .filter((warning) => { + const text = normalizeStatsMessage(warning.message); + return !ignored.some((pattern) => pattern.test(text)); + }) + .map((warning) => statsDiagnostic(context, warning, 'warning'))); + +/** + * The `AB4772` size advisory for one emitted view, or nothing when the view + * is within bounds: a production view at or above + * {@link MCP_APP_HTML_ADVISORY_BYTES}, or a view in either mode above the + * {@link MAX_APP_HTML_BYTES} the Workbench and serve-app hosts accept. The + * largest leaf modules by stats size name where the bytes come from. + */ +export const mcpAppSizeDiagnostic = ( + context: McpAppDiagnosticContext, + options: { + readonly mode: McpAppCompileMode; + readonly modules: readonly Rspack.StatsModule[]; + readonly size: McpAppOutputSize; + }, +): Diagnostic | undefined => { + const aboveHostBound = options.size.bytes > MAX_APP_HTML_BYTES; + const aboveAdvisory = options.mode === 'production' && options.size.bytes >= MCP_APP_HTML_ADVISORY_BYTES; + if (!aboveHostBound && !aboveAdvisory) return undefined; + const bound = aboveHostBound + ? `, above the ${String(MAX_APP_HTML_BYTES / 1_048_576)} MiB bound the Workbench and serve-app hosts accept — the view will not render there` + : `, above the ${String(MCP_APP_HTML_ADVISORY_BYTES / 1_048_576)} MiB advisory bound`; + return { + code: mcpAppSizeAdvisoryCode, + message: `MCP App ${JSON.stringify(context.appName)} compiled to ${formatByteSize(options.size.bytes)} ` + + `(${formatByteSize(options.size.gzipBytes)} gzip)${bound}${largestModulesClause(options.modules, context.projectRoot)}`, + recovery: sizeAdvisoryRecovery, + severity: 'warning', + sourcePath: context.entrySource, + }; +}; + +/** `; largest modules: <name> (<size>), …` — empty when the stats carried no modules. */ +const largestModulesClause = (modules: readonly Rspack.StatsModule[], projectRoot: string): string => { + const ranked = largestModules(modules, projectRoot); + return ranked.length === 0 + ? '' + : `; largest modules: ${ranked.map((module) => `${module.name} (${formatByteSize(module.size)})`).join(', ')}`; +}; + +/** + * The `AB4772` a development compile reports when a view's readable output + * would not render in the hosts and the production profile — which does fit + * — was emitted in its place: the preview still shows the view, just not its + * readable source. A replacement that itself exceeds the bound gets the plain + * {@link mcpAppSizeDiagnostic} instead; claiming the preview renders it would + * be false. + */ +export const mcpAppReadableFallbackDiagnostic = ( + context: McpAppDiagnosticContext, + options: { + readonly modules: readonly Rspack.StatsModule[]; + readonly production: McpAppOutputSize; + readonly readable: McpAppOutputSize; + }, +): Diagnostic => ({ + code: mcpAppSizeAdvisoryCode, + message: `MCP App ${JSON.stringify(context.appName)} readable development output compiled to ` + + `${formatByteSize(options.readable.bytes)}, above the ${String(MAX_APP_HTML_BYTES / 1_048_576)} MiB bound the ` + + 'Workbench and serve-app hosts accept; the preview renders the production build ' + + `(${formatByteSize(options.production.bytes)}, ${formatByteSize(options.production.gzipBytes)} gzip) instead` + + largestModulesClause(options.modules, context.projectRoot), + recovery: readableFallbackRecovery, + severity: 'warning', + sourcePath: context.entrySource, +}); diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index a0b06ffd6..b42140c84 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -1,13 +1,35 @@ -import { createRsbuild, mergeRsbuildConfig, rspack, type RsbuildConfig, type Rspack } from '@rsbuild/core'; +import { + createRsbuild, + mergeRsbuildConfig, + rspack, + type RsbuildConfig, + type RsbuildPlugin, + type Rspack, +} from '@rsbuild/core'; import { pluginReact } from '@rsbuild/plugin-react'; -import { readFile } from 'node:fs/promises'; -import { extname, resolve } from 'node:path'; +import { copyFile, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { gzipSync } from 'node:zlib'; +import { DiagnosticError, freezeDiagnostics, type Diagnostic } from '../core/diagnostics.ts'; import type { AgentBundleToolsConfig, NormalizedMcpApp } from '../core/types.ts'; import { stableJson } from '../core/digest.ts'; +import { MAX_APP_HTML_BYTES } from '../core/mcp-app-limits.ts'; +import { escapeRegExp } from '../core/strings.ts'; import type { AgentBundleMeta } from '../meta.ts'; import { composeToolsLayers, frameworkInvariantLayer } from './compose-layers.ts'; import { listArtifactFiles, resolveArtifactDestination } from './emit.ts'; +import { + mcpAppBundlerFailureDiagnostic, + mcpAppCompileErrorDiagnostics, + mcpAppCompileWarningDiagnostics, + mcpAppReadableFallbackDiagnostic, + mcpAppSizeDiagnostic, + type McpAppCompileMode, + type McpAppDiagnosticContext, + type McpAppOutputSize, +} from './mcp-app-diagnostics.ts'; import { assertGeneratedModulesRootAbsent, generatedMetaModulePath, @@ -18,6 +40,8 @@ import { } from './meta.ts'; import { collectBundledOutputEvidence } from './provenance.ts'; +export type { McpAppCompileMode, McpAppOutputSize } from './mcp-app-diagnostics.ts'; + export const mcpAppMimeType = 'text/html;profile=mcp-app'; /** @@ -32,7 +56,8 @@ const rsbuildVirtualModulesPlugin = (): typeof rspack.experiments.VirtualModules 'serve the generated agent-bundle/meta module to browser MCP App bundles', ); -export interface CompiledMcpApp { +/** One MCP App view the build will compile: the planning shape, known before the bundler runs. */ +export interface PlannedMcpApp { readonly _meta?: Readonly<Record<string, unknown>>; readonly id: string; readonly mimeType: typeof mcpAppMimeType; @@ -46,12 +71,118 @@ export interface CompiledMcpApp { readonly target: string; } -const usesReactSyntax = (source: string): boolean => /\.[jt]sx$/iu.test(extname(source)); +/** A planned MCP App after its self-contained HTML was emitted and measured. */ +export interface CompiledMcpApp extends PlannedMcpApp { + readonly size: McpAppOutputSize; +} + +export interface CompiledMcpAppsResult { + readonly apps: readonly CompiledMcpApp[]; + /** Compile warnings (`AB4771`) and size advisories (`AB4772`) that did not fail the build; errors throw a `DiagnosticError` of `AB4770`s instead. */ + readonly diagnostics: readonly Diagnostic[]; +} + +/** + * The stats every App environment records for the diagnostics: errors and + * warnings (with their module traces, as Rsbuild's own reporter reads them) + * and the complete module list with concatenated parts, which the `AB4772` + * advisory ranks. The parts of a concatenated module are orphans of the + * chunk graph, and without `orphanModules` Rspack collapses them — nested + * and top-level alike — into one nameless aggregate, so the advisory could + * name a CommonJS dependency but never the author's own ESM source. + * Reasons, sources, and chunk membership are switched off: they are paid for + * per module and nothing here reads them. + */ +const mcpAppStatsOptions = { + all: false, + assets: true, + chunkModules: false, + errors: true, + moduleTrace: true, + modules: true, + nestedModules: true, + orphanModules: true, + reasons: false, + source: false, + warnings: true, +} as const; + +/** + * Compile-time collector: `onAfterEnvironmentCompile` fires with the + * environment's stats even when the compile failed (the build then rejects + * with Rspack's bare `Rspack build failed.`), so this is where the errors the + * `AB4770`s carry come from. `logLevel` stays `silent`; these diagnostics are + * the one channel. Added through `addPlugins` in `compileMcpApps`, never in + * the composed profile `inspect --bundler` renders. + */ +const mcpAppStatsCollectorPlugin = (collected: Map<string, Rspack.StatsCompilation>): RsbuildPlugin => ({ + name: 'agent-bundle:mcp-app-stats', + setup(api) { + api.onAfterEnvironmentCompile(({ environment, stats }) => { + if (stats === undefined) return; + collected.set(environment.name, stats.toJson(mcpAppStatsOptions)); + }); + }, +}); + +/** + * The document defaults a template-less view ships and an authored template + * keeps only where it left a gap: `lang="en"` on a root element that + * declares no language, and a `<title>` naming the App when the document has + * none (right after the charset declaration so the encoding stays first, else + * right after `<head>`). Rsbuild's `html.title` already adds the title to a + * template without one; this keeps the guarantee when a `tools.rsbuild` hatch + * clears it. A template with its own `lang` or `<title>` is untouched. + * + * The title is the App name verbatim: config validation (`AB4324`) admits + * only lowercase kebab-case names, so there is nothing to escape. A name that + * reached the compiler another way and could break the markup is refused + * rather than written. + */ +const withMcpAppHtmlDefaults = (html: string, appName: string): string => { + const withLanguage = html.replace(/<html(?<attributes>[^>]*)>/iu, (rootElement, attributes: string) => ( + /\slang\s*=/iu.test(attributes) ? rootElement : `<html lang="en"${attributes}>` + )); + if (/<title[\s>]/iu.test(withLanguage)) return withLanguage; + if (/[&<>"']/u.test(appName)) { + throw new Error(`MCP App name ${JSON.stringify(appName)} is not the kebab-case name config validation guarantees.`); + } + const title = `<title>${appName}`; + const anchor = /]*charset\s*=[^>]*>|]*)?>/iu.exec(withLanguage); + if (anchor === null) return withLanguage; + const insertAt = anchor.index + anchor[0].length; + return `${withLanguage.slice(0, insertAt)}${title}${withLanguage.slice(insertAt)}`; +}; + +const mcpAppHtmlDefaultsPlugin = (): RsbuildPlugin => ({ + name: 'agent-bundle:mcp-app-html-defaults', + setup(api) { + // The environment is the App: its name is the view's name. + api.modifyHTML((html, { environment }) => withMcpAppHtmlDefaults(html, environment.name)); + }, +}); + +/** + * Rspack consults a consumer tsconfig `paths` entry before `resolve.alias` + * (Rsbuild's default `prefer-tsconfig`, which is what lets a view import + * through the author's own `paths`), so an entry for `agent-bundle/meta` would + * shadow the generated identity module. This replacement rewrites the exact + * specifier to the virtual module's path before resolution starts, ahead of + * both; the alias stays as the declared mapping the inspection renders. + */ +const metaModuleReplacement = (metaModulePath: string): InstanceType => + new rspack.NormalModuleReplacementPlugin(new RegExp(`^${escapeRegExp(metaModuleSpecifier)}$`, 'u'), metaModulePath); + +const isMetaModuleReplacement = (plugin: unknown, metaModulePath: string): boolean => + plugin instanceof rspack.NormalModuleReplacementPlugin + && plugin._args[0].test(metaModuleSpecifier) + && plugin._args[1] === metaModulePath; const assertResolvedViewConfig = ( inspection: Awaited>['inspectConfig']>>, appNames: readonly string[], outputRoot: string, + metaModulePath: string, ): void => { const environments = inspection.origin.environmentConfigs; const bundlers = inspection.origin.bundlerConfigs; @@ -76,28 +207,51 @@ const assertResolvedViewConfig = ( } } for (const bundler of bundlers) { - if (bundler.output?.asyncChunks !== false || bundler.output.path !== outputRoot) { + if ( + bundler.output?.asyncChunks !== false || + bundler.output.path !== outputRoot || + // The reserved `agent-bundle/meta` specifier must beat a consumer + // tsconfig `paths` entry that shadows it (see `metaModuleReplacement`). + !(bundler.plugins ?? []).some((plugin) => isMetaModuleReplacement(plugin, metaModulePath)) + ) { throw new Error('Rsbuild resolved an invalid self-contained MCP App configuration.'); } } }; +/** + * Proves every planned view landed as exactly one self-contained HTML file + * and measures it: the emitted bytes and their gzip size are what the build + * summary reports and what the size advisory judges. + */ const assertSelfContainedViews = async ( - compiled: readonly CompiledMcpApp[], + compiled: readonly PlannedMcpApp[], outputRoot: string, -): Promise => { +): Promise> => { const expected = new Set(compiled.map((entry) => entry.output)); const files = await listArtifactFiles(outputRoot); - if (files.length !== expected.size || files.some((entry) => !expected.has(resolve(outputRoot, entry.path)))) { - throw new Error('Rsbuild emitted files beyond the stable self-contained MCP App HTML output.'); + const unexpected = files.filter((entry) => !expected.has(resolve(outputRoot, entry.path))).map((entry) => entry.path); + if (unexpected.length > 0) { + throw new Error( + `Rsbuild emitted files beyond the stable self-contained MCP App HTML output: ${unexpected.join(', ')}. ` + + 'Only inline source maps keep a view self-contained; a `tools.rsbuild` output.sourceMap other than inline-source-map emits .map siblings.', + ); + } + if (files.length !== expected.size) { + const emitted = new Set(files.map((entry) => resolve(outputRoot, entry.path))); + const missing = [...expected].filter((output) => !emitted.has(output)); + throw new Error(`Rsbuild did not emit the planned MCP App HTML output: ${missing.join(', ')}.`); } + const sizes = new Map(); for (const app of compiled) { - const html = await readFile(app.output, 'utf8'); - if (/<(?:script|link)\b[^>]+(?:src|href)=/iu.test(html)) { + const html = await readFile(app.output); + if (/<(?:script|link)\b[^>]+(?:src|href)=/iu.test(html.toString('utf8'))) { throw new Error(`MCP App ${JSON.stringify(app.name)} HTML is not self-contained.`); } + sizes.set(app.name, Object.freeze({ bytes: html.byteLength, gzipBytes: gzipSync(html).byteLength })); } + return sizes; }; /** @@ -127,7 +281,7 @@ const selectedAppTarget = ( export const planCompiledMcpApps = ( apps: readonly NormalizedMcpApp[], options: Readonly<{ readonly outDir: string } & McpAppTargetSelection>, -): readonly CompiledMcpApp[] => { +): readonly PlannedMcpApp[] => { const planned = new Map(); for (const app of apps) { const target = selectedAppTarget(app, options); @@ -179,6 +333,8 @@ export const composeMcpAppsRsbuildConfig = ( readonly cwd: string; /** The project identity served to widget source as `agent-bundle/meta`. */ readonly meta: AgentBundleMeta; + /** Defaults to `production`; see {@link McpAppCompileMode}. */ + readonly mode?: McpAppCompileMode; readonly outDir: string; readonly tools?: AgentBundleToolsConfig; }, @@ -186,14 +342,22 @@ export const composeMcpAppsRsbuildConfig = ( const metaModulePath = generatedMetaModulePath(options.cwd); const profile: RsbuildConfig = { environments: Object.fromEntries(sources.map((source) => [source.name, { - ...(usesReactSyntax(source.source) ? { plugins: [pluginReact()] } : {}), + // Every view carries the React plugin, whatever its entry extension: a + // `.ts` entry importing a `.tsx` component needs the automatic runtime + // just as much as a `.tsx` entry, and without the plugin its JSX lowers + // to a `React.createElement` no module has in scope. + plugins: [pluginReact({ fastRefresh: false })], html: { inject: 'body' as const, + mountId: 'root', + title: source.name, ...(source.template === undefined ? {} : { template: source.template }), }, source: { entry: { [source.name]: source.source } }, }])), logLevel: 'silent' as const, + // Both compile modes build the production profile (production React, no + // refresh runtime); development only makes the output readable. mode: 'production' as const, output: { dataUriLimit: Number.MAX_SAFE_INTEGER, @@ -203,9 +367,19 @@ export const composeMcpAppsRsbuildConfig = ( inlineScripts: true, inlineStyles: true, legalComments: 'inline' as const, + // Development keeps the output readable (real identifiers, one + // `// CONCATENATED MODULE: ./views/…` marker per module), about 2.7× the + // production bytes. No source map in either mode: an inline map that + // carries the sources is another ~7× (a 617 KiB ext-apps view becomes + // 4.2 MiB), past the host bound; `tools.rsbuild.output.sourceMap` opts a + // small view in, and only the inline forms keep it one file. + ...(options.mode === 'development' ? { minify: false } : {}), sourceMap: false, target: 'web' as const, }, + // Rsbuild's default `resolve.aliasStrategy` (`prefer-tsconfig`) stays: it + // is what hands the author's tsconfig `paths` to the view compiler. The + // reserved specifier wins through `metaModuleReplacement` instead. server: { publicDir: false }, splitChunks: false, }; @@ -222,6 +396,7 @@ export const composeMcpAppsRsbuildConfig = ( const VirtualModulesPlugin = rsbuildVirtualModulesPlugin(); config.plugins = [ ...(config.plugins ?? []), + metaModuleReplacement(metaModulePath), new VirtualModulesPlugin({ [metaModulePath]: generatedMetaModuleSource(options.meta) }), ]; return config; @@ -244,16 +419,18 @@ export const compileMcpApps = async ( readonly cwd: string; /** The project identity served to widget source as `agent-bundle/meta`. */ readonly meta: AgentBundleMeta; + /** Defaults to `production`; see {@link McpAppCompileMode}. */ + readonly mode?: McpAppCompileMode; readonly outDir: string; readonly tools?: AgentBundleToolsConfig; } & McpAppTargetSelection>, -): Promise => { +): Promise => { const compiled = planCompiledMcpApps(apps, { outDir: options.outDir, ...(options.target === undefined ? { targets: options.targets } : { target: options.target }), }); if (compiled.length === 0) { - return compiled; + return Object.freeze({ apps: Object.freeze([]), diagnostics: Object.freeze([]) }); } await assertGeneratedModulesRootAbsent(options.cwd); @@ -265,21 +442,49 @@ export const compileMcpApps = async ( return source; }); + const mode: McpAppCompileMode = options.mode ?? 'production'; const rsbuild = await createRsbuild({ cwd: options.cwd, config: composeMcpAppsRsbuildConfig(sources, { cwd: options.cwd, meta: options.meta, + mode, 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' }); - assertResolvedViewConfig(inspection, compiled.map((app) => app.name), options.outDir); + assertResolvedViewConfig(inspection, compiled.map((app) => app.name), options.outDir, generatedMetaModulePath(options.cwd)); + const contexts: readonly McpAppDiagnosticContext[] = compiled.map((app) => ({ + appName: app.name, + entrySource: app.source, + projectRoot: options.cwd, + })); + /** + * Every Rspack error the collector recorded, as `AB4770`s; when the bundler + * rejected without leaving a stats error (a compiler-level failure rather + * than a module's), its own message, attributed to each App of the run. + */ + const compileFailure = (error: unknown): DiagnosticError => { + const fromStats = contexts.flatMap((context) => + mcpAppCompileErrorDiagnostics(context, collectedStats.get(context.appName)?.errors ?? [])); + if (fromStats.length > 0) return new DiagnosticError(fromStats); + const failure = error instanceof Error ? error.message : String(error); + return new DiagnosticError(contexts.map((context) => mcpAppBundlerFailureDiagnostic(context, failure))); + }; + const buildViews = async (): Promise>> => { + try { + return await rsbuild.build(); + } catch (error) { + throw compileFailure(error); + } + }; const evidenceByPath = new Map(); let result: Awaited> | undefined; try { - result = await rsbuild.build(); + result = await buildViews(); const evidence = collectBundledOutputEvidence({ expectedAssets: compiled.map((app) => ({ allowUnassociatedHtml: true, @@ -297,9 +502,83 @@ export const compileMcpApps = async ( await result?.close(); } - await assertSelfContainedViews(compiled, options.outDir); - return Object.freeze(compiled.map((app) => Object.freeze({ + const sizes = await assertSelfContainedViews(compiled, options.outDir); + const compiledApps = Object.freeze(compiled.map((app): CompiledMcpApp => Object.freeze({ ...app, + size: sizes.get(app.name) ?? (() => { throw new Error(`Missing emitted size for MCP App ${JSON.stringify(app.name)}.`); })(), sourceInputs: evidenceByPath.get(`mcp-apps/${app.name}.html`) ?? (() => { throw new Error(`Missing bundled MCP App evidence for ${JSON.stringify(app.name)}.`); })(), }))); + /** + * One App's advisories: its Rspack warnings, then the size advisory for + * the document that was emitted for it — by default this compile's, or the + * production replacement's when the fallback below swapped it in. + */ + const appDiagnostics = ( + context: McpAppDiagnosticContext, + index: number, + emitted: { readonly mode: McpAppCompileMode; readonly size: McpAppOutputSize } = { mode, size: compiledApps[index]!.size }, + ): readonly Diagnostic[] => { + const stats = collectedStats.get(context.appName); + const size = mcpAppSizeDiagnostic(context, { modules: stats?.modules ?? [], ...emitted }); + return [ + ...mcpAppCompileWarningDiagnostics(context, stats?.warnings ?? []), + ...(size === undefined ? [] : [size]), + ]; + }; + const oversized = mode === 'development' ? compiledApps.filter((app) => app.size.bytes > MAX_APP_HTML_BYTES) : []; + if (oversized.length === 0) { + return Object.freeze({ + apps: compiledApps, + diagnostics: freezeDiagnostics(contexts.flatMap((context, index) => appDiagnostics(context, index))), + }); + } + + // Readable output that would not render in the hosts gives way to the + // production profile for that view, so the Workbench preview shows every + // view `agent-bundle build` ships. The production compile lands in its own + // directory: the staged root already holds the other views, which the + // self-containment assertion would count as strays. + const fallbackRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-app-production-')); + let production: CompiledMcpAppsResult; + try { + production = await compileMcpApps( + apps.filter((app) => oversized.some((entry) => entry.name === app.name)), + { ...options, mode: 'production', outDir: fallbackRoot }, + ); + for (const app of oversized) { + const replacement = production.apps.find((entry) => entry.name === app.name); + if (replacement === undefined) { + throw new Error(`MCP App ${JSON.stringify(app.name)} disappeared during its production fallback compile.`); + } + await copyFile(replacement.output, app.output); + } + } finally { + await rm(fallbackRoot, { force: true, recursive: true }); + } + const replaced = new Map(production.apps.map((app) => [app.name, app])); + return Object.freeze({ + apps: Object.freeze(compiledApps.map((app) => { + const replacement = replaced.get(app.name); + return replacement === undefined ? app : Object.freeze({ ...app, size: replacement.size, sourceInputs: replacement.sourceInputs }); + })), + // The production compile's own diagnostics are not merged: its warnings + // are this compile's (same module graph), and each replaced App gets + // exactly one `AB4772` here — the substitution notice when the + // replacement fits the hosts, else the plain host-bound advisory, since a + // notice claiming the preview renders it would be false. + diagnostics: freezeDiagnostics(contexts.flatMap((context, index) => { + const replacement = replaced.get(context.appName); + if (replacement === undefined) return appDiagnostics(context, index); + if (replacement.size.bytes > MAX_APP_HTML_BYTES) return appDiagnostics(context, index, { mode: 'production', size: replacement.size }); + const stats = collectedStats.get(context.appName); + return [ + ...mcpAppCompileWarningDiagnostics(context, stats?.warnings ?? []), + mcpAppReadableFallbackDiagnostic(context, { + modules: stats?.modules ?? [], + production: replacement.size, + readable: compiledApps[index]!.size, + }), + ]; + })), + }); }; diff --git a/packages/agent-bundle/src/build/rspack-stats-errors.ts b/packages/agent-bundle/src/build/rspack-stats-errors.ts new file mode 100644 index 000000000..b243c100d --- /dev/null +++ b/packages/agent-bundle/src/build/rspack-stats-errors.ts @@ -0,0 +1,147 @@ +import type { Rspack } from '@rsbuild/core'; +import { isAbsolute, resolve } from 'node:path'; +import { stripVTControlCharacters } from 'node:util'; + +import { isInside, toPosixRelative } from '../core/paths.ts'; + +/** + * Reads Rspack stats errors and warnings the way Rsbuild's own reporter + * does — which module an entry belongs to, where it points, and its message + * as one line of prose — for every consumer that turns a compile failure into + * a diagnostic: the MCP App view compiler's `AB4770`/`AB4771`, and projects + * that drive Rsbuild themselves (the `rsc-agent-runtime` example's `AB8206`) + * through the `agent-bundle/api` exports. Pure: no I/O, no logging. + */ + +export interface RspackStatsErrorLocation { + /** As printed by Rspack: `loc` columns are 1-based, SWC's miette frame columns 0-based. */ + readonly column: number; + readonly line: number; +} + +/** One stats entry, read: the module (absolute), the position, and the flattened message. */ +export interface RspackStatsErrorDetail { + /** The absolute path of the module the entry belongs to; `undefined` for compilation-level entries that name none. */ + readonly file: string | undefined; + /** Where the entry points inside `file`, when Rspack or the SWC frame said. */ + readonly location: RspackStatsErrorLocation | undefined; + /** The message as one line of prose: no ANSI colour, miette glyphs, or code frame. */ + readonly message: string; +} + +const rspackLocation = /^(?\d+):(?\d+)/u; +/** miette's frame header names the span it opens: `╭─[1:10]`, or `╭─[file:1:10]`. */ +const mietteFrameHeader = /╭─\[(?:[^\]\n]*:)?(?\d+):(?\d+)\]/u; +/** One code-frame line: a line number, the gutter, the source. */ +const codeFrameLine = /^\s*(?\d+)\s*│/u; +/** The marker line under a code-frame line: the gutter dot, then the span underlined with `─`. */ +const codeFrameMarker = /^\s*·/u; +const codeFrameGutter = '·'; +const codeFrameUnderline = '─'; +/** miette's decorations, in the order they must go: the arrow and the frame header before the bare glyph runs. */ +const mietteDecorations = /╰─▶|╭─\[[^\]\n]*\]|[×⚠│·╭╰╯╮─]+/gu; + +/** + * Where an entry points. Rspack's own `loc` (`"1:1-41"`) wins; otherwise the + * SWC/miette frame inside the message: its `╭─[line:col]` header when miette + * printed one, else the caret line under the code frame (miette omits the + * header when the span starts on the first line, which is exactly where a + * one-line fixture fails). Both frame forms report miette's 0-based column. + */ +export const statsErrorLocation = (error: Rspack.StatsError): RspackStatsErrorLocation | undefined => { + const located = error.loc === undefined ? undefined : rspackLocation.exec(error.loc)?.groups; + if (located !== undefined) return { column: Number(located.column), line: Number(located.line) }; + const message = stripVTControlCharacters(error.message); + const header = mietteFrameHeader.exec(message)?.groups; + if (header !== undefined) return { column: Number(header.column), line: Number(header.line) }; + const lines = message.split(/\r?\n/u); + for (const [index, line] of lines.entries()) { + const frame = codeFrameLine.exec(line)?.groups; + const marker = lines[index + 1]; + if (frame === undefined || marker === undefined || !codeFrameMarker.test(marker)) continue; + const underline = marker.indexOf(codeFrameUnderline); + if (underline === -1) continue; + // The source starts two cells after the gutter (`│ ` above, `· ` below). + const column = underline - (marker.indexOf(codeFrameGutter) + 2); + if (column < 0) continue; + return { column, line: Number(frame.line) }; + } + return undefined; +}; + +/** + * The request a loader chain ends in: `builtin:swc-loader??ruleSet[…]!/abs/views/status.ts` + * names `/abs/views/status.ts`; Rspack's inline match-resource form + * (`!=!`) names the resource, as Rsbuild's own + * `removeLoaderChainDelimiter` reads it. A resource query is not a path. + */ +export const loaderChainTarget = (request: string): string => { + const resource = request.split('!=!')[0] ?? request; + const lastDelimiter = resource.lastIndexOf('!'); + return (lastDelimiter === -1 ? resource : resource.slice(lastDelimiter + 1)).replace(/\?.*$/u, ''); +}; + +/** + * The absolute path of the module an entry belongs to, resolved the way + * Rsbuild's `resolveFileName` does: `file`, else `moduleName` (relative to + * the compiler context, the project root), else the resource the + * `moduleIdentifier` loader chain ends in. `undefined` for compilation-level + * entries that name no module. + */ +export const statsErrorFile = (error: Rspack.StatsError, projectRoot: string): string | undefined => { + const named = [error.file, error.moduleName, error.moduleIdentifier] + .find((candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0); + if (named === undefined) return undefined; + const target = loaderChainTarget(named); + if (target.length === 0) return undefined; + return isAbsolute(target) ? target : resolve(projectRoot, target); +}; + +/** Project-relative with forward slashes inside the project root, absolute otherwise. */ +export const displayPath = (projectRoot: string, path: string): string => + isInside(projectRoot, path) ? toPosixRelative(projectRoot, path) : path; + +/** + * One line of prose out of an Rspack message: ANSI stripped, miette's box + * glyphs (`×` and its warning twin `⚠`, `╰─▶`, `╭─[…]`, `╰────`, `│`, `·`, + * `─`) removed, code-frame lines (` │ …`) and the marker lines under them + * dropped, the remaining lines trimmed and joined with a single space. + */ +export const normalizeStatsMessage = (message: string): string => stripVTControlCharacters(message) + .split(/\r?\n/u) + .filter((line) => !codeFrameLine.test(line)) + .map((line) => line.replace(mietteDecorations, ' ').replace(/\s+/gu, ' ').trim()) + .filter((line) => line.length > 0) + .join(' '); + +/** Reads one Rspack stats error or warning: its module, position, and one-line message. */ +export const describeRspackStatsError = (error: Rspack.StatsError, projectRoot: string): RspackStatsErrorDetail => Object.freeze({ + file: statsErrorFile(error, projectRoot), + location: statsErrorLocation(error), + message: normalizeStatsMessage(error.message), +}); + +/** + * `::: ` — the file project-relative, the + * position only as far as the entry knows it, the bare message when Rspack + * attributed the entry to no module. + */ +export const renderRspackStatsErrorDetail = (detail: RspackStatsErrorDetail, projectRoot: string): string => { + if (detail.file === undefined) return detail.message; + const position = detail.location === undefined ? '' : `:${String(detail.location.line)}:${String(detail.location.column)}`; + return `${displayPath(projectRoot, detail.file)}${position}: ${detail.message}`; +}; + +/** {@link describeRspackStatsError} rendered as one `file:line:column: message` line. */ +export const formatRspackStatsError = (error: Rspack.StatsError, projectRoot: string): string => + renderRspackStatsErrorDetail(describeRspackStatsError(error, projectRoot), projectRoot); + +/** + * Every error in a Stats or MultiStats JSON document. A MultiStats document + * already lists its children's errors at the top level, so children are only + * walked when that list is empty; concatenating both would double-count. + */ +export const rspackStatsErrors = (stats: Rspack.StatsCompilation): readonly Rspack.StatsError[] => { + if (stats.errors !== undefined && stats.errors.length > 0) return stats.errors; + return (stats.children ?? []).flatMap(rspackStatsErrors); +}; diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 387f93d89..5ad4c9125 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -51,6 +51,7 @@ import { errorMessage } from './core/errors.ts'; import { formatInstallResult, formatUninstallResult } from './install/format.ts'; import { projectVersionLabel } from './core/project-context.ts'; import { stableJson } from './core/digest.ts'; +import { formatByteSize } from './core/strings.ts'; import { formatServeAppReadyLine, isServeAppAllowCapability, type ServeAppAllowCapability } from './serve-app/command-contract.ts'; import type { EvalComparisonDelta, EvalConditionMetrics } from './eval/compare.ts'; import type { CliTerminal } from './effect/cli-runtime.ts'; @@ -357,6 +358,16 @@ const humanBuild = (result: Awaited>): string => { out.push(`${diagnostic.code} (${diagnostic.severity}): ${diagnostic.message}\n`); } out.push(`Built ${result.model.metadata.name} to ${result.build.outputRoot}\n`); + // One line per compiled MCP App view (#572): the artifact-relative document + // and its measured size, the number the `AB4772` advisory bounds. + const compiledMcpApps = [...result.build.compiledMcpApps] + .sort((left, right) => left.target.localeCompare(right.target) || left.name.localeCompare(right.name)); + for (const app of compiledMcpApps) { + out.push( + `MCP App ${app.name} (${app.target}): mcp-apps/${app.name}.html ` + + `${formatByteSize(app.size.bytes)} (${formatByteSize(app.size.gzipBytes)} gzip)\n`, + ); + } for (const report of result.hostValidation ?? []) { out.push( `Host validation (${report.target}): ${report.status}` + @@ -422,13 +433,6 @@ const describeInstallComparison = (comparison: DoctorInstallComparison): string } }; -const formatByteSize = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B`; - const kibibytes = bytes / 1024; - if (kibibytes < 1024) return `${kibibytes.toFixed(1).replace(/\.0$/u, '')} KiB`; - return `${(kibibytes / 1024).toFixed(1).replace(/\.0$/u, '')} MiB`; -}; - const humanDoctor = (result: DoctorReport): string => { const out: string[] = []; for (const host of result.hosts) { diff --git a/packages/agent-bundle/src/core/mcp-app-limits.ts b/packages/agent-bundle/src/core/mcp-app-limits.ts new file mode 100644 index 000000000..54e5091bf --- /dev/null +++ b/packages/agent-bundle/src/core/mcp-app-limits.ts @@ -0,0 +1,10 @@ +/** + * The largest MCP App HTML document the framework's hosts render: the + * Workbench bridge (`dev/mcp-apps/mcp-app-bridge.ts`) and the `serve-app` + * host refuse a resource above this many UTF-8 bytes, and the browser test + * harness (`rstest/browser-setup-module.ts`) rejects a compiled view above + * it. The compiler's `AB4772` size advisory judges emitted views against the + * same number, which is why the constant lives in `core/`: `build/**` never + * imports `dev/**`. + */ +export const MAX_APP_HTML_BYTES = 2_097_152; diff --git a/packages/agent-bundle/src/core/strings.ts b/packages/agent-bundle/src/core/strings.ts index 9e1d59296..bd478050f 100644 --- a/packages/agent-bundle/src/core/strings.ts +++ b/packages/agent-bundle/src/core/strings.ts @@ -1,2 +1,13 @@ /** Escapes a literal string for interpolation into a RegExp source. */ export const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`); + +/** + * A byte count for people: 1024-based with one decimal and a trailing `.0` + * dropped (`427.1 KiB`, `1.3 MiB`, `2 MiB`); plain bytes below 1 KiB. + */ +export const formatByteSize = (bytes: number): string => { + if (bytes < 1024) return `${String(bytes)} B`; + const kibibytes = bytes / 1024; + if (kibibytes < 1024) return `${kibibytes.toFixed(1).replace(/\.0$/u, '')} KiB`; + return `${(kibibytes / 1024).toFixed(1).replace(/\.0$/u, '')} MiB`; +}; diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-service.ts b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts index 4595307d7..26edf43b0 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts @@ -205,7 +205,14 @@ export class ArtifactService { let result: ArtifactEpochResult; try { - await this.#compile({ + // The dev loop is the one caller that reads its own output: MCP App + // views compile unminified (readable, still one self-contained HTML per + // App; a view too large to render that way falls back to the production + // profile). A failing compile throws a `DiagnosticError` carrying + // AB4770s, which `failureDiagnostics` forwards unchanged; only foreign + // throws fall back to AB7100. + const compiled = await this.#compile({ + mode: 'development', model, outputRoot: artifactRoot, projectContext, @@ -226,7 +233,10 @@ export class ArtifactService { snapshot: undefined, }; const validationDiagnostics = freezeDiagnostics(firstValidation.diagnostics); - buildDiagnostics = freezeDiagnostics([...prepared.diagnostics, ...validationDiagnostics]); + // Non-fatal compiler findings (AB4771 warnings, AB4772 size advisories) + // ride the epoch's summary and the attempt's diagnostics like + // validation findings do. + buildDiagnostics = freezeDiagnostics([...prepared.diagnostics, ...compiled.diagnostics, ...validationDiagnostics]); if (hasErrors(buildDiagnostics)) throw new DiagnosticError(buildDiagnostics); const currentSource = await prepared.snapshotSource(); diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts index c0b63e702..0ef426f9a 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts @@ -1,3 +1,4 @@ +import { MAX_APP_HTML_BYTES } from '../../core/mcp-app-limits.ts'; import { validateMcpAppDownloadRequest, validateMcpAppExternalLink, @@ -228,7 +229,6 @@ const maximumTeardownTimeoutMs = 30_000; const defaultMaximumQueuedHostMessageBytes = 1_048_576; const maximumQueuedHostMessageBytes = 16_777_216; const maximumQueuedHostMessageSendAttempts = 3; -export const MAX_APP_HTML_BYTES = 2_097_152; const defaultSharedSenderQueuedMessages = 32; const defaultSharedSenderQueuedMessageBytes = 256 * 1024; const loggingLevels = new Set(['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency']); diff --git a/packages/agent-bundle/src/rstest/browser-setup-module.ts b/packages/agent-bundle/src/rstest/browser-setup-module.ts index 5819bd94b..888521266 100644 --- a/packages/agent-bundle/src/rstest/browser-setup-module.ts +++ b/packages/agent-bundle/src/rstest/browser-setup-module.ts @@ -3,7 +3,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import type { CompiledMcpApp } from '../build/mcp-apps.ts'; -import { MAX_APP_HTML_BYTES } from '../dev/mcp-apps/mcp-app-bridge.ts'; +import { MAX_APP_HTML_BYTES } from '../core/mcp-app-limits.ts'; import { AGENT_BROWSER_TEST_REGISTRY_SYMBOL_KEY, AGENT_BROWSER_TEST_REGISTRY_VERSION, diff --git a/packages/agent-bundle/src/rstest/browser.ts b/packages/agent-bundle/src/rstest/browser.ts index 65fc6bc5c..ca4512c66 100644 --- a/packages/agent-bundle/src/rstest/browser.ts +++ b/packages/agent-bundle/src/rstest/browser.ts @@ -119,7 +119,7 @@ export const agentBundleBrowserRstest = async ( const outputRoot = resolve(root, '.agent-bundle', 'test', 'browser-app-build'); await rm(outputRoot, { force: true, recursive: true }); await mkdir(outputRoot, { recursive: true }); - const compiled = await compileMcpApps(normalized, { + const { apps: compiled } = await compileMcpApps(normalized, { cwd: root, meta: { name: manifest.plugin.name, diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 5d2570d50..8f560ead1 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -534,6 +534,10 @@ it('reports complete immutable output provenance for a Skill copy and bundled sc expect(provenance.every((record) => record.sourceInputs.every((input) => !input.startsWith('/')))).toBe(true); expect(Object.isFrozen(provenance)).toBe(true); expect(provenance.every((record) => Object.isFrozen(record) && Object.isFrozen(record.sourceInputs))).toBe(true); + // The compiler's non-fatal findings (#572) ride the same frozen result; a + // build with no MCP App views has none to report. + expect(result.diagnostics).toEqual([]); + expect(Object.isFrozen(result.diagnostics)).toBe(true); } finally { await cleanupProject(project); } diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 63178dadb..0df1fde20 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -1,4 +1,5 @@ import { execFile as executeFile, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -50,11 +51,33 @@ const runSourceCliWithOutput = async ( return { code, stderr: terminal.stderr(), stdout: terminal.stdout() }; }; -const createCliProject = async (): Promise<{ readonly output: string; readonly root: string }> => { +/** + * Deterministic text gzip cannot shrink much (a sha256 hex chain). A real App + * view carries hundreds of KiB of runtime, so the fixture view embeds enough + * of this to keep both its raw and gzip sizes above 1 KiB — the size line's + * units are KiB/MiB. + */ +const incompressibleText = (length: number): string => { + let text = ''; + for (let seed = 'agent-bundle cli fixture'; text.length < length;) { + seed = createHash('sha256').update(seed).digest('hex'); + text += seed; + } + return text.slice(0, length); +}; + +const createCliProject = async ( + options: Readonly<{ + /** Also declare one local MCP server with a `dashboard` App view compiled for `portable` (#572). */ + readonly mcpApp?: boolean; + }> = {}, +): Promise<{ readonly output: string; readonly root: string }> => { const parent = await mkdtemp(join(tmpdir(), 'agent bundle cli parent-')); const root = join(parent, 'project with spaces'); const output = join(root, 'artifact with spaces'); + const mcpApp = options.mcpApp === true; await mkdir(join(root, 'src', 'skills', 'review'), { recursive: true }); + if (mcpApp) await mkdir(join(root, 'views'), { recursive: true }); await Promise.all([ writeFile(join(root, 'package.json'), '{"type":"module"}\n'), writeFile( @@ -64,6 +87,12 @@ const createCliProject = async (): Promise<{ readonly output: string; readonly r " plugin: { name: 'cli-fixture', version: '1.0.0' },", " targets: selectedTargets.length === 0 ? ['portable', 'codex'] : selectedTargets,", ' fixtureContext: { command, mode, projectRoot, selectedTargets },', + ...(mcpApp ? [ + ' mcp: { servers: { fixture: {', + " apps: { dashboard: { entry: './views/dashboard.ts', resourceUri: 'ui://cli-fixture/dashboard.html', targets: ['portable'] } },", + " entry: './src/server.ts',", + ' } } },', + ] : []), '});', '', ].join('\n'), @@ -72,6 +101,13 @@ const createCliProject = async (): Promise<{ readonly output: string; readonly r join(root, 'src', 'skills', 'review', 'SKILL.md'), '---\nname: review\ndescription: Reviews changes\n---\n# Review\n', ), + ...(mcpApp ? [ + writeFile(join(root, 'src', 'server.ts'), 'export {};\n'), + writeFile( + join(root, 'views', 'dashboard.ts'), + `document.body.textContent = ${JSON.stringify(`dashboard ${incompressibleText(16 * 1024)}`)};\n`, + ), + ] : []), ]); return { output, root }; }; @@ -526,7 +562,7 @@ it('build requests the Claude host validator by default, opts out under --no-hos ? [] : [{ code: 'AB6020', message: 'Claude plugin validation warning.', severity: strict ? 'error' as const : 'warning' as const }]; return { - build: { outputRoot: '/artifact' }, + build: { compiledMcpApps: [], outputRoot: '/artifact' }, diagnostics, ...(skipped ? {} : { hostValidation: [{ diagnostics, host: 'claude', load: { status: 'loaded' }, status: strict ? 'failed' : 'warnings', target: 'claude', version: '2.1.259' }], @@ -556,6 +592,80 @@ it('build requests the Claude host validator by default, opts out under --no-hos ]); }); +it('build lists every compiled MCP App view with its measured size after the Built line (#572)', async () => { + // Sizes chosen off the whole-unit boundary so the expected text does not + // depend on how a formatter renders an exact `.0`. + const build = async () => ({ + build: { + compiledMcpApps: [ + { name: 'status', size: { bytes: 1_363_149, gzipBytes: 437_350 }, target: 'portable' }, + { name: 'dashboard', size: { bytes: 437_350, gzipBytes: 104_550 }, target: 'portable' }, + { name: 'dashboard', size: { bytes: 437_350, gzipBytes: 104_550 }, target: 'codex' }, + ], + diagnostics: [], + outputRoot: '/artifact', + }, + diagnostics: [], + model: { metadata: { name: 'fixture' } }, + }); + + const human = await runSourceCliWithOutput(['build', '--root', '/project', '--no-host-validation'], { build: build as never }); + + // Sorted by target, then App name; 1024-based, one decimal. + expect(human).toEqual({ + code: 0, + stderr: '', + stdout: [ + 'Built fixture to /artifact', + 'MCP App dashboard (codex): mcp-apps/dashboard.html 427.1 KiB (102.1 KiB gzip)', + 'MCP App dashboard (portable): mcp-apps/dashboard.html 427.1 KiB (102.1 KiB gzip)', + 'MCP App status (portable): mcp-apps/status.html 1.3 MiB (427.1 KiB gzip)', + '', + ].join('\n'), + }); +}); + +it('build compiles a declared MCP App view and reports its document and measured size (#572)', async () => { + const project = await createCliProject({ mcpApp: true }); + try { + // `--json` serializes the whole build result, so the measured sizes and + // the compiler's non-fatal diagnostics ride along without a bespoke shape. + const json = await runSourceCliWithOutput([ + 'build', '--root', project.root, '--output', project.output, '--no-host-validation', '--json', + ]); + expect(json).toMatchObject({ code: 0, stderr: '' }); + const document = JSON.parse(json.stdout) as { + readonly build: { + readonly compiledMcpApps: readonly { + readonly name: string; + readonly size: { readonly bytes: number; readonly gzipBytes: number }; + readonly target: string; + }[]; + readonly diagnostics: readonly unknown[]; + }; + readonly diagnostics: readonly unknown[]; + }; + expect(document.build.compiledMcpApps).toMatchObject([{ name: 'dashboard', target: 'portable' }]); + const size = document.build.compiledMcpApps[0]!.size; + expect(size.bytes).toBeGreaterThan(0); + expect(size.gzipBytes).toBeGreaterThan(0); + expect(size.gzipBytes).toBeLessThanOrEqual(size.bytes); + expect(Array.isArray(document.build.diagnostics)).toBe(true); + expect(Array.isArray(document.diagnostics)).toBe(true); + + const human = await runSourceCliWithOutput([ + 'build', '--root', project.root, '--output', project.output, '--no-host-validation', + ]); + expect(human).toMatchObject({ code: 0, stderr: '' }); + expect(human.stdout).toContain(`Built cli-fixture to ${project.output}\n`); + expect(human.stdout).toMatch( + /^MCP App dashboard \(portable\): mcp-apps\/dashboard\.html \d+(?:\.\d)? [KM]iB \(\d+(?:\.\d)? [KM]iB gzip\)$/mu, + ); + } finally { + await rm(resolve(project.root, '..'), { force: true, recursive: true }); + } +}, 60_000 * timeScale); + it('enables bounded host validation for built artifacts and promotes warnings only under --strict', async () => { const calls: unknown[] = []; const validate = async (options: unknown) => { diff --git a/packages/agent-bundle/tests/dev-artifact-service.test.ts b/packages/agent-bundle/tests/dev-artifact-service.test.ts index 1618ce288..6fc19a6d1 100644 --- a/packages/agent-bundle/tests/dev-artifact-service.test.ts +++ b/packages/agent-bundle/tests/dev-artifact-service.test.ts @@ -6,8 +6,9 @@ import { join } from 'node:path'; import { expect, it } from '@rstest/core'; import { EpochStore, type CreateStagingEpochOptions, type EpochStaging, type StagingValidator } from '../src/dev/epoch-store.ts'; -import { build } from '../src/build/build.ts'; +import { build, type BuildOptions } from '../src/build/build.ts'; import { validateArtifact } from '../src/build/validate-artifact.ts'; +import { DiagnosticError, type Diagnostic } from '../src/core/diagnostics.ts'; import { ArtifactService } from '../src/dev/index.ts'; import { NativePlaygroundService } from '../src/dev/playground/native-playground-service.ts'; import { ProjectService } from '../src/dev/project-service.ts'; @@ -346,6 +347,86 @@ it('uses the prepared output exclusions when checking source changes after compi } }); +it('compiles in development mode and carries MCP App compile advisories onto the published epoch', async () => { + const root = await createProject(); + const store = new EpochStore({ projectRoot: root }); + const advisory: Diagnostic = { + code: 'AB4772', + message: 'MCP App "status" compiled to 1.3 MiB (412.0 KiB gzip), above the 1 MiB advisory bound; largest modules: node_modules/zod/index.js (300.0 KiB)', + severity: 'warning', + sourcePath: join(root, 'views', 'status.ts'), + }; + const compileOptions: BuildOptions[] = []; + try { + const prepared = await new ProjectService({ root }).prepare('build'); + const result = await new ArtifactService({ + compile: async (options) => { + compileOptions.push(options); + const built = await build(options); + return { ...built, diagnostics: [advisory] }; + }, + createEpochId: () => 'epoch-compile-advisory', + epochStore: store, + }).build(prepared); + + expect(compileOptions.map((options) => options.mode)).toEqual(['development']); + expect(result.outcome).toBe('succeeded'); + if (result.outcome !== 'succeeded') throw new Error(result.diagnostics.map((diagnostic) => diagnostic.message).join('\n')); + expect(result.diagnostics).toContainEqual(advisory); + expect(result.epoch.diagnostics).toEqual({ errors: 0, infos: 0, warnings: 1 }); + await expect(store.readActiveEpoch()).resolves.toEqual(result.epoch); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('reports a failed MCP App compile as the compiler\'s own AB4770 diagnostics, not AB7100', async () => { + const root = await createProject(); + const store = new EpochStore({ projectRoot: root }); + const compileError: Diagnostic = { + code: 'AB4770', + message: 'MCP App "status" failed to compile: views/status.ts:1:10: Syntax Error: Expression expected', + severity: 'error', + sourcePath: join(root, 'views', 'status.ts'), + }; + try { + const prepared = await new ProjectService({ root }).prepare('build'); + const result = await new ArtifactService({ + compile: async () => { throw new DiagnosticError([compileError]); }, + epochStore: store, + }).build(prepared); + + expect(result).toEqual({ diagnostics: [compileError], outcome: 'failed' }); + await expect(store.readActiveEpoch()).resolves.toBeUndefined(); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('keeps AB7100 for compiler throws that carry no diagnostics', async () => { + const root = await createProject(); + const store = new EpochStore({ projectRoot: root }); + try { + const prepared = await new ProjectService({ root }).prepare('build'); + const result = await new ArtifactService({ + compile: async () => { throw new Error('Rspack build failed.'); }, + epochStore: store, + }).build(prepared); + + expect(result).toEqual({ + diagnostics: [{ + code: 'AB7100', + message: 'Unable to compile the build: Rspack build failed.', + severity: 'error', + sourcePath: prepared.configPath, + }], + outcome: 'failed', + }); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('uses a root-independent digest for equivalent normalized project models', async () => { const leftRoot = await createProject(); const rightRoot = await createProject(); diff --git a/packages/agent-bundle/tests/dev-coordinator.test.ts b/packages/agent-bundle/tests/dev-coordinator.test.ts index 3e7458567..658f4cd55 100644 --- a/packages/agent-bundle/tests/dev-coordinator.test.ts +++ b/packages/agent-bundle/tests/dev-coordinator.test.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import { expect, it } from '@rstest/core'; +import { DiagnosticError, type Diagnostic } from '../src/core/diagnostics.ts'; import { EpochStore } from '../src/dev/epoch-store.ts'; import { ArtifactService, @@ -14,6 +15,7 @@ import { type ArtifactEpoch, type ArtifactEpochResult, type DiagnosticReport, + type FailedBuildAttempt, type Invalidation, type PreparedProject, } from '../src/dev/index.ts'; @@ -371,6 +373,48 @@ it('retains the last good epoch as stale when a later rebuild fails', async () = } }); +it('surfaces the compiler\'s own MCP App diagnostics on build.failed instead of AB7100', async () => { + const root = await createProject(); + const store = new EpochStore({ projectRoot: root }); + const compileError: Diagnostic = { + code: 'AB4770', + message: 'MCP App "status" failed to compile: views/status.ts:1:10: Syntax Error: Expression expected', + severity: 'error', + sourcePath: join(root, 'views', 'status.ts'), + }; + const hub = new ProjectEventHub({ now: () => new Date('2026-08-14T12:00:00.000Z') }); + const failures: FailedBuildAttempt[] = []; + hub.subscribe((event) => { + if (event.type === 'build.failed') failures.push(event.payload); + }); + + try { + const coordinator = new DevCoordinator({ + acquireLock: async () => ({ close: async () => undefined }), + artifactService: new ArtifactService({ + compile: async () => { throw new DiagnosticError([compileError]); }, + epochStore: store, + }), + createWatcher: () => ({ close: async () => undefined }), + diagnosticService: { close: async () => undefined, lint: async (paths) => ({ diagnostics: [], paths }) }, + epochStore: store, + eventHub: hub, + projectService: new ProjectService({ root }), + root, + }); + + const session = await coordinator.start(); + expect(failures.map((attempt) => attempt.diagnostics)).toEqual([[compileError]]); + expect(session.status()).toMatchObject({ + artifact: { state: 'missing' }, + build: { lastAttempt: { diagnostics: [compileError], outcome: 'failed' }, state: 'failed' }, + }); + await session.close(); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('waits for an in-flight build and closes watcher, diagnostics, and lock exactly once', async () => { const root = await createProject(); let releaseBuild: (() => void) | undefined; diff --git a/packages/agent-bundle/tests/framework-plugin-registration.test.ts b/packages/agent-bundle/tests/framework-plugin-registration.test.ts index 4c45e1175..a4903c591 100644 --- a/packages/agent-bundle/tests/framework-plugin-registration.test.ts +++ b/packages/agent-bundle/tests/framework-plugin-registration.test.ts @@ -48,10 +48,13 @@ describe('framework-owned Rsbuild plugin registry', () => { expect(registeredPluginNames(lib.plugins)).toEqual(owned); }); - it('matches exactly the plugins a React-syntax MCP App view registers, and nothing for a plain view', () => { + it('matches exactly the plugins every MCP App view registers, whatever its entry extension', () => { const apps = composeMcpAppsRsbuildConfig([reactView, plainView], { cwd: '/project', meta, outDir: '/staged/portable' }); + // A `.ts` entry importing a `.tsx` component needs the React plugin as + // much as a `.tsx` entry does, so the registration is not keyed on the + // entry extension. expect(registeredPluginNames(apps.environments?.[reactView.name]?.plugins)).toEqual(owned); - expect(registeredPluginNames(apps.environments?.[plainView.name]?.plugins)).toEqual([]); + expect(registeredPluginNames(apps.environments?.[plainView.name]?.plugins)).toEqual(owned); // The framework registers plugins per environment, never at the root the // consumer's `tools.rsbuild.plugins` merges into. expect(apps.plugins).toBeUndefined(); diff --git a/packages/agent-bundle/tests/inspect-bundler.test.ts b/packages/agent-bundle/tests/inspect-bundler.test.ts index d61ff5049..88b4ee0cf 100644 --- a/packages/agent-bundle/tests/inspect-bundler.test.ts +++ b/packages/agent-bundle/tests/inspect-bundler.test.ts @@ -152,13 +152,21 @@ it('surfaces every synthesized bundler config with the tools hatch merged over t expect(apps.bundler).toBe('rsbuild'); expect(apps.config).toMatchObject({ environments: { - dashboard: { source: { entry: { dashboard: `${root}/src/view.tsx` } } }, + 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` } }, + }, }, output: { distPath: { html: 'mcp-apps', root: '/portable' }, 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: [ diff --git a/packages/agent-bundle/tests/mcp-app-diagnostics.test.ts b/packages/agent-bundle/tests/mcp-app-diagnostics.test.ts new file mode 100644 index 000000000..e1e8e734e --- /dev/null +++ b/packages/agent-bundle/tests/mcp-app-diagnostics.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from '@rstest/core'; +import type { Rspack } from '@rsbuild/core'; + +import { + largestModules, + MCP_APP_COMPILE_ERROR_CAP, + MCP_APP_HTML_ADVISORY_BYTES, + mcpAppCompileErrorDiagnostics, + mcpAppCompileWarningDiagnostics, + mcpAppReadableFallbackDiagnostic, + mcpAppSizeDiagnostic, + type McpAppDiagnosticContext, +} from '../src/build/mcp-app-diagnostics.ts'; +import { MAX_APP_HTML_BYTES } from '../src/core/mcp-app-limits.ts'; +import { formatByteSize } from '../src/core/strings.ts'; + +const context: McpAppDiagnosticContext = Object.freeze({ + appName: 'status', + entrySource: '/project/views/status.ts', + projectRoot: '/project', +}); + +const statsError = (overrides: Partial & { readonly message: string }): Rspack.StatsError => ({ ...overrides }); + +const swcSyntaxError = statsError({ + code: 'ModuleBuildError', + message: ' × Module build failed (from builtin:swc-loader):\n ╰─▶ × Syntax Error: Expression expected\n ╭────\n 1 │ const x = ;\n · ─\n ╰────\n \n', + moduleIdentifier: 'builtin:swc-loader??ruleSet[1].rules[2].oneOf[3].use[0]!/project/views/status.ts', + moduleName: './views/status.ts', +}); + +const unresolvedImportError = statsError({ + loc: '1:1-41', + message: " × Module not found: Can't resolve './missing-module' in '/project/views'\n ╭─[1:0]\n 1 │ import { nope } from './missing-module';\n · ────────────────────────────────────────\n 2 │ console.log(nope);\n ╰────\n", + moduleIdentifier: 'builtin:swc-loader??ruleSet[1].rules[2].oneOf[3].use[0]!/project/views/status.ts', + moduleName: './views/status.ts', +}); + +const statsModule = (overrides: Partial & { readonly size: number }): Rspack.StatsModule => ({ + built: true, + buildTimeExecuted: false, + cached: false, + codeGenerated: true, + moduleType: 'javascript/auto', + sizes: { javascript: overrides.size }, + type: 'module', + ...overrides, +}); + +/** + * The parts of a concatenated module, as Rspack reports them with + * `orphanModules` on: nested under the module that absorbed them and, flagged + * orphan, once more at the top level. + */ +const concatenatedParts: readonly Rspack.StatsModule[] = [ + statsModule({ name: './views/status.ts', nameForCondition: '/project/views/status.ts', orphan: true, size: 2_048 }), + statsModule({ name: './views/StatusPanel.tsx', nameForCondition: '/project/views/StatusPanel.tsx', orphan: true, size: 4_096 }), + statsModule({ name: './.agent-bundle-virtual/generated/meta.mjs', nameForCondition: '/project/.agent-bundle-virtual/generated/meta.mjs', orphan: true, size: 128 }), +]; + +const modules: readonly Rspack.StatsModule[] = [ + // A concatenated module: only its parts are ranked, never its summed size. + statsModule({ + identifier: '/project/views/status.ts + 2 modules', + modules: [...concatenatedParts], + name: './views/status.ts + 2 modules', + orphan: false, + size: 6_272, + }), + ...concatenatedParts, + // Inlined at its only use and emitted nowhere: an orphan no module absorbed. + statsModule({ name: './views/constants.ts', nameForCondition: '/project/views/constants.ts', orphan: true, size: 900_000 }), + statsModule({ + name: '../../workspace/node_modules/.pnpm/react-dom@19.2.8_react@19.2.8/node_modules/react-dom/cjs/react-dom-client.production.js', + nameForCondition: '/workspace/node_modules/.pnpm/react-dom@19.2.8_react@19.2.8/node_modules/react-dom/cjs/react-dom-client.production.js', + size: 536_016, + }), + statsModule({ name: '../../workspace/node_modules/.pnpm/react@19.2.8/node_modules/react/cjs/react.production.js', nameForCondition: '/workspace/node_modules/.pnpm/react@19.2.8/node_modules/react/cjs/react.production.js', size: 17_217 }), + statsModule({ name: '../shared/theme.css', nameForCondition: '/shared/theme.css', size: 9_000 }), + statsModule({ moduleType: 'runtime', name: 'webpack/runtime/define property getters', size: 300 }), + statsModule({ name: '../../workspace/node_modules/.pnpm/scheduler@0.27.0/node_modules/scheduler/cjs/scheduler.production.js', nameForCondition: '/workspace/node_modules/.pnpm/scheduler@0.27.0/node_modules/scheduler/cjs/scheduler.production.js', size: 10_181 }), +]; + +const largestFive = 'node_modules/react-dom/cjs/react-dom-client.production.js (523.5 KiB), node_modules/react/cjs/react.production.js (16.8 KiB), ' + + 'node_modules/scheduler/cjs/scheduler.production.js (9.9 KiB), /shared/theme.css (8.8 KiB), views/StatusPanel.tsx (4 KiB)'; + +describe('MCP App stats mapping', () => { + it('renders every stats error as an AB4770 and caps the list per App', () => { + expect(mcpAppCompileErrorDiagnostics(context, [swcSyntaxError, unresolvedImportError])).toEqual([ + { + code: 'AB4770', + message: 'MCP App "status" failed to compile: views/status.ts:1:10: Module build failed (from builtin:swc-loader): Syntax Error: Expression expected', + recovery: 'Fix the reported error in the named file and rebuild; run `agent-bundle build` for the full message.', + severity: 'error', + sourcePath: '/project/views/status.ts', + }, + expect.objectContaining({ + message: "MCP App \"status\" failed to compile: views/status.ts:1:1: Module not found: Can't resolve './missing-module' in '/project/views'", + }), + ]); + // A module outside the project root shows absolutely; no module at all falls back to the entry. + expect(mcpAppCompileErrorDiagnostics(context, [ + statsError({ message: ' × Module build failed\n', moduleName: '../shared/lib.ts' }), + statsError({ message: ' × Tsconfig not found /project/does-not-exist.json\n' }), + ])).toEqual([ + expect.objectContaining({ message: 'MCP App "status" failed to compile: /shared/lib.ts: Module build failed', sourcePath: '/shared/lib.ts' }), + expect.objectContaining({ + message: 'MCP App "status" failed to compile: Tsconfig not found /project/does-not-exist.json', + sourcePath: '/project/views/status.ts', + }), + ]); + + const many = Array.from({ length: MCP_APP_COMPILE_ERROR_CAP + 3 }, (_, index) => + statsError({ message: ` × failure ${String(index)}\n`, moduleName: './views/status.ts' })); + const capped = mcpAppCompileErrorDiagnostics(context, many); + expect(capped).toHaveLength(MCP_APP_COMPILE_ERROR_CAP); + expect(capped[MCP_APP_COMPILE_ERROR_CAP - 2]!.message).toContain(`failure ${String(MCP_APP_COMPILE_ERROR_CAP - 2)}`); + expect(capped[MCP_APP_COMPILE_ERROR_CAP - 1]).toEqual(expect.objectContaining({ + code: 'AB4770', + message: 'MCP App "status" failed to compile: … and 4 more errors (run the compile with logLevel error via tools.rsbuild for the full list)', + sourcePath: '/project/views/status.ts', + })); + expect(mcpAppCompileErrorDiagnostics(context, many.slice(0, MCP_APP_COMPILE_ERROR_CAP))).toHaveLength(MCP_APP_COMPILE_ERROR_CAP); + }); + + it('renders warnings as AB4771 minus the ignore list', () => { + const warning = statsError({ + loc: '3:1-40', + message: ' ⚠ Critical dependency: the request of a dependency is an expression\n', + moduleName: './views/status.ts', + }); + const noise = statsError({ message: ' ⚠ \u001b[33mnoise\u001b[39m: something nobody can act on\n', moduleName: './views/status.ts' }); + // Ignore patterns see the normalised text, so an entry reads like the message it silences. + expect(mcpAppCompileWarningDiagnostics(context, [warning, noise], [/^noise: /u])).toEqual([{ + code: 'AB4771', + message: 'MCP App "status" produced a warning while compiling: views/status.ts:3:1: Critical dependency: the request of a dependency is an expression', + recovery: 'Address the reported warning in the named file and rebuild; run `agent-bundle build` for the full message.', + severity: 'warning', + sourcePath: '/project/views/status.ts', + }]); + // The default ignore list is empty: every warning surfaces. + expect(mcpAppCompileWarningDiagnostics(context, [warning, noise])).toHaveLength(2); + expect(mcpAppCompileWarningDiagnostics(context, [])).toEqual([]); + }); + + it('formats sizes 1024-based with one decimal, the same helper the CLI prints with', () => { + expect(formatByteSize(512)).toBe('512 B'); + expect(formatByteSize(437_000)).toBe('426.8 KiB'); + expect(formatByteSize(1_048_576)).toBe('1 MiB'); + expect(formatByteSize(1_363_149)).toBe('1.3 MiB'); + }); + + it('ranks the largest leaf modules under project-relative, node_modules, or absolute names', () => { + expect(largestModules(modules, '/project')).toEqual([ + { name: 'node_modules/react-dom/cjs/react-dom-client.production.js', size: 536_016 }, + { name: 'node_modules/react/cjs/react.production.js', size: 17_217 }, + { name: 'node_modules/scheduler/cjs/scheduler.production.js', size: 10_181 }, + { name: '/shared/theme.css', size: 9_000 }, + { name: 'views/StatusPanel.tsx', size: 4_096 }, + ]); + // Each concatenated part once, through the module that absorbed it; the + // orphan nothing absorbed is not in the document and never ranks. + expect(largestModules(modules, '/project', 20).slice(5)).toEqual([ + { name: 'views/status.ts', size: 2_048 }, + { name: 'webpack/runtime/define property getters', size: 300 }, + { name: '.agent-bundle-virtual/generated/meta.mjs', size: 128 }, + ]); + }); + + it('advises on a production view from 1 MiB and on any view past the host bound', () => { + const size = { bytes: 1_363_149, gzipBytes: 319_895 }; + expect(mcpAppSizeDiagnostic(context, { mode: 'production', modules, size })).toEqual({ + code: 'AB4772', + message: `MCP App "status" compiled to 1.3 MiB (312.4 KiB gzip), above the 1 MiB advisory bound; largest modules: ${largestFive}`, + recovery: 'Trim the largest modules listed and rebuild; the Workbench and serve-app hosts refuse a view above 2 MiB.', + severity: 'warning', + sourcePath: '/project/views/status.ts', + }); + // The advisory is a production concern: readable development output is larger by design. + expect(mcpAppSizeDiagnostic(context, { mode: 'development', modules, size: { bytes: 1_572_864, gzipBytes: 400_000 } })).toBeUndefined(); + expect(mcpAppSizeDiagnostic(context, { mode: 'production', modules, size: { bytes: MCP_APP_HTML_ADVISORY_BYTES - 1, gzipBytes: 1 } })).toBeUndefined(); + expect(mcpAppSizeDiagnostic(context, { mode: 'production', modules: [], size: { bytes: MCP_APP_HTML_ADVISORY_BYTES, gzipBytes: 1 } })).toEqual( + expect.objectContaining({ message: 'MCP App "status" compiled to 1 MiB (1 B gzip), above the 1 MiB advisory bound' }), + ); + + const hostRefusal = ', above the 2 MiB bound the Workbench and serve-app hosts accept — the view will not render there'; + for (const mode of ['development', 'production'] as const) { + const diagnostic = mcpAppSizeDiagnostic(context, { mode, modules, size: { bytes: MAX_APP_HTML_BYTES + 1, gzipBytes: 500_000 } }); + expect(diagnostic?.code).toBe('AB4772'); + expect(diagnostic?.message).toContain(`compiled to 2 MiB (488.3 KiB gzip)${hostRefusal}; largest modules: node_modules/react-dom`); + } + // Exactly the host bound still renders there; production still gets the advisory. + expect(mcpAppSizeDiagnostic(context, { mode: 'development', modules, size: { bytes: MAX_APP_HTML_BYTES, gzipBytes: 1 } })).toBeUndefined(); + expect(mcpAppSizeDiagnostic(context, { mode: 'production', modules, size: { bytes: MAX_APP_HTML_BYTES, gzipBytes: 1 } })?.message) + .toContain('above the 1 MiB advisory bound'); + }); + + it('reports a development substitution with both sizes and the modules behind them', () => { + expect(mcpAppReadableFallbackDiagnostic(context, { + modules, + production: { bytes: 1_363_149, gzipBytes: 319_895 }, + readable: { bytes: 3_670_016, gzipBytes: 700_000 }, + })).toEqual({ + code: 'AB4772', + message: 'MCP App "status" readable development output compiled to 3.5 MiB, above the 2 MiB bound the Workbench and serve-app ' + + `hosts accept; the preview renders the production build (1.3 MiB, 312.4 KiB gzip) instead; largest modules: ${largestFive}`, + recovery: 'The preview shows the minified production build; trim the view to read its source in the Workbench.', + severity: 'warning', + sourcePath: '/project/views/status.ts', + }); + expect(mcpAppReadableFallbackDiagnostic(context, { + modules: [], + production: { bytes: 512, gzipBytes: 128 }, + readable: { bytes: MAX_APP_HTML_BYTES + 1, gzipBytes: 1 }, + }).message).toMatch(/the preview renders the production build \(512 B, 128 B gzip\) instead$/u); + }); +}); diff --git a/packages/agent-bundle/tests/mcp-apps-compile.test.ts b/packages/agent-bundle/tests/mcp-apps-compile.test.ts new file mode 100644 index 000000000..8df2928fd --- /dev/null +++ b/packages/agent-bundle/tests/mcp-apps-compile.test.ts @@ -0,0 +1,407 @@ +import { mkdir, mkdtemp, readdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import { MCP_APP_HTML_ADVISORY_BYTES } from '../src/build/mcp-app-diagnostics.ts'; +import { compileMcpApps, composeMcpAppsRsbuildConfig, type McpAppCompileMode } from '../src/build/mcp-apps.ts'; +import { DiagnosticError, type Diagnostic } from '../src/core/diagnostics.ts'; +import { MAX_APP_HTML_BYTES } from '../src/core/mcp-app-limits.ts'; +import type { AgentBundleToolsConfig, NormalizedMcpApp } from '../src/core/types.ts'; +import type { AgentBundleMeta } from '../src/meta.ts'; +import { workbenchNodeModules } from './helpers/workspace-paths.ts'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const meta: AgentBundleMeta = Object.freeze({ + name: 'compile-fixture', + packageName: undefined, + packageVersion: undefined, + version: '1.0.0', +}); + +/** A project root with the workspace's browser dependencies linked in and the given files written. */ +const createProject = async (files: Readonly>): Promise => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-apps-compile-'))); + roots.push(root); + await symlink(workbenchNodeModules, join(root, 'node_modules'), 'dir'); + await writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'); + for (const [path, content] of Object.entries(files)) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), content); + } + return root; +}; + +/** The shape `normalizeProject` (and the Rstest browser preset) hands the compiler, built by hand. */ +const app = ( + root: string, + overrides: { readonly name?: string; readonly source?: string; readonly template?: string } = {}, +): NormalizedMcpApp => { + const name = overrides.name ?? 'status'; + return { + id: `mcp-app:fixture:${name}`, + name, + provenance: { kind: 'config', sourcePath: join(root, 'agent-bundle.config.ts') }, + resourceUri: `ui://compile-fixture/${name}.html`, + serverId: 'mcp:fixture', + serverName: 'fixture', + source: join(root, overrides.source ?? 'views/status.ts'), + targets: ['portable'], + ...(overrides.template === undefined ? {} : { template: join(root, overrides.template) }), + }; +}; + +const compile = async ( + root: string, + apps: readonly NormalizedMcpApp[], + options: { readonly mode?: McpAppCompileMode; readonly tools?: AgentBundleToolsConfig } = {}, +) => { + const outDir = join(root, 'dist', 'portable'); + const result = await compileMcpApps(apps, { cwd: root, meta, outDir, target: 'portable', ...options }); + return { outDir, result }; +}; + +const emittedHtml = async (outDir: string, name = 'status'): Promise => + readFile(join(outDir, 'mcp-apps', `${name}.html`), 'utf8'); + +const compileFailure = async (promise: Promise): Promise => { + try { + await promise; + } catch (error) { + if (error instanceof DiagnosticError) return error.diagnostics; + throw error; + } + throw new Error('Expected the MCP App compile to reject with a DiagnosticError.'); +}; + +const compileErrorShape = { code: 'AB4770', severity: 'error' } as const; + +const reactView = { + 'views/StatusPanel.tsx': 'export const Panel = () => panel-ready;\n', + 'views/status.ts': [ + "import { createRoot } from 'react-dom/client';", + "import { Panel } from './StatusPanel';", + "createRoot(document.getElementById('root')!).render(Panel());", + '', + ].join('\n'), +}; + +describe('compileMcpApps', () => { + it('compiles a .ts entry that imports a .tsx component to the automatic JSX runtime and measures the view', async () => { + const root = await createProject(reactView); + const { outDir, result } = await compile(root, [app(root)]); + const html = await emittedHtml(outDir); + // Without the React plugin on a `.ts` entry the component lowered to a + // free `React.createElement` that no module has in scope. + expect(html).not.toMatch(/[^.\w]React\.createElement/u); + expect(html).toContain('panel-ready'); + expect(result.diagnostics).toEqual([]); + expect(result.apps).toHaveLength(1); + const [compiled] = result.apps; + expect(compiled!.size.bytes).toBe(Buffer.byteLength(html, 'utf8')); + expect(compiled!.size.bytes).toBeGreaterThan(0); + expect(compiled!.size.gzipBytes).toBeGreaterThan(0); + expect(compiled!.size.gzipBytes).toBeLessThan(compiled!.size.bytes); + }, 60_000); + + it('reports a syntax error as one AB4770 naming the file and position', async () => { + const root = await createProject({ 'views/status.ts': 'const x = ;\n' }); + const diagnostics = await compileFailure(compile(root, [app(root)])); + expect(diagnostics).toEqual([expect.objectContaining({ + ...compileErrorShape, + recovery: 'Fix the reported error in the named file and rebuild; run `agent-bundle build` for the full message.', + sourcePath: join(root, 'views', 'status.ts'), + })]); + const [diagnostic] = diagnostics; + expect(diagnostic!.message).toMatch(/^MCP App "status" failed to compile: views\/status\.ts:1:10: /u); + expect(diagnostic!.message).toContain('Syntax Error: Expression expected'); + // The miette frame is flattened: no box glyphs, no code-frame lines. + expect(diagnostic!.message).not.toMatch(/[×│·╭╰─]|\n/u); + }, 60_000); + + it('reports an unresolved import as one AB4770 at the import position', async () => { + const root = await createProject({ + 'views/status.ts': "import { nope } from './missing-module';\nconsole.log(nope);\n", + }); + const diagnostics = await compileFailure(compile(root, [app(root)])); + expect(diagnostics).toEqual([expect.objectContaining({ + ...compileErrorShape, + sourcePath: join(root, 'views', 'status.ts'), + })]); + expect(diagnostics[0]!.message).toMatch(/^MCP App "status" failed to compile: views\/status\.ts:1:1: /u); + expect(diagnostics[0]!.message).toContain("Module not found: Can't resolve './missing-module'"); + }, 60_000); + + it('attributes a compilation error that names no module to the App entry', async () => { + const root = await createProject({ 'views/status.ts': "document.body.textContent = 'ok';\n" }); + // Rsbuild reports a favicon it cannot read as a compilation error without + // a module, the same shape as a tsconfig the resolver cannot load. + const tools: AgentBundleToolsConfig = { rsbuild: { html: { favicon: './assets/missing.ico' } } }; + const diagnostics = await compileFailure(compile(root, [app(root)], { tools })); + expect(diagnostics).toEqual([expect.objectContaining({ + ...compileErrorShape, + message: `MCP App "status" failed to compile: [rsbuild:html] Failed to read the favicon file at ${join(root, 'assets', 'missing.ico')}.`, + sourcePath: join(root, 'views', 'status.ts'), + })]); + }, 60_000); + + it('reports a tsconfig the resolver cannot load as one AB4770 naming it, attributed to the App entry', async () => { + const root = await createProject({ + 'tsconfig.json': `${JSON.stringify({ extends: './does-not-exist.json' })}\n`, + 'views/status.ts': "document.body.textContent = 'ok';\n", + }); + const diagnostics = await compileFailure(compile(root, [app(root)])); + expect(diagnostics).toEqual([expect.objectContaining({ + ...compileErrorShape, + sourcePath: join(root, 'views', 'status.ts'), + })]); + expect(diagnostics[0]!.message).toMatch(/^MCP App "status" failed to compile: /u); + expect(diagnostics[0]!.message).toContain(`Tsconfig not found ${join(root, 'does-not-exist.json')}`); + }, 60_000); + + it('resolves the reserved agent-bundle/meta specifier ahead of a consumer tsconfig paths entry that shadows it', async () => { + const root = await createProject({ + 'stub-meta.ts': "export const name = 'SHADOWED';\n", + 'tsconfig.json': `${JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { 'agent-bundle/meta': ['./stub-meta.ts'] } }, + })}\n`, + 'views/status.ts': "import { name } from 'agent-bundle/meta';\ndocument.body.textContent = `identity:${name}`;\n", + }); + const { outDir } = await compile(root, [app(root)]); + const html = await emittedHtml(outDir); + expect(html).toContain(meta.name); + expect(html).not.toContain('SHADOWED'); + }, 60_000); + + it('still resolves the author’s own tsconfig paths inside a view', async () => { + // Winning the reserved specifier must not cost the author their `paths`: + // Rsbuild's `prefer-alias` strategy would drop them from the view compiler. + const root = await createProject({ + 'lib/greeting.ts': "export const greeting = 'paths-resolved';\n", + 'tsconfig.json': `${JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@lib/*': ['./lib/*'] } }, + })}\n`, + 'views/status.ts': "import { greeting } from '@lib/greeting';\ndocument.body.textContent = greeting;\n", + }); + const { outDir } = await compile(root, [app(root)]); + expect(await emittedHtml(outDir)).toContain('paths-resolved'); + }, 60_000); + + it('ships document defaults for a template-less view and leaves an authored template alone', async () => { + const root = await createProject({ + 'views/status.ts': "document.body.textContent = 'ok';\n", + 'views/panel.ts': "document.querySelector('#view')!.textContent = 'ok';\n", + 'views/panel.html': 'My Panel
\n', + 'views/shell.ts': "document.querySelector('#view')!.textContent = 'ok';\n", + 'views/shell.html': '
\n', + }); + const { outDir, result } = await compile(root, [ + app(root), + app(root, { name: 'panel', source: 'views/panel.ts', template: 'views/panel.html' }), + app(root, { name: 'shell', source: 'views/shell.ts', template: 'views/shell.html' }), + ]); + expect(result.apps.map((compiled) => compiled.name)).toEqual(['status', 'panel', 'shell']); + expect(result.diagnostics).toEqual([]); + + const status = await emittedHtml(outDir); + expect(status).toMatch(//u); + expect(status.match(/[^<]*<\/title>/gu)).toEqual(['<title>status']); + expect(status).toContain('
'); + + // An authored language and title are the author's. + const panel = await emittedHtml(outDir, 'panel'); + expect(panel).toMatch(//u); + expect(panel.match(/[^<]*<\/title>/gu)).toEqual(['<title>My Panel']); + expect(panel).toContain('
'); + expect(panel).not.toContain('id="root"'); + + // A template that set neither gets the defaults without losing its own body. + const shell = await emittedHtml(outDir, 'shell'); + expect(shell).toMatch(//u); + expect(shell.match(/[^<]*<\/title>/gu)).toEqual(['<title>shell']); + expect(shell).toContain('
'); + }, 60_000); + + it('keeps development output readable and one self-contained file, with inline source maps as an opt-in', async () => { + const files = { + 'views/helper.ts': 'export function veryLongIdentifierName(): number { return 1; }\n', + 'views/status.ts': [ + "import { veryLongIdentifierName } from './helper';", + "document.body.textContent = String(veryLongIdentifierName());", + '', + ].join('\n'), + }; + const development = await createProject(files); + const { outDir, result } = await compile(development, [app(development)], { mode: 'development' }); + const html = await emittedHtml(outDir); + expect(html).toContain('function veryLongIdentifierName()'); + // Rspack's module markers make the readable output navigable. + expect(html).toContain('// CONCATENATED MODULE: ./views/helper.ts'); + // No map by default: one carrying the sources is ~7× the production + // bytes, past the host bound for any real view. + expect(html).not.toContain('sourceMappingURL'); + expect(await readdir(join(outDir, 'mcp-apps'))).toEqual(['status.html']); + expect(await readdir(outDir)).toEqual(['mcp-apps']); + expect(result.diagnostics).toEqual([]); + + const mapped = await createProject(files); + const { outDir: mappedOutDir } = await compile(mapped, [app(mapped)], { + mode: 'development', + tools: { rsbuild: { output: { sourceMap: { css: false, js: 'inline-source-map' } } } }, + }); + expect(await emittedHtml(mappedOutDir)).toContain('sourceMappingURL=data:application/json'); + expect(await readdir(mappedOutDir)).toEqual(['mcp-apps']); + expect(await readdir(join(mappedOutDir, 'mcp-apps'))).toEqual(['status.html']); + + const production = await createProject(files); + const { outDir: productionOutDir } = await compile(production, [app(production)]); + const minified = await emittedHtml(productionOutDir); + expect(minified).not.toContain('sourceMappingURL'); + expect(minified).not.toContain('function veryLongIdentifierName()'); + expect(minified).not.toContain('CONCATENATED MODULE'); + }, 60_000); + + it('refuses external source-map files, naming the stray output', async () => { + const root = await createProject({ 'views/status.ts': "document.body.textContent = 'ok';\n" }); + await expect(compile(root, [app(root)], { + mode: 'development', + tools: { rsbuild: { output: { sourceMap: { css: false, js: 'source-map' } } } }, + })).rejects.toThrow(/beyond the stable self-contained MCP App HTML output: static\/js\/status\.js\.map\. Only inline source maps/u); + }, 60_000); + + /** + * Exported functions nobody imports: readable output keeps every one (long + * names, comments and all — about 3 MiB for 16,000), the production + * minifier drops them all. + */ + const readableOnlyPadding = Array.from({ length: 16_000 }, (_, index) => [ + `/** Padding function number ${String(index)} that the readable build keeps and the minifier removes. */`, + `export function paddingFunctionWithAVeryLongName${String(index)}(): string { return 'padding-${String(index)}'; }`, + ].join('\n')).join('\n'); + + /** + * A string literal of `bytes` that no profile can shrink: the same size in + * readable and production output — as long as the view uses the string + * itself; the minifier folds `blob.length` to a number and drops it. + */ + const incompressibleModule = (bytes: number): string => `export const blob = '${'x'.repeat(bytes)}';\n`; + + it('falls back to the production profile in development when the readable output would not render in the hosts', async () => { + const files = { + 'views/padding.ts': `${readableOnlyPadding}\nexport const marker = 'fallback-ready';\n`, + 'views/status.ts': "import { marker } from './padding';\ndocument.body.textContent = marker;\n", + }; + const root = await createProject(files); + const { outDir, result } = await compile(root, [app(root)], { mode: 'development' }); + const html = await emittedHtml(outDir); + expect(html).toContain('fallback-ready'); + expect(html).not.toContain('CONCATENATED MODULE'); + expect(Buffer.byteLength(html, 'utf8')).toBeLessThanOrEqual(MAX_APP_HTML_BYTES); + expect(result.apps[0]!.size.bytes).toBe(Buffer.byteLength(html, 'utf8')); + expect(result.apps[0]!.output).toBe(join(outDir, 'mcp-apps', 'status.html')); + expect(await readdir(outDir)).toEqual(['mcp-apps']); + expect(result.diagnostics).toEqual([expect.objectContaining({ + code: 'AB4772', + recovery: 'The preview shows the minified production build; trim the view to read its source in the Workbench.', + severity: 'warning', + sourcePath: join(root, 'views', 'status.ts'), + })]); + // The substitution notice names where the readable bytes came from. + expect(result.diagnostics[0]!.message).toMatch( + /^MCP App "status" readable development output compiled to \d+(?:\.\d)? MiB, above the 2 MiB bound the Workbench and serve-app hosts accept; the preview renders the production build \(\d+ B, \d+ B gzip\) instead; largest modules: views\/padding\.ts \(\d+(?:\.\d)? MiB\), /u, + ); + expect(result.diagnostics[0]!.message).toContain('views/status.ts ('); + }, 120_000); + + it('reports one AB4772 for a substituted view whose production build itself draws the advisory', async () => { + // Readable output: padding plus the blob, past 2 MiB. Production: the + // blob alone, between the 1 MiB advisory and the 2 MiB host bound. + const files = { + 'views/blob.ts': incompressibleModule(1_200_000), + 'views/padding.ts': `${readableOnlyPadding}\nexport const marker = 'fallback-ready';\n`, + 'views/status.ts': "import { blob } from './blob';\nimport { marker } from './padding';\ndocument.title = marker;\ndocument.body.textContent = blob;\n", + }; + const root = await createProject(files); + const { outDir, result } = await compile(root, [app(root)], { mode: 'development' }); + const bytes = Buffer.byteLength(await emittedHtml(outDir), 'utf8'); + expect(bytes).toBeGreaterThanOrEqual(MCP_APP_HTML_ADVISORY_BYTES); + expect(bytes).toBeLessThanOrEqual(MAX_APP_HTML_BYTES); + expect(result.apps[0]!.size.bytes).toBe(bytes); + // Exactly one size diagnostic: the substitution notice, not also the + // production compile's own 1 MiB advisory for the same view. + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['AB4772']); + expect(result.diagnostics[0]!.message).toMatch( + /^MCP App "status" readable development output compiled to \d+(?:\.\d)? MiB, above the 2 MiB bound the Workbench and serve-app hosts accept; the preview renders the production build \(1\.1 MiB, \d+(?:\.\d)? KiB gzip\) instead; largest modules: views\/padding\.ts \(\d+(?:\.\d)? MiB\), views\/blob\.ts \(1\.1 MiB\)/u, + ); + }, 120_000); + + it('does not claim a substitution when the production build would not render in the hosts either', async () => { + const files = { + 'views/blob.ts': incompressibleModule(2_200_000), + 'views/status.ts': "import { blob } from './blob';\ndocument.body.textContent = blob;\n", + }; + const root = await createProject(files); + const { outDir, result } = await compile(root, [app(root)], { mode: 'development' }); + const html = await emittedHtml(outDir); + // The smaller production document is what lands, and it still does not fit. + expect(html).not.toContain('CONCATENATED MODULE'); + expect(Buffer.byteLength(html, 'utf8')).toBeGreaterThan(MAX_APP_HTML_BYTES); + expect(result.apps[0]!.size.bytes).toBe(Buffer.byteLength(html, 'utf8')); + expect(result.diagnostics).toEqual([expect.objectContaining({ + code: 'AB4772', + recovery: 'Trim the largest modules listed and rebuild; the Workbench and serve-app hosts refuse a view above 2 MiB.', + severity: 'warning', + sourcePath: join(root, 'views', 'status.ts'), + })]); + // The plain host-bound advisory for the production bytes — no "the preview renders" claim. + expect(result.diagnostics[0]!.message).toMatch( + /^MCP App "status" compiled to 2\.1 MiB \(\d+(?:\.\d)? KiB gzip\), above the 2 MiB bound the Workbench and serve-app hosts accept — the view will not render there; largest modules: views\/blob\.ts \(2\.1 MiB\), /u, + ); + expect(result.diagnostics[0]!.message).toContain('views/status.ts ('); + expect(result.diagnostics[0]!.message).not.toContain('preview renders'); + }, 120_000); + + it('names the largest authored modules in a production advisory, concatenated or not', async () => { + // Concatenation folds the author's ESM modules into the entry; the + // advisory still has to name the part that carries the bytes. + const files = { + 'views/blob.ts': incompressibleModule(1_200_000), + 'views/status.ts': "import { blob } from './blob';\ndocument.body.textContent = blob;\n", + }; + const root = await createProject(files); + const { result } = await compile(root, [app(root)]); + expect(result.apps[0]!.size.bytes).toBeGreaterThanOrEqual(MCP_APP_HTML_ADVISORY_BYTES); + expect(result.diagnostics).toEqual([expect.objectContaining({ code: 'AB4772', severity: 'warning' })]); + expect(result.diagnostics[0]!.message).toMatch( + /^MCP App "status" compiled to 1\.1 MiB \(\d+(?:\.\d)? KiB gzip\), above the 1 MiB advisory bound; largest modules: views\/blob\.ts \(1\.1 MiB\), views\/status\.ts \(\d+ B\)/u, + ); + }, 60_000); +}); + +describe('composeMcpAppsRsbuildConfig', () => { + const source = Object.freeze({ name: 'status', source: '/project/views/status.ts', template: undefined }); + const options = { cwd: '/project', meta, outDir: '/staged/portable' }; + + it('keeps the production profile and only overlays readability in development', () => { + const production = composeMcpAppsRsbuildConfig([source], options); + expect(production.mode).toBe('production'); + expect(production.output?.sourceMap).toBe(false); + expect(production.output?.minify).toBeUndefined(); + // Rsbuild's default alias strategy stays so a view resolves through the + // author's tsconfig `paths`; the reserved specifier wins by replacement. + expect(production.resolve).toBeUndefined(); + expect(production.environments?.status?.html).toEqual({ inject: 'body', mountId: 'root', title: 'status' }); + + const development = composeMcpAppsRsbuildConfig([source], { ...options, mode: 'development' }); + expect(development.mode).toBe('production'); + expect(development.output?.sourceMap).toBe(false); + expect(development.output?.minify).toBe(false); + expect(development.output?.inlineScripts).toBe(true); + }); +}); diff --git a/packages/agent-bundle/tests/rspack-stats-errors.test.ts b/packages/agent-bundle/tests/rspack-stats-errors.test.ts new file mode 100644 index 000000000..b499cd667 --- /dev/null +++ b/packages/agent-bundle/tests/rspack-stats-errors.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from '@rstest/core'; +import type { Rspack } from '@rsbuild/core'; + +import { + describeRspackStatsError, + formatRspackStatsError, + normalizeStatsMessage, + rspackStatsErrors, + statsErrorFile, + statsErrorLocation, +} from '../src/build/rspack-stats-errors.ts'; + +const statsError = (overrides: Partial & { readonly message: string }): Rspack.StatsError => ({ ...overrides }); + +// Captured from Rspack 2.2.1 (`stats.toJson({ all: false, errors: true, children: true, moduleTrace: true })`). +const swcSyntaxError = statsError({ + code: 'ModuleBuildError', + message: ' × Module build failed (from builtin:swc-loader):\n ╰─▶ × Syntax Error: Expression expected\n ╭────\n 1 │ const x = ;\n · ─\n ╰────\n \n', + moduleIdentifier: 'builtin:swc-loader??ruleSet[1].rules[2].oneOf[3].use[0]!/project/views/status.ts', + moduleName: './views/status.ts', +}); + +const unresolvedImportError = statsError({ + loc: '1:1-41', + message: " × Module not found: Can't resolve './missing-module' in '/project/views'\n ╭─[1:0]\n 1 │ import { nope } from './missing-module';\n · ────────────────────────────────────────\n 2 │ console.log(nope);\n ╰────\n", + moduleIdentifier: 'builtin:swc-loader??ruleSet[1].rules[2].oneOf[3].use[0]!/project/views/status.ts', + moduleName: './views/status.ts', +}); + +describe('Rspack stats errors', () => { + it('flattens an Rspack message to one line of prose', () => { + expect(normalizeStatsMessage(swcSyntaxError.message)) + .toBe('Module build failed (from builtin:swc-loader): Syntax Error: Expression expected'); + expect(normalizeStatsMessage(unresolvedImportError.message)) + .toBe("Module not found: Can't resolve './missing-module' in '/project/views'"); + expect(normalizeStatsMessage(' × Tsconfig not found /project/does-not-exist.json\n')) + .toBe('Tsconfig not found /project/does-not-exist.json'); + expect(normalizeStatsMessage(' ⚠ Critical dependency: the request of a dependency is an expression\n')) + .toBe('Critical dependency: the request of a dependency is an expression'); + expect(normalizeStatsMessage('\u001b[31mfailed\u001b[39m badly\r\n\n \u001b[2mdetail\u001b[22m')) + .toBe('failed badly detail'); + }); + + it("locates an entry from Rspack's loc, else the miette header, else the caret under the code frame", () => { + expect(statsErrorLocation(unresolvedImportError)).toEqual({ column: 1, line: 1 }); + expect(statsErrorLocation(statsError({ loc: '12:5', message: '' }))).toEqual({ column: 5, line: 12 }); + expect(statsErrorLocation(statsError({ loc: '4:1-27', message: '' }))).toEqual({ column: 1, line: 4 }); + expect(statsErrorLocation(statsError({ + message: ' × Syntax Error: Expression expected\n ╭─[2:10]\n 1 │ export const a = 1;\n 2 │ const x = ;\n · ─\n ╰────\n', + }))).toEqual({ column: 10, line: 2 }); + expect(statsErrorLocation(statsError({ message: ' × Syntax Error\n ╭─[views/status.ts:3:4]\n' }))).toEqual({ column: 4, line: 3 }); + // miette omits the header when the span starts on the first line. + expect(statsErrorLocation(swcSyntaxError)).toEqual({ column: 10, line: 1 }); + expect(statsErrorLocation(statsError({ message: ' × Tsconfig not found /project/does-not-exist.json\n' }))).toBeUndefined(); + // A malformed loc falls through to the message, and an empty one to nothing. + expect(statsErrorLocation(statsError({ loc: 'somewhere', message: ' × plain\n' }))).toBeUndefined(); + }); + + it('resolves the module like Rsbuild does: file, then module name, then the loader chain target', () => { + expect(statsErrorFile(statsError({ file: 'views/a.ts', message: '', moduleName: './views/b.ts' }), '/project')).toBe('/project/views/a.ts'); + expect(statsErrorFile(swcSyntaxError, '/project')).toBe('/project/views/status.ts'); + expect(statsErrorFile(statsError({ + message: '', + moduleIdentifier: 'builtin:swc-loader??ruleSet[1].rules[2].oneOf[3].use[0]!/elsewhere/views/status.ts?raw', + }), '/project')).toBe('/elsewhere/views/status.ts'); + expect(statsErrorFile(statsError({ message: '', moduleIdentifier: '/project/views/a.css!=!builtin:lightningcss-loader!/project/views/a.css' }), '/project')) + .toBe('/project/views/a.css'); + expect(statsErrorFile(statsError({ file: '', message: '' }), '/project')).toBeUndefined(); + expect(statsErrorFile(statsError({ message: 'no module' }), '/project')).toBeUndefined(); + }); + + it('describes and formats an entry as file:line:column: message, project-relative', () => { + expect(describeRspackStatsError(unresolvedImportError, '/project')).toEqual({ + file: '/project/views/status.ts', + location: { column: 1, line: 1 }, + message: "Module not found: Can't resolve './missing-module' in '/project/views'", + }); + expect(formatRspackStatsError(swcSyntaxError, '/project')) + .toBe('views/status.ts:1:10: Module build failed (from builtin:swc-loader): Syntax Error: Expression expected'); + // No location: the file alone. No module: the message alone. Outside the root: absolute. + expect(formatRspackStatsError(statsError({ message: ' × Something failed\n', moduleName: './views/c.ts' }), '/project')) + .toBe('views/c.ts: Something failed'); + expect(formatRspackStatsError(statsError({ message: '\u001B[31m × Tsconfig not found: ./does-not-exist.json\u001B[0m\n' }), '/project')) + .toBe('Tsconfig not found: ./does-not-exist.json'); + expect(formatRspackStatsError(statsError({ message: ' × Module build failed\n', moduleName: '../shared/lib.ts' }), '/project')) + .toBe('/shared/lib.ts: Module build failed'); + }); + + it('reads a MultiStats document once and falls back to the children when the top level lists nothing', () => { + const multi: Rspack.StatsCompilation = { + children: [ + { errors: [swcSyntaxError], name: 'a' }, + { errors: [unresolvedImportError], name: 'b' }, + ], + errors: [swcSyntaxError, unresolvedImportError], + }; + expect(rspackStatsErrors(multi)).toEqual([swcSyntaxError, unresolvedImportError]); + expect(rspackStatsErrors({ children: multi.children, errors: [] })).toEqual([swcSyntaxError, unresolvedImportError]); + expect(rspackStatsErrors({ children: [{ children: [{ errors: [swcSyntaxError] }] }] })).toEqual([swcSyntaxError]); + expect(rspackStatsErrors({})).toEqual([]); + }); +}); diff --git a/packages/workbench/tests/runtime-playground-hmr.e2e.test.ts b/packages/workbench/tests/runtime-playground-hmr.e2e.test.ts index 4848fe727..b94b12ce1 100644 --- a/packages/workbench/tests/runtime-playground-hmr.e2e.test.ts +++ b/packages/workbench/tests/runtime-playground-hmr.e2e.test.ts @@ -317,7 +317,10 @@ e2e('activates an edited RSC generation and replays the selected hook without re const sourceBuildDiagnostic = page.getByLabel('Runtime diagnostics evidence'); await expect(sourceBuildDiagnostic).toContainText('source/build', { timeout: browserTimeout }); await expect(sourceBuildDiagnostic).toContainText('AB8206', { timeout: browserTimeout }); - await expect(sourceBuildDiagnostic).toContainText('RSC runtime source build failed.', { timeout: browserTimeout }); + // #572: the diagnostic carries the Rspack error with the failing file and + // line instead of a fixed sentence. + await expect(sourceBuildDiagnostic).toContainText('RSC runtime source build failed:', { timeout: browserTimeout }); + await expect(sourceBuildDiagnostic).toContainText('src/rsc/components.tsx:', { timeout: browserTimeout }); const afterSourceBuildFailure = await identity.evaluate((element) => Object.fromEntries([...element.attributes] .filter((attribute) => attribute.name.startsWith('data-runtime-')) .map((attribute) => [attribute.name, attribute.value]))); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 27456efe0..ec7691837 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -59,6 +59,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/integration-matrix.test.ts', 'packages/agent-bundle/tests/layout-build.test.ts', 'packages/agent-bundle/tests/lifecycle-replay-dev-server.test.ts', + 'packages/agent-bundle/tests/mcp-apps-compile.test.ts', 'packages/agent-bundle/tests/mcp-probe-dev-server.test.ts', 'packages/agent-bundle/tests/mcp-session-service.test.ts', 'packages/agent-bundle/tests/mcp.test.ts', diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 0b48b2d29..79645850b 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -710,6 +710,106 @@ silently at run time. An App declared on a *prebuilt* server stays a development surface: the Workbench compiles it live, and the build assumes the payload already serves the resource. +### The compiled document + +Every App compiles to exactly one self-contained HTML file, `mcp-apps/.html` in each +selected target, with its scripts and styles inlined. An App without a `template` gets the +framework shell: ``, a `` equal to the App name, the charset and viewport +`<meta>` tags, and the mount point `<div id="root"></div>`. The entry renders into that node: + +```ts +// src/mcp/status/apps/status.ts — a .ts entry; the JSX lives in the .tsx it imports. +import type { AppRouteConfig } from 'agent-bundle'; + +import { mountStatusPanel } from './StatusPanel.tsx'; + +export const config = { + resourceUri: 'ui://mcp-app-example/status.html', +} satisfies AppRouteConfig; + +const root = document.getElementById('root'); +if (root === null) throw new Error('The App document has no #root mount point.'); +mountStatusPanel(root); +``` + +A `template` replaces that shell entirely, so the template owns the mount id, the title, and the +`lang` attribute. The framework only fills in `lang="en"` on `<html>` when the template declares +no `lang`, and a `<title>` equal to the App name when it has no `<title>`; a template that sets +them is left alone. + +JSX compiles in every App build regardless of the entry's extension: `@rsbuild/plugin-react` with +the automatic runtime (`react/jsx-runtime`, fast refresh off) is registered on every App +environment, so the `<StatusPanel />` that `StatusPanel.tsx` renders compiles the same way behind +a `.ts` entry as behind a `.tsx` one — never to a `React.createElement` call that would need a +`React` import in scope. JSX syntax itself still belongs in `.tsx`/`.jsx` files, because the +bundler detects a module's syntax from its extension. + +### Reserved specifiers win + +Your `tsconfig.json` `paths` work inside a view (`@lib/*`, `@/components/*`, and so on): +Rsbuild hands them to the bundler as usual, where they resolve ahead of every alias. The one +specifier that escapes them is `agent-bundle/meta`: the compiler rewrites that exact import to +the generated identity module before resolution starts, so a `paths` mapping for +`agent-bundle/meta` has no effect inside a compiled view — the framework's identity is what the +App imports, in every build, with every tsconfig. The resolved-config assertion refuses a build +where a `tools` hatch removed that rewrite. + +### Compile errors and size + +The bundler's own log output is silenced while views compile; the framework reads the Rspack +stats instead and reports each error as one `AB4770` diagnostic carrying the failing file, its +`line:column`, and the bundler's message (`sourcePath` is the failing file): + +```text +MCP App "status" failed to compile: views/status.ts:1:10: Module build failed + (from builtin:swc-loader): Syntax Error: Expression expected +``` + +`agent-bundle build` exits `1` and writes those diagnostics to stderr — never the `AB5000` +catch-all. Warnings that did not fail the build are `AB4771` in the same shape; they print as +`AB4771 (warning): …` lines before `Built …` and ride `--json` as `diagnostics` and +`build.diagnostics`. The +[Diagnostics reference](../../reference/diagnostics.md#mcp-app-view-compilation-ab4770ab4772) +lists every shape, including the location-less form for a `tsconfig.json` problem. + +A successful build prints one line per App after `Built …`, sorted by target and App name, with +the emitted size and its gzip size (`build.compiledMcpApps[].size.bytes` and `.gzipBytes` in +`--json`): + +```text +Built mcp-app-example to …/mcp-app-example/artifact +MCP App status (portable): mcp-apps/status.html 427.1 KiB (102.1 KiB gzip) +``` + +That example is close to the floor. Any view that imports `@modelcontextprotocol/ext-apps` +carries `zod` (v3 and v4), `@modelcontextprotocol/sdk`, `zod-to-json-schema`, and `ext-apps` +itself — about 437 kB (104 kB gzip) before the first line of your own code — so a healthy view +sits well under 1 MiB. A production build that emits 1 MiB or more reports the `AB4772` +advisory naming the five largest modules; any build over 2 MiB reports it too, because 2 MiB is +the bound above which the Workbench and `serve-app` hosts refuse the resource and the view stops +rendering there. The thresholds are fixed; no config key moves them. + +### Development vs production builds + +`agent-bundle dev` (the Workbench) compiles views unminified, so the preview's source is +readable in the browser devtools: real identifiers, and one +`// CONCATENATED MODULE: ./src/mcp/status/apps/StatusPanel.tsx` marker per module. The output is +still exactly one self-contained HTML document per App, about 2.7× the production size (a +617 KiB `ext-apps` view becomes 1.6 MiB), which is why the 1 MiB advisory does not apply to +development builds while the 2 MiB host bound still does. A view whose readable document would +cross that bound is recompiled with the production profile so the preview keeps rendering it, +and `AB4772` says so. `agent-bundle build` ships the production profile: minified, no source +maps. + +Neither profile emits a source map by default: a map that carries the original sources is +another ~7× on top (that same view would reach 4.2 MiB), past what the hosts accept. For a +small view, opt in through the escape hatch — +`tools.rsbuild = { output: { sourceMap: { js: 'inline-source-map' } } }` — which, like every +`tools` fragment, reaches every synthesized config. Only inline maps work: an external `.map` +file beside the HTML fails the build with an error naming the stray file, because the artifact +must stay one self-contained document per App. `agent-bundle inspect --bundler` prints the +resolved App configuration when a hatch value does not land where you expected. + ### Serving an App standalone Outside an MCP host, an App is normally reached through the Workbench MCP page. When a plugin diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index 139f95b74..1e0e3f8d8 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -259,7 +259,7 @@ customizes *how code compiles*, never *what the artifact promises*. The hatch merges *beside* the framework profile: `plugins` arrays concatenate, and Rsbuild never dedupes plugins by name. Re-adding a plugin the framework already registers — `@rsbuild/plugin-react` -(`rsbuild:react`), which every synthesized entry and every React-syntax MCP App view carries — +(`rsbuild:react`), which every synthesized entry and every MCP App view carries — through `tools.rsbuild.plugins` would run it twice, so `agent-bundle validate` reports it as `AB4724` naming the plugin and its package. Remove the entry; the framework registers it for you. diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 770c2172a..9be7c43fc 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -38,6 +38,16 @@ These are contracts, not defaults: | Evals | Eval runs and run comparisons. | | Logs | Concise events plus raw stdout, stderr, and protocol streams, grouped by producer: normalization, build, diagnostics, MCP, hook, host trial, and grader. | +A failed MCP App view compile is a build failure like any other. The Overview **Diagnostics** +table shows one `AB4770` row per Rspack error: the Message column carries +`MCP App "<name>" failed to compile: <file>:<line>:<column>: <message>` and the Source column the +failing file, in place of the former +`AB7100 "Unable to compile the build: Rspack build failed."` row whose only source was the config +file. Logs records the same diagnostics — code and source path — under the `build.failed` entry. +No `artifact.available` follows, so the last good epoch stays active and every bound MCP session +keeps serving it; save a fix and the next rebuild publishes. Compile warnings (`AB4771`) and the +size advisory (`AB4772`) ride the succeeded epoch's diagnostics the same way. + ## MCP sessions bind to an epoch A Workbench MCP session binds `{ epochId, target, serverName }` when it is opened and never moves @@ -46,8 +56,12 @@ came from one generated server built from one set of inputs. - **Restart MCP session** respawns that generated server on its *selected* epoch. - To use a newly published epoch, open a **new** session. -- Compatible MCP Apps preview through the same bound session. The same host stack serves one App - standalone in a plain browser tab through `agent-bundle serve-app`; see +- Compatible MCP Apps preview through the same bound session. The dev loop compiles views + unminified, so the browser devtools show readable App source; `agent-bundle build` ships them + minified (see + [Development vs production builds](../authoring/mcp.mdx#development-vs-production-builds)). + The same host stack serves one App standalone in a plain browser tab through + `agent-bundle serve-app`; see [Serving an App standalone](../authoring/mcp.mdx#serving-an-app-standalone). ## Standalone MCP Inspector diff --git a/website/docs/en/reference/api.mdx b/website/docs/en/reference/api.mdx index 49efb0768..3f4423758 100644 --- a/website/docs/en/reference/api.mdx +++ b/website/docs/en/reference/api.mdx @@ -14,7 +14,7 @@ Every public entry point is documented from its declarations: | Entry point | Contents | | --- | --- | | `agent-bundle` | The authoring and orchestration surface: `defineSkill`, `canonicalAgentEvents`, `startDevServer`, `runEvals`, `compareEvals`, the eval harness factories, and the artifact-manifest helpers. | -| `agent-bundle/api` | The programmatic compiler: `build`, `validate`, `inspect`, `prepack`, their option and result types, the `AgentComponentKind` / `componentKindCapability` component-kind helpers, and the artifact operations `listMcp`, `invokeMcp`, `runMcp`, `serveApp` (a built MCP App served standalone in a browser — a host-process API for scripts and tests; a routed CLI command inside the artifact uses `spawnServeApp` from `agent-bundle/serve-app-command` instead, see [Serving an App standalone](../guide/authoring/mcp.mdx#serving-an-app-standalone)), `listHooks`, and `simulateHook`. | +| `agent-bundle/api` | The programmatic compiler: `build`, `validate`, `inspect`, `prepack`, their option and result types, the `AgentComponentKind` / `componentKindCapability` component-kind helpers, and the artifact operations `listMcp`, `invokeMcp`, `runMcp`, `serveApp` (a built MCP App served standalone in a browser — a host-process API for scripts and tests; a routed CLI command inside the artifact uses `spawnServeApp` from `agent-bundle/serve-app-command` instead, see [Serving an App standalone](../guide/authoring/mcp.mdx#serving-an-app-standalone)), `listHooks`, and `simulateHook`; plus the Rspack stats formatters `rspackStatsErrors`, `describeRspackStatsError`, and `formatRspackStatsError`, which render a compile error as the same `<file>:<line>:<column>: <message>` line the compiler's `AB4770` carries, for tools that drive their own Rsbuild compile. | | `agent-bundle/config` | `defineConfig` and the configuration types. | | `agent-bundle/test` | The route-testing harness, matchers, and contract matrices. | | `agent-bundle/test/browser` | The MCP App bridge harness for browser-rendered views. | diff --git a/website/docs/en/reference/configuration.mdx b/website/docs/en/reference/configuration.mdx index 780b86d75..7c4cde548 100644 --- a/website/docs/en/reference/configuration.mdx +++ b/website/docs/en/reference/configuration.mdx @@ -193,12 +193,20 @@ project's. Never construct plugins or perform `instanceof` checks against an imp (`(config, { rspack }) => ...`), which always hands you the executing engine's own `rspack` object. +MCP App builds keep Rsbuild's default `resolve.aliasStrategy` (`prefer-tsconfig`), so the +project's `tsconfig.json` `paths` resolve inside a compiled view as they do anywhere else. The +reserved `agent-bundle/meta` specifier is the exception: the compiler rewrites that exact import +to the generated identity module before resolution starts, ahead of both `paths` and any +`resolve.alias`, so no `paths` mapping can shadow it. The resolved-config assertion refuses a +hatch value that removes that rewrite, as it refuses one that breaks the self-contained output. + ### Plugins The framework registers exactly one Rsbuild plugin in the configs it synthesizes: `@rsbuild/plugin-react` (plugin name `rsbuild:react`), on every artifact entry — routes are -authored as TSX and the plugin selects the automatic JSX runtime — and on every MCP App view whose -source is `.jsx`/`.tsx`. That set is `frameworkOwnedRsbuildPlugins` in +authored as TSX and the plugin selects the automatic JSX runtime — and on every MCP App view, +whatever its entry's extension (fast refresh off), so a `.ts` entry importing `.tsx` components +compiles to the same automatic runtime. That set is `frameworkOwnedRsbuildPlugins` in `packages/agent-bundle/src/build/framework-plugins.ts`; a unit test derives it from the synthesized configs, so it cannot drift, and `validateTools` reads the same map to report `AB4724` when a `tools.rsbuild.plugins` entry re-adds one of its names. diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 0f9aa6296..576b881d3 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -632,6 +632,93 @@ export default defineConfig({ 声明在*预构建*服务器上的 App 仍属于开发期表面:Workbench 会实时编译它,而构建假定 payload 已经在 提供该资源。 +### 编译出的文档 + +每个 App 都编译为恰好一个自包含的 HTML 文件——在每个被选中的 target 下为 `mcp-apps/<name>.html`—— +脚本与样式全部内联。没有 `template` 的 App 会得到框架外壳:`<html lang="en">`、等于 App 名称的 +`<title>`、charset 与 viewport 的 `<meta>` 标签,以及挂载点 `<div id="root"></div>`。入口渲染到该 +节点上: + +```ts +// src/mcp/status/apps/status.ts —— 一个 .ts 入口;JSX 位于它导入的 .tsx 中。 +import type { AppRouteConfig } from 'agent-bundle'; + +import { mountStatusPanel } from './StatusPanel.tsx'; + +export const config = { + resourceUri: 'ui://mcp-app-example/status.html', +} satisfies AppRouteConfig; + +const root = document.getElementById('root'); +if (root === null) throw new Error('The App document has no #root mount point.'); +mountStatusPanel(root); +``` + +`template` 会完全替换这个外壳,因此模板拥有挂载 id、标题与 `lang` 属性。框架只在模板未声明 `lang` +时为 `<html>` 补上 `lang="en"`,并在模板没有 `<title>` 时插入一个等于 App 名称的 `<title>`;自己 +设置了这两项的模板不会被改动。 + +无论入口的扩展名是什么,每次 App 构建都会编译 JSX:带自动运行时(`react/jsx-runtime`,关闭 fast +refresh)的 `@rsbuild/plugin-react` 注册在每个 App 环境上,因此 `StatusPanel.tsx` 渲染的 +`<StatusPanel />` 在 `.ts` 入口之后与在 `.tsx` 入口之后的编译方式完全相同——绝不会变成需要作用域内 +有 `React` 导入的 `React.createElement` 调用。JSX 语法本身仍然只能写在 `.tsx`/`.jsx` 文件里,因为 +打包器按扩展名识别模块的语法。 + +### 保留标识符优先 + +你的 `tsconfig.json` `paths` 在视图内照常生效(`@lib/*`、`@/components/*` 等):Rsbuild 会像往常 +一样把它们交给打包器,并在所有别名之前解析。唯一不受它们影响的标识符是 `agent-bundle/meta`:编译器 +会在解析开始之前就把这个精确的导入改写为生成的身份模块,因此针对 `agent-bundle/meta` 的 `paths` +映射在编译出的视图内不起作用——无论哪次构建、无论哪份 tsconfig,App 导入的都是框架的身份模块。 +若某个 `tools` 逃生舱移除了这一改写,已解析配置断言会拒绝该构建。 + +### 编译错误与体积 + +视图编译期间,打包器自己的日志输出被静音;框架改为读取 Rspack 的 stats,并把每个错误报告为一条 +`AB4770` 诊断,携带出错文件、其 `line:column` 以及打包器的消息(`sourcePath` 即出错文件): + +```text +MCP App "status" failed to compile: views/status.ts:1:10: Module build failed + (from builtin:swc-loader): Syntax Error: Expression expected +``` + +`agent-bundle build` 以退出码 `1` 结束并把这些诊断写到 stderr——绝不会是 `AB5000` 兜底码。未导致 +构建失败的警告是同样形状的 `AB4771`;它们以 `AB4771 (warning): …` 行打印在 `Built …` 之前,并在 +`--json` 中同时出现在 `diagnostics` 与 `build.diagnostics` 里。 +[诊断参考](../../reference/diagnostics.md#mcp-app-view-compilation-ab4770ab4772)列出了每种形状, +包括 `tsconfig.json` 问题那种不带位置的形式。 + +成功的构建会在 `Built …` 之后为每个 App 打印一行,按 target 与 App 名称排序,给出输出体积及其 +gzip 体积(`--json` 中为 `build.compiledMcpApps[].size.bytes` 与 `.gzipBytes`): + +```text +Built mcp-app-example to …/mcp-app-example/artifact +MCP App status (portable): mcp-apps/status.html 427.1 KiB (102.1 KiB gzip) +``` + +这个例子已接近下限。任何导入 `@modelcontextprotocol/ext-apps` 的视图都会带上 `zod`(v3 与 v4)、 +`@modelcontextprotocol/sdk`、`zod-to-json-schema` 以及 `ext-apps` 本身——在你写下第一行自己的代码 +之前就约有 437 kB(gzip 后 104 kB)——因此一个健康的视图远在 1 MiB 以下。生产构建的输出达到或超过 +1 MiB 时会报告 `AB4772` 建议性诊断并点名最大的五个模块;任何超过 2 MiB 的构建也会报告它,因为 +2 MiB 是 Workbench 与 `serve-app` 宿主拒绝该资源的上限,超过后视图在那里将不再渲染。阈值是固定的; +没有配置键可以改变它们。 + +### 开发构建与生产构建 + +`agent-bundle dev`(Workbench)以未压缩的方式编译视图,因此预览的源码在浏览器开发者工具中可读: +真实的标识符,以及每个模块一条 `// CONCATENATED MODULE: ./src/mcp/status/apps/StatusPanel.tsx` +标记。输出仍然是每个 App 恰好一份自包含 HTML 文档,约为生产体积的 2.7 倍(一个 617 KiB 的 +`ext-apps` 视图会变成 1.6 MiB),这正是 1 MiB 建议性诊断不适用于开发构建、而 2 MiB 宿主上限仍然 +适用的原因。可读文档会越过该上限的视图会改用生产配置重新编译,以便预览继续渲染它,并由 `AB4772` +说明这一点。`agent-bundle build` 交付生产配置:已压缩、无 source map。 + +两种配置默认都不输出 source map:携带原始源码的 map 会再增加约 7 倍(同一视图会达到 4.2 MiB), +超出宿主所能接受的范围。对于小视图,可通过逃生舱选择加入—— +`tools.rsbuild = { output: { sourceMap: { js: 'inline-source-map' } } }`——它与其他每个 `tools` +片段一样,会到达每一份合成配置。只有内联 map 可用:HTML 旁边的外部 `.map` 文件会让构建失败,并以一条 +点名该多余文件的错误结束,因为产物必须保持每个 App 一份自包含文档。当某个逃生舱取值没有落在你预期的 +位置时,`agent-bundle inspect --bundler` 会打印解析后的 App 配置。 + ### 独立提供 App 在 MCP 宿主之外,App 通常经由 Workbench 的 MCP 页面访问。当插件想要一条“打开仪表盘”的命令——从终端 diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index 0784eb299..7f75ff8fb 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -228,7 +228,7 @@ npx agent-bundle prepack --root . --output artifact --json 逃生舱是*并列*合并到框架配置旁边的:`plugins` 数组会拼接,而 Rsbuild 从不按名称去重插件。通过 `tools.rsbuild.plugins` 再次添加框架已经注册的插件——`@rsbuild/plugin-react`(`rsbuild:react`), -每个合成入口与每个使用 React 语法的 MCP App 视图都带着它——会让它运行两次,因此 `agent-bundle validate` +每个合成入口与每个 MCP App 视图都带着它——会让它运行两次,因此 `agent-bundle validate` 会以 `AB4724` 报告,并点名该插件及其包名。删除这一项即可;框架会替你注册它。 有一个引擎身份注意事项:产物脚本、MCP 入口、钩子包装层与包构建通过 Rslib 编译,MCP App 视图通过 diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index 397aad6c5..7fd4dd411 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -34,6 +34,15 @@ npx agent-bundle dev --root . --port 3100 --no-open | Evals | eval 运行与运行对比。 | | Logs | 按生产者分组的精简事件与原始 stdout、stderr、协议流:规范化、构建、诊断、MCP、钩子、宿主试验与 grader。 | +MCP App 视图编译失败与其他任何构建失败一样处理。Overview 的 **Diagnostics** 表格为每个 Rspack +错误显示一行 `AB4770`:Message 列携带 +`MCP App "<name>" failed to compile: <file>:<line>:<column>: <message>`,Source 列则是出错文件, +取代了过去那条只能以配置文件为来源的 +`AB7100 "Unable to compile the build: Rspack build failed."`。Logs 在 `build.failed` 条目下记录 +同样的诊断(代码与来源路径)。之后不会有 `artifact.available`,因此最后一个可用的 epoch 保持活跃,每个已绑定的 MCP +会话继续提供它;保存修复后,下一次重建即会发布。编译警告(`AB4771`)与体积建议(`AB4772`)以同样的 +方式随成功 epoch 的诊断一起出现。 + ## MCP 会话绑定到一个 epoch Workbench 的 MCP 会话在打开时绑定 `{ epochId, target, serverName }`,并且绝不会自动迁移到新的 @@ -41,8 +50,11 @@ epoch。这正是协议轨迹有意义的原因:其中每一帧都来自同一 - **Restart MCP session** 会在它*所选*的那个 epoch 上重新启动该生成式服务器。 - 要使用新发布的 epoch,请打开一个**新**会话。 -- 兼容的 MCP App 通过同一个已绑定会话预览。同一套宿主栈也能通过 `agent-bundle serve-app` 在一个普通 - 浏览器标签页里独立提供某个 App;见[独立提供 App](../authoring/mcp.mdx#独立提供-app)。 +- 兼容的 MCP App 通过同一个已绑定会话预览。开发循环以未压缩的方式编译视图,因此浏览器开发者工具能 + 显示可读的 App 源码;`agent-bundle build` 交付的则是压缩后的版本(见 + [开发构建与生产构建](../authoring/mcp.mdx#开发构建与生产构建))。同一套宿主栈也能通过 + `agent-bundle serve-app` 在一个普通浏览器标签页里独立提供某个 App;见 + [独立提供 App](../authoring/mcp.mdx#独立提供-app)。 ## 独立的 MCP Inspector diff --git a/website/docs/zh/reference/api.mdx b/website/docs/zh/reference/api.mdx index d752c04f8..a2fde7f2b 100644 --- a/website/docs/zh/reference/api.mdx +++ b/website/docs/zh/reference/api.mdx @@ -13,7 +13,7 @@ description: '生成的 agent-bundle 类型 API:它覆盖哪些入口点、如 | 入口点 | 内容 | | --- | --- | | `agent-bundle` | 编写与编排表面:`defineSkill`、`canonicalAgentEvents`、`startDevServer`、`runEvals`、`compareEvals`、eval harness 工厂,以及产物清单辅助函数。 | -| `agent-bundle/api` | 程序化编译器:`build`、`validate`、`inspect`、`prepack`,及其选项与结果类型,`AgentComponentKind` / `componentKindCapability` 组件类型辅助,以及产物操作 `listMcp`、`invokeMcp`、`runMcp`、`serveApp`(在浏览器里独立提供一个已构建的 MCP App——面向脚本与测试的宿主进程 API;产物内部的路由式 CLI 命令则改用 `agent-bundle/serve-app-command` 中的 `spawnServeApp`,见[独立提供 App](../guide/authoring/mcp.mdx#独立提供-app))、`listHooks` 与 `simulateHook`。 | +| `agent-bundle/api` | 程序化编译器:`build`、`validate`、`inspect`、`prepack`,及其选项与结果类型,`AgentComponentKind` / `componentKindCapability` 组件类型辅助,以及产物操作 `listMcp`、`invokeMcp`、`runMcp`、`serveApp`(在浏览器里独立提供一个已构建的 MCP App——面向脚本与测试的宿主进程 API;产物内部的路由式 CLI 命令则改用 `agent-bundle/serve-app-command` 中的 `spawnServeApp`,见[独立提供 App](../guide/authoring/mcp.mdx#独立提供-app))、`listHooks` 与 `simulateHook`;另有 Rspack stats 格式化工具 `rspackStatsErrors`、`describeRspackStatsError` 与 `formatRspackStatsError`,把一条编译错误渲染成与编译器 `AB4770` 相同的 `<file>:<line>:<column>: <message>` 行,供自行驱动 Rsbuild 编译的工具使用。 | | `agent-bundle/config` | `defineConfig` 与配置类型。 | | `agent-bundle/test` | 路由测试 harness、匹配器与契约矩阵。 | | `agent-bundle/test/browser` | 面向浏览器渲染视图的 MCP App bridge harness。 | diff --git a/website/docs/zh/reference/configuration.mdx b/website/docs/zh/reference/configuration.mdx index 387764123..b548af1ed 100644 --- a/website/docs/zh/reference/configuration.mdx +++ b/website/docs/zh/reference/configuration.mdx @@ -175,11 +175,18 @@ export default defineConfig({ 传给 `tools.rspack` 变更函数的 `utils` 参数(`(config, { rspack }) => ...`),它总会交给你当前执行引擎 自己的 `rspack` 对象。 +MCP App 构建保留 Rsbuild 默认的 `resolve.aliasStrategy`(`prefer-tsconfig`),因此项目 +`tsconfig.json` 中的 `paths` 在编译出的视图内部与其他地方一样生效。保留标识符 `agent-bundle/meta` +是例外:编译器会在解析开始之前把这个精确的导入改写为生成的身份模块,先于 `paths` 与任何 +`resolve.alias`,因此没有任何 `paths` 映射能够遮蔽它。已解析配置断言会拒绝移除这一改写的逃生舱取值, +就像它拒绝破坏自包含输出的取值一样。 + ### 插件 框架在它合成的配置中只注册一个 Rsbuild 插件:`@rsbuild/plugin-react`(插件名 `rsbuild:react`), -它作用于每个产物入口(路由以 TSX 编写,该插件选择自动 JSX 运行时)以及每个源文件为 `.jsx`/`.tsx` 的 -MCP App 视图。这个集合就是 `packages/agent-bundle/src/build/framework-plugins.ts` 中的 +它作用于每个产物入口(路由以 TSX 编写,该插件选择自动 JSX 运行时)以及每个 MCP App 视图——无论其 +入口扩展名是什么(关闭 fast refresh),因此导入 `.tsx` 组件的 `.ts` 入口也会编译到同一个自动运行时。 +这个集合就是 `packages/agent-bundle/src/build/framework-plugins.ts` 中的 `frameworkOwnedRsbuildPlugins`;有一条单元测试从合成的配置中推导它,因此不会漂移,而 `validateTools` 读取同一个映射,在 `tools.rsbuild.plugins` 条目重复添加其中某个名称时报告 `AB4724`。