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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/remove-runtime-app-assets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agent-bundle': minor
---

Remove the development runtime App-asset path: `DevRuntimeSession.readAsset`, the `/api/runtime/assets` route (now a 400 invalid path), the `DevRuntimeAssetRequest` and `DevRuntimePreparedMcpApp` exports, the prepared-runtime `apps` input, `DevRuntimeStatus.hmrReady`, the `mcp-app` runtime surface kind, the `mcp-protocol`, `resource-selection`, `sandbox/csp`, and `app-bridge` diagnostic phases, and the launch and credential fields on `DevRuntimePreparedMcpServer`, which is now `{ id, name, targets }`. (#856)
20 changes: 10 additions & 10 deletions examples/rsc-agent-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,16 @@ production RSC/runtime artifacts are built by its explicit Rsbuild production
command (`pnpm --filter @agent-bundle/rsc-agent-runtime-demo build`); its provider
uses a separate long-lived Rsbuild development session only when an
`agent-bundle dev` project opts into `dev.runtime.provider`. That session
compiles each change into a runtime generation, publishes its hook, MCP tool,
resource, and App surfaces to the Workbench runtime routes, and serves
generation assets through them; no browser connects to its loopback Rsbuild
server. The session uses development entries and output roots while compiling every
environment in production mode: production decoders cannot read development
Flight payloads, and Rsbuild only inlines the App's scripts and styles in
production mode. `@rsbuild/plugin-react` is configured as
`pluginReact({ fastRefresh: false })` and the App environment sets
`hmr: false`: the self-contained App document never receives a browser HMR
credential or connection. The `widget` and `app` web environments set
compiles the `rsc` and `widget` environments into a runtime generation and
publishes its hook, MCP tool, and resource surfaces to the Workbench runtime
routes; no browser connects to its loopback Rsbuild server. The `app`
environment is compiled only by the production build. The session uses
development entries and output roots while compiling every
environment in production mode, because
production decoders cannot read development
Flight payloads. `@rsbuild/plugin-react` is configured as
`pluginReact({ fastRefresh: false })`, so no refresh runtime is injected into
the self-contained App document. The `widget` and `app` web environments set
`overrideBrowserslist: ['chrome >= 144']` for the Chromium MCP App hosts
(Cursor 3.18.25 still ships Chromium 144; Claude Desktop Electron 42 and
Cursor 3.19.7 ship Chromium 148; ChatGPT/Codex Desktop reports Chromium 151).
Expand Down
35 changes: 11 additions & 24 deletions examples/rsc-agent-runtime/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,10 @@ export interface RscRuntimeCompileSnapshot {

export type RscRuntimeActivationOutcome = 'activated' | 'failed';
export type RscRuntimeCompileFailureKind = 'provider-lifecycle' | 'source-build';
export type RscRuntimeCompileEnvironmentName = 'app' | 'rsc' | 'widget';
export type RscRuntimeCompileEnvironmentName = 'rsc' | 'widget';
export type RscRuntimeCompileEnvironmentHashes = Readonly<Record<RscRuntimeCompileEnvironmentName, string>>;

const compileEnvironmentNames: readonly RscRuntimeCompileEnvironmentName[] = Object.freeze(['app', 'rsc', 'widget'] as const);
const compileEnvironmentNames: readonly RscRuntimeCompileEnvironmentName[] = Object.freeze(['rsc', 'widget'] as const);

const isCompileEnvironmentName = (value: string): value is RscRuntimeCompileEnvironmentName =>
(compileEnvironmentNames as readonly string[]).includes(value);
Expand Down Expand Up @@ -296,7 +296,7 @@ export const createRscRuntimeRsbuildConfig = (
if (development && options.compilerRoot === undefined) {
throw new TypeError('Development RSC runtime config requires compilerRoot.');
}
const root = (name: 'rsc' | 'widget' | 'app', productionRoot: string): string =>
const root = (name: 'rsc' | 'widget', productionRoot: string): string =>
development ? join(options.compilerRoot as string, name) : productionRoot;

return {
Expand All @@ -322,7 +322,7 @@ export const createRscRuntimeRsbuildConfig = (
pluginReact(rscRuntimeReactPluginOptions),
pluginRSC({ environments: { server: 'rsc', client: 'widget' } }),
emitRuntimeManifest(),
selfContainedAppPlugin(),
...(development ? [] : [selfContainedAppPlugin()]),
...(options.onCompile === undefined ? [] : [runtimeCompileObserverPlugin(options.onCompile)]),
],
environments: {
Expand Down Expand Up @@ -383,33 +383,20 @@ export const createRscRuntimeRsbuildConfig = (
rspack: { name: 'widget' },
},
},
app: {
...(development ? {
dev: {
// The development session serves no browser client; the compiled
// App must never receive a browser HMR credential or connection.
hmr: false,
liveReload: false,
},
} : {}),
...(development ? {} : { app: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update docs that still promise development App compilation

When users consult the public example catalog, both website/docs/en/examples/index.mdx:25 and website/docs/zh/examples/index.mdx:23 still claim that the development session compiles App output and uses an HMR topology, while this conditional now excludes the app environment from development builds. The linked topology document also still describes an HMR lane and a generation-bound MCP App bridge. Update both locales and the topology prose so they describe App compilation as production-only and do not promise the removed runtime App path.

AGENTS.md reference: AGENTS.md:L89-L95

Useful? React with 👍 / 👎.

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'),
distPath: { js: './', root: 'dist/app' },
filename: {
assets: '[name][ext]',
css: '[name].css',
js: '[name].js',
},
...(development ? {} : {
filename: {
assets: '[name][ext]',
css: '[name].css',
js: '[name].js',
},
}),
filenameHash: false,
inlineScripts: true,
inlineStyles: true,
Expand All @@ -431,7 +418,7 @@ export const createRscRuntimeRsbuildConfig = (
output: { asyncChunks: false },
},
},
},
} }),
},
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ import { copyTree, digestBytes, writeFileDurably } from './durable-tree.js';
* hash.
*/

export type RscRuntimeEnvironmentName = 'app' | 'rsc' | 'widget';
export type RscRuntimeEnvironmentName = 'rsc' | 'widget';

export const rscRuntimeEnvironmentNames: readonly RscRuntimeEnvironmentName[] =
Object.freeze(['app', 'rsc', 'widget'] as const);
Object.freeze(['rsc', 'widget'] as const);

export type RscEnvironmentCohortHashes = Readonly<Record<RscRuntimeEnvironmentName, string>>;

Expand Down
162 changes: 5 additions & 157 deletions examples/rsc-agent-runtime/src/dev/generation-materializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ import type {
RscStagedEnvironmentCheckpoint,
} from './environment-checkpoint-store.js';
import type {
RscRuntimeAppDefinition,
RscRuntimeGenerationMetadata,
RscRuntimeSurfaceAsset,
SerializedRuntimeDefinition,
} from '../runtime/contracts.js';
import type { JsonObject, JsonValue } from 'agent-bundle';
Expand All @@ -29,7 +27,7 @@ import type {
RuntimeGenerationValidationInput,
} from 'agent-bundle/api';

export type { RscRuntimeGenerationMetadata, RscRuntimeSurfaceAsset } from '../runtime/contracts.js';
export type { RscRuntimeGenerationMetadata } from '../runtime/contracts.js';

const definitionFile = 'rsc/runtime-definition.json';
const runtimeAssetsFile = 'rsc/runtime-assets.json';
Expand Down Expand Up @@ -453,109 +451,6 @@ const validateClientReferenceRelationship = async (
}
};

const contentTypeFor = (path: string): RscRuntimeSurfaceAsset['contentType'] | undefined => {
if (path.endsWith('.js')) return 'application/javascript';
if (path.endsWith('.json')) return 'application/json';
if (path.endsWith('.css')) return 'text/css';
if (path.endsWith('.html')) return 'text/html';
return undefined;
};

const surfaceAssets = (
preparedRuntime: DevRuntimePreparedProject,
assets: readonly RuntimeGenerationAsset[],
): Readonly<Record<string, readonly RscRuntimeSurfaceAsset[]>> => {
const widgetAssets = assets.flatMap((asset): RscRuntimeSurfaceAsset[] => {
if (!asset.path.startsWith('widget/')) return [];
const contentType = contentTypeFor(asset.path);
if (contentType === undefined) return [];
const requestPath = asset.path.slice('widget'.length);
return [Object.freeze({
bytes: asset.bytes,
contentType,
generationPath: asset.path,
requestPath,
sha256: asset.sha256,
})];
});
const appHtmlAssets = assets.flatMap((asset): RscRuntimeSurfaceAsset[] => {
if (!asset.path.startsWith('app/') || !asset.path.endsWith('.html')) return [];
return [Object.freeze({
bytes: asset.bytes,
contentType: 'text/html',
generationPath: asset.path,
requestPath: asset.path.slice('app'.length),
sha256: asset.sha256,
})];
});
const surfaces: Record<string, readonly RscRuntimeSurfaceAsset[]> = {};
for (const app of preparedRuntime.apps) {
const surfaceId = `mcp.${app.name}`;
if (surfaces[surfaceId] !== undefined) throw new Error('Runtime generation has duplicate App surface definitions.');
const resourcePath = appResourcePath(app.resourceUri);
const html = appHtmlAssets.filter((asset) => asset.requestPath === resourcePath);
if (html.length !== 1) throw new Error(`Runtime generation App ${JSON.stringify(app.resourceUri)} has no unique captured HTML asset.`);
surfaces[surfaceId] = Object.freeze([
...widgetAssets.map((asset) => Object.freeze({ ...asset })),
Object.freeze({ ...html[0]! }),
]);
}
return Object.freeze(surfaces);
};

const appResourcePath = (uri: string): string => {
let parsed: URL;
try {
parsed = new URL(uri);
} catch {
throw new TypeError('Runtime generation App resource URI is invalid.');
}
if (parsed.protocol !== 'ui:' || parsed.host.length === 0 || parsed.search.length > 0 || parsed.hash.length > 0) {
throw new TypeError('Runtime generation App resource URI is invalid.');
}
const origin = `ui://${parsed.host}`;
if (!uri.startsWith(origin)) throw new TypeError('Runtime generation App resource URI is invalid.');
const path = uri.slice(origin.length);
const segments = path.startsWith('/') ? path.slice(1).split('/') : [];
if (segments.length === 0 || segments.some((segment) => !isSafeSegment(segment) || decodeURIComponent(segment) !== segment)) {
throw new TypeError('Runtime generation App resource URI is invalid.');
}
return `/${segments.join('/')}`;
};

const validateAppSurfaceAssets = (
apps: readonly RscRuntimeAppDefinition[],
surfaces: Readonly<Record<string, readonly RscRuntimeSurfaceAsset[]>>,
): void => {
const expected = new Set<string>();
for (const app of apps) {
const surfaceId = `mcp.${app.name}`;
if (expected.has(surfaceId)) throw new TypeError('Runtime generation has duplicate App surface definitions.');
expected.add(surfaceId);
const resourcePath = appResourcePath(app.resourceUri);
const appHtml = surfaces[surfaceId]?.filter((asset) =>
asset.contentType === 'text/html' && asset.generationPath.startsWith('app/'),
) ?? [];
if (appHtml.length !== 1 || appHtml[0]!.generationPath !== `app${resourcePath}` || appHtml[0]!.requestPath !== resourcePath) {
throw new TypeError(`Runtime generation App ${JSON.stringify(app.resourceUri)} has no canonical captured HTML asset.`);
}
}
if (Object.keys(surfaces).length !== expected.size || Object.keys(surfaces).some((surfaceId) => !expected.has(surfaceId))) {
throw new TypeError('Runtime generation App surface assets are not owned by App definitions.');
}
};

const appDefinitions = (preparedRuntime: DevRuntimePreparedProject): readonly RscRuntimeAppDefinition[] =>
freezeJson(preparedRuntime.apps.map((app) => ({
id: app.id,
name: app.name,
resourceUri: app.resourceUri,
})).sort((left, right) => {
const leftJson = canonicalJson(left);
const rightJson = canonicalJson(right);
return leftJson < rightJson ? -1 : leftJson > rightJson ? 1 : 0;
})) as unknown as readonly RscRuntimeAppDefinition[];

const metadataFromSnapshot = async (
snapshot: RscRuntimeCapturedGenerationSnapshot,
assets: readonly RuntimeGenerationAsset[],
Expand All @@ -577,10 +472,8 @@ const metadataFromSnapshot = async (
return [entry, `rsc/${path}`];
})));
return Object.freeze({
appDefinitions: appDefinitions(snapshot.preparedRuntime),
entries,
stateStoreId,
surfaceAssets: surfaceAssets(snapshot.preparedRuntime, assets),
});
};

Expand All @@ -594,15 +487,14 @@ export const captureRuntimeGenerationSnapshot = async (
// live compiler roots, which the next parallel compile may already be
// rewriting. The definition executable and the generated definition
// artifacts likewise run against and land in the candidate's own copy.
const { app, rsc, widget } = input.cohort;
const { rsc, widget } = input.cohort;
const candidateRsc = join(input.candidate.root, 'rsc');
const runtimeAssets = await parseRuntimeAssets(rsc.root);
await copyDeclaredRscAssets(rsc, runtimeAssets, candidateRsc);
const definition = await runDefinitionExecutable(join(candidateRsc, 'dev', 'definition.js'));
await writeFileDurably(join(candidateRsc, 'runtime-definition.json'), Buffer.from(canonicalJson(definition)));
await emitRuntimeArtifacts(candidateRsc, definition);
await fsyncPath(candidateRsc);
await copyCheckpointTree(app, join(input.candidate.root, 'app'));
await copyCheckpointTree(widget, join(input.candidate.root, 'widget'));
await fsyncPath(input.candidate.root);
const assets = await walkRegularFiles(input.candidate.root);
Expand All @@ -619,55 +511,22 @@ export const captureRuntimeGenerationSnapshot = async (

const decodeMetadata = (value: JsonValue): RscRuntimeGenerationMetadata => {
if (!isJsonObject(value)) throw new TypeError('Runtime generation metadata is malformed.');
const required = ['appDefinitions', 'entries', 'stateStoreId', 'surfaceAssets'];
const required = ['entries', 'stateStoreId'];
if (Object.keys(value).some((key) => !required.includes(key)) || required.some((key) => !(key in value))) {
throw new TypeError('Runtime generation metadata has an invalid schema.');
}
const { appDefinitions, entries, stateStoreId, surfaceAssets } = value;
if (typeof stateStoreId !== 'string' || !Array.isArray(appDefinitions) || !isJsonObject(entries) || !isJsonObject(surfaceAssets)) {
const { entries, stateStoreId } = value;
if (typeof stateStoreId !== 'string' || !isJsonObject(entries)) {
throw new TypeError('Runtime generation metadata is malformed.');
}
if (stateStoreId.length === 0 ||
Object.keys(entries).length !== requiredEntries.length || requiredEntries.some((entry) => typeof entries[entry] !== 'string')) {
throw new TypeError('Runtime generation entries are malformed.');
}

const decodedAppDefinitions = appDefinitions.map((value): RscRuntimeAppDefinition => {
if (!isJsonObject(value)) throw new TypeError('Runtime generation App definition is malformed.');
const fields = ['id', 'name', 'resourceUri'];
if (Object.keys(value).some((key) => !fields.includes(key)) || fields.some((field) => !(field in value)) ||
typeof value.id !== 'string' || typeof value.name !== 'string' || typeof value.resourceUri !== 'string') {
throw new TypeError('Runtime generation App definition is malformed.');
}
return Object.freeze({ id: value.id, name: value.name, resourceUri: value.resourceUri });
});

const decodedSurfaceAssets: Record<string, readonly RscRuntimeSurfaceAsset[]> = {};
for (const [surfaceId, value] of Object.entries(surfaceAssets)) {
if (surfaceId.length === 0 || !Array.isArray(value)) throw new TypeError('Runtime generation surface assets are malformed.');
decodedSurfaceAssets[surfaceId] = Object.freeze(value.map((value): RscRuntimeSurfaceAsset => {
if (!isJsonObject(value)) throw new TypeError('Runtime generation surface asset is malformed.');
const fields = ['bytes', 'contentType', 'generationPath', 'requestPath', 'sha256'];
if (Object.keys(value).some((key) => !fields.includes(key)) || fields.some((field) => !(field in value)) ||
typeof value.bytes !== 'number' || !Number.isSafeInteger(value.bytes) || value.bytes < 0 || typeof value.generationPath !== 'string' ||
typeof value.requestPath !== 'string' || typeof value.sha256 !== 'string' || !sha256Expression.test(value.sha256) ||
(value.contentType !== 'application/javascript' && value.contentType !== 'application/json' && value.contentType !== 'text/css' && value.contentType !== 'text/html')) {
throw new TypeError('Runtime generation surface asset is malformed.');
}
return Object.freeze({
bytes: value.bytes,
contentType: value.contentType,
generationPath: assertRelativeAssetPath(value.generationPath),
requestPath: value.requestPath,
sha256: value.sha256,
});
}));
}
return Object.freeze({
appDefinitions: Object.freeze(decodedAppDefinitions),
entries: Object.freeze(Object.fromEntries(requiredEntries.map((entry) => [entry, entries[entry] as string]))),
stateStoreId,
surfaceAssets: Object.freeze(decodedSurfaceAssets),
});
};

Expand Down Expand Up @@ -701,17 +560,6 @@ export const validateRscRuntimeGenerationMetadata = async (
throw new TypeError('Runtime generation definition is not canonical.');
}
await validateClientReferenceRelationship(input.root, input.assets);
const declaredSurfaceAssets = metadata.surfaceAssets as Readonly<Record<string, readonly RscRuntimeSurfaceAsset[]>>;
for (const [surface, descriptors] of Object.entries(declaredSurfaceAssets)) {
const requestPaths = new Set<string>();
for (const asset of descriptors) {
if (requestPaths.has(asset.requestPath) || assets.get(asset.generationPath)?.sha256 !== asset.sha256 || assets.get(asset.generationPath)?.bytes !== asset.bytes || contentTypeFor(asset.generationPath) !== asset.contentType) {
throw new TypeError(`Runtime generation surface ${JSON.stringify(surface)} is invalid.`);
}
requestPaths.add(asset.requestPath);
}
}
validateAppSurfaceAssets(metadata.appDefinitions, declaredSurfaceAssets);
return metadata;
};

Expand Down
Loading
Loading