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
14 changes: 14 additions & 0 deletions .changeset/runtime-app-reload-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"agent-bundle": minor
---

Carry Runtime App reloads over a provider-owned channel instead of Rsbuild's
private WebSocket protocol. The trusted client-surface endpoint now exposes
`subscribeReload`, fed by the provider's successful, changed App environment
compile hook; the relay proxy hosts its own one-way reload WebSocket at
`/__agent_bundle_runtime/reload`, replays the current reload generation on
every (re)connect, and refreshes the opaque App child only when that
generation strictly advances. The proxy no longer dials Rsbuild's WebSocket,
so the endpoint's `webSocketOrigin`/`webSocketPath`/`webSocketToken` fields
and the Runtime App preview's `clientSurface.webSocketPath` field are gone,
and no Rsbuild HMR credential is handled outside the compiler process.
31 changes: 15 additions & 16 deletions examples/rsc-agent-runtime/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ export type RscRuntimeCompileFailureKind = 'provider-lifecycle' | 'source-build'
export interface RscRuntimeRsbuildConfigOptions {
readonly compilerRoot?: string;
readonly mode: 'development' | 'production';
/** Receives the App environment's server-only Rsbuild HMR credential. */
readonly onAppWebSocketToken?: (input: Readonly<{
readonly path: string;
readonly token: string;
}>) => void;
/**
* Provider-owned reload signal: invoked once for each later successful,
* changed App environment compilation. This callback replaces
* `hot.send('full-reload')`, so no consumer has to parse Rsbuild's private
* WebSocket envelope to learn that the App surface changed.
*/
readonly onAppReload?: () => void;
readonly onCompile?: Readonly<{
beforeAttempt(): string;
capture(input: {
Expand All @@ -43,21 +45,18 @@ export interface RscRuntimeRsbuildConfigOptions {
}>;
}

const runtimeAppHmrTokenPlugin = (
capture: NonNullable<RscRuntimeRsbuildConfigOptions['onAppWebSocketToken']>,
const runtimeAppReloadPlugin = (
onAppReload: NonNullable<RscRuntimeRsbuildConfigOptions['onAppReload']>,
): RsbuildPlugin => {
let devServer: RsbuildDevServer | undefined;
let lastAppCompilation: object | string | undefined;
return {
name: 'agent-bundle:rsc-runtime-app-hmr-token',
name: 'agent-bundle:rsc-runtime-app-reload',
setup(api) {
api.onAfterCreateCompiler(({ environments }) => {
const app = environments.app;
const token = app?.webSocketToken;
if (typeof token !== 'string') throw new Error('RSC runtime App compiler did not expose an HMR credential.');
const path = app?.config.dev.client.path;
if (typeof path !== 'string') throw new Error('RSC runtime App compiler did not expose a normalized HMR path.');
capture(Object.freeze({ path, token }));
if (environments.app === undefined) {
throw new Error('RSC runtime compiler did not expose the App environment.');
}
});
api.onBeforeStartDevServer(({ server }) => {
devServer = server;
Expand All @@ -73,7 +72,7 @@ const runtimeAppHmrTokenPlugin = (
if (lastAppCompilation === compilation) return;
lastAppCompilation = compilation;
if (isFirstCompile) return;
devServer?.environments.app.hot.send('full-reload');
onAppReload();
});
},
};
Expand Down Expand Up @@ -221,7 +220,7 @@ export const createRscRuntimeRsbuildConfig = (
pluginReact(),
pluginRSC({ environments: { server: 'rsc', client: 'widget' } }),
emitRuntimeManifest(),
...(options.onAppWebSocketToken === undefined ? [] : [runtimeAppHmrTokenPlugin(options.onAppWebSocketToken)]),
...(options.onAppReload === undefined ? [] : [runtimeAppReloadPlugin(options.onAppReload)]),
...(options.onCompile === undefined ? [] : [runtimeCompileObserverPlugin(options.onCompile)]),
],
environments: {
Expand Down
57 changes: 25 additions & 32 deletions examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,8 +589,6 @@ const sourceBuildDiagnostic = (): DevRuntimeDiagnostic => Object.freeze({
});

const abortReason = (signal: AbortSignal): unknown => signal.reason ?? new Error('RSC runtime provider startup was aborted.');
const hmrPathMaxLength = 2_048;
const hmrTokenMaxLength = 4_096;

export interface RsbuildRuntimeSessionStartTesting {
readonly createRsbuild?: typeof createRsbuild;
Expand Down Expand Up @@ -675,8 +673,11 @@ export class RsbuildRuntimeSession implements DevRuntimeSession {
readonly #workers = new Map<string, InvocationWorker>();
readonly #failedAttempts = new Set<string>();
#active: RuntimeGeneration<RscRuntimeGenerationMetadata> | undefined;
#appWebSocketPath: string | undefined;
#appWebSocketToken: string | undefined;
/**
* Wrapper objects, not raw listeners, so one relay subscribing the same
* function twice still owns two independently detachable subscriptions.
*/
readonly #appReloadSubscriptions = new Set<Readonly<{ readonly listener: () => void }>>();
#clientSurface: DevRuntimeClientSurfaceEndpoint | undefined;
#closePromise: Promise<void> | undefined;
#closed = false;
Expand Down Expand Up @@ -845,7 +846,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession {
config: createRscRuntimeRsbuildConfig({
compilerRoot: join(storageRoot, 'compiler'),
mode: 'development',
onAppWebSocketToken: (input) => session.#captureAppWebSocketConnection(input),
onAppReload: () => { session.#emitAppReload(); },
onCompile: session.#compileObserver(),
}),
cwd: context.projectRoot,
Expand Down Expand Up @@ -2150,47 +2151,38 @@ export class RsbuildRuntimeSession implements DevRuntimeSession {
devServer === undefined || devServer.hostname !== '127.0.0.1' || devServer.https ||
!Number.isSafeInteger(devServer.port) || devServer.port < 1 || devServer.port > 65_535
) throw new Error('RSC runtime dev server did not expose a valid loopback HTTP origin.');
const webSocketPath = this.#appWebSocketPath;
if (webSocketPath === undefined) throw new Error('RSC runtime App compiler did not capture a normalized HMR path.');
const webSocketToken = this.#appWebSocketToken;
if (webSocketToken === undefined) throw new Error('RSC runtime App compiler did not capture an HMR credential.');
const origin = new URL(`http://${devServer.hostname}:${String(devServer.port)}`).origin;
this.#server = started.server;
this.#clientSurface = Object.freeze({
entryPath: clientSurfaceEntry,
httpOrigin: origin,
httpPathPrefixes: Object.freeze(['/']),
subscribeReload: (listener: () => void) => this.#subscribeAppReload(listener),
surfaceId: clientSurfaceId,
webSocketOrigin: origin.replace(/^http:/u, 'ws:'),
webSocketPath,
webSocketToken,
});
this.#hmrReady = true;
this.#setStatus(this.#active === undefined ? 'compiling' : 'active');
}

#captureAppWebSocketConnection(input: Readonly<{ readonly path: string; readonly token: string }>): void {
const { path, token } = input;
if (
typeof path !== 'string' || path.length === 0 || path.length > hmrPathMaxLength || !path.startsWith('/') ||
new URL(path, 'http://compiler.invalid').pathname !== path
) {
throw new Error('RSC runtime App compiler exposed an invalid normalized HMR path.');
#subscribeAppReload(listener: () => void): () => void {
if (typeof listener !== 'function') {
throw new TypeError('RSC runtime App reload subscription requires a listener function.');
}
// Rsbuild's public contract says only that webSocketToken is a string.
// Its 2.2.1 alphabet and length are empirical, so enforce only resource
// bounds and rely on URLSearchParams at the proxy boundary.
if (typeof token !== 'string' || token.length === 0 || token.length > hmrTokenMaxLength) {
throw new Error('RSC runtime App compiler exposed an invalid HMR credential.');
}
if (
(this.#appWebSocketPath !== undefined && this.#appWebSocketPath !== path) ||
(this.#appWebSocketToken !== undefined && this.#appWebSocketToken !== token)
) {
throw new Error('RSC runtime App compiler changed its HMR connection during startup.');
if (this.#closed) return () => undefined;
const subscription = Object.freeze({ listener });
this.#appReloadSubscriptions.add(subscription);
return () => { this.#appReloadSubscriptions.delete(subscription); };
}

#emitAppReload(): void {
if (this.#closed) return;
for (const subscription of [...this.#appReloadSubscriptions]) {
try {
subscription.listener();
} catch {
// One relay's failure must not starve the remaining subscribers.
}
}
this.#appWebSocketPath = path;
this.#appWebSocketToken = token;
}

#compileObserver(): NonNullable<Parameters<typeof createRscRuntimeRsbuildConfig>[0]['onCompile']> {
Expand Down Expand Up @@ -2649,6 +2641,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession {
this.#closed = true;
this.#invocationAbort.abort(new Error('RSC runtime session is closing.'));
this.#hmrReady = false;
this.#appReloadSubscriptions.clear();
for (const attempt of [...this.#attempts.values()]) attempt.settle();
for (const worker of this.#workers.values()) {
worker.terminate(new Error('RSC runtime session is closing.'));
Expand Down
86 changes: 29 additions & 57 deletions examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,43 +207,42 @@ const introduceWorkerSyntaxError = async (projectRoot: string): Promise<void> =>
);
};

test('captures the App compiler HMR credential only through the public Rsbuild environment hook', async () => {
const captured: Array<Readonly<{ readonly path: string; readonly token: string }>> = [];
test('requires the App environment through the public Rsbuild compiler hook', async () => {
const config = createRscRuntimeRsbuildConfig({
compilerRoot: join(tmpdir(), 'rsc-provider-hmr-token'),
compilerRoot: join(tmpdir(), 'rsc-provider-app-environment'),
mode: 'development',
onAppWebSocketToken: (input) => { captured.push(input); },
} as Parameters<typeof createRscRuntimeRsbuildConfig>[0]);
onAppReload: () => undefined,
});
const plugin = (config.plugins as readonly unknown[]).find((candidate): candidate is Readonly<{
readonly name: string;
setup(api: unknown): void;
}> => typeof candidate === 'object' && candidate !== null &&
(candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-app-hmr-token');
if (plugin === undefined) throw new Error('RSC App HMR token plugin is unavailable.');
(candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-app-reload');
if (plugin === undefined) throw new Error('RSC App reload plugin is unavailable.');
let afterCreate: ((input: unknown) => void) | undefined;
plugin.setup({
onAfterCreateCompiler: (callback: unknown) => { afterCreate = callback as (input: unknown) => void; },
onAfterEnvironmentCompile: () => undefined,
onBeforeStartDevServer: () => undefined,
onCloseDevServer: () => undefined,
});
afterCreate?.({ environments: { app: { config: { dev: { client: { path: '/custom-hmr' } } }, webSocketToken: 'rsbuild-token-1234' } } });
expect(captured).toEqual([{ path: '/custom-hmr', token: 'rsbuild-token-1234' }]);
expect(() => afterCreate?.({ environments: {} })).toThrow('App environment');
expect(() => afterCreate?.({ environments: { app: {} } })).not.toThrow();
});

test('sends one App-only full reload for each later successful App compilation', async () => {
const captured: string[] = [];
test('emits one owned App reload for each later successful changed App compilation', async () => {
const reloads: number[] = [];
const config = createRscRuntimeRsbuildConfig({
compilerRoot: join(tmpdir(), 'rsc-provider-app-reload'),
mode: 'development',
onAppWebSocketToken: ({ token }) => { captured.push(token); },
} as Parameters<typeof createRscRuntimeRsbuildConfig>[0]);
onAppReload: () => { reloads.push(reloads.length + 1); },
});
const plugin = (config.plugins as readonly unknown[]).find((candidate): candidate is Readonly<{
readonly name: string;
setup(api: unknown): void;
}> => typeof candidate === 'object' && candidate !== null &&
(candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-app-hmr-token');
if (plugin === undefined) throw new Error('RSC App HMR token plugin is unavailable.');
(candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-app-reload');
if (plugin === undefined) throw new Error('RSC App reload plugin is unavailable.');

let afterCompiler: ((input: unknown) => void) | undefined;
let afterEnvironmentCompile: ((input: unknown) => void) | undefined;
Expand All @@ -256,8 +255,6 @@ test('sends one App-only full reload for each later successful App compilation',
onCloseDevServer: (callback: unknown) => { closeDevServer = callback as () => unknown; },
});

const appSends: string[] = [];
const otherSends: string[] = [];
const firstAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: true, stats: { hasErrors: () => false, hash: 'app-change-a' } });
const duplicateFirstAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: 'app-change-a' } });
const appBUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => false, hash: 'app-change-b' } });
Expand All @@ -266,45 +263,30 @@ test('sends one App-only full reload for each later successful App compilation',
const failedAppUpdate = Object.freeze({ environment: { name: 'app' }, isFirstCompile: false, stats: { hasErrors: () => true } });
const nonAppUpdate = Object.freeze({ environment: { name: 'widget' }, isFirstCompile: false, stats: { hasErrors: () => false } });

afterCompiler?.({
environments: {
app: { config: { dev: { client: { path: '/rsbuild-hmr' } } }, webSocketToken: 'rsbuild-app-token-1234' },
widget: { webSocketToken: 'widget-token-must-not-leak' },
},
});
afterCompiler?.({ environments: { app: {}, widget: {} } });
afterEnvironmentCompile?.(appBUpdate);
expect(appSends).toEqual([]);
beforeStartDevServer?.({
server: {
environments: {
app: { hot: { send: (type: string) => { appSends.push(type); } } },
widget: { hot: { send: (type: string) => { otherSends.push(type); } } },
},
},
});
expect(reloads).toEqual([]);
beforeStartDevServer?.({ server: { environments: { app: {}, widget: {} } } });
afterEnvironmentCompile?.(firstAppUpdate);
afterEnvironmentCompile?.(nonAppUpdate);
afterEnvironmentCompile?.(failedAppUpdate);
afterEnvironmentCompile?.(duplicateFirstAppUpdate);
expect(captured).toEqual(['rsbuild-app-token-1234']);
expect(appSends).toEqual([]);
expect(reloads).toEqual([]);

afterEnvironmentCompile?.(appBUpdate);
expect(appSends).toEqual(['full-reload']);
expect(reloads).toEqual([1]);
afterEnvironmentCompile?.(appAUpdate);
expect(appSends).toEqual(['full-reload', 'full-reload']);
expect(reloads).toEqual([1, 2]);
afterEnvironmentCompile?.(repeatedAppBUpdate);
expect(appSends).toEqual(['full-reload', 'full-reload', 'full-reload']);
expect(otherSends).toEqual([]);
expect(reloads).toEqual([1, 2, 3]);

await closeDevServer?.();
afterEnvironmentCompile?.(appAUpdate);
expect(appSends).toEqual(['full-reload', 'full-reload', 'full-reload']);
expect(reloads).toEqual([1, 2, 3]);

const replacementSends: string[] = [];
beforeStartDevServer?.({ server: { environments: { app: { hot: { send: (type: string) => { replacementSends.push(type); } } } } } });
beforeStartDevServer?.({ server: { environments: { app: {}, widget: {} } } });
afterEnvironmentCompile?.(appBUpdate);
expect(replacementSends).toEqual(['full-reload']);
expect(reloads).toEqual([1, 2, 3, 4]);
});

test('keeps compiler-App HMR out of the opaque browser child', () => {
Expand Down Expand Up @@ -369,9 +351,8 @@ test('declares an optional runtime while keeping Claude and Codex artifacts buil
entryPath: '/edit-timeline-v1.html',
httpOrigin: expect.stringMatching(/^http:\/\/127\.0\.0\.1:[1-9]\d*$/u),
httpPathPrefixes: ['/'],
subscribeReload: expect.any(Function),
surfaceId: 'mcp.edit-timeline',
webSocketOrigin: expect.stringMatching(/^ws:\/\/127\.0\.0\.1:[1-9]\d*$/u),
webSocketPath: '/rsbuild-hmr',
});
expect(session.status()).not.toHaveProperty('clientSurface');
expect(session.surfaces()).toEqual(expect.arrayContaining([
Expand Down Expand Up @@ -1739,23 +1720,16 @@ test('uses the bound Rsbuild dev-server context instead of a stale port-zero sta
readonly name: string;
setup(api: unknown): void;
}> => typeof candidate === 'object' && candidate !== null &&
(candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-app-hmr-token');
if (plugin === undefined) throw new Error('RSC App HMR token plugin is unavailable.');
(candidate as { readonly name?: unknown }).name === 'agent-bundle:rsc-runtime-app-reload');
if (plugin === undefined) throw new Error('RSC App reload plugin is unavailable.');
let afterCreate: ((input: unknown) => void) | undefined;
plugin.setup({
onAfterCreateCompiler: (callback: unknown) => { afterCreate = callback as (input: unknown) => void; },
onAfterEnvironmentCompile: () => undefined,
onBeforeStartDevServer: () => undefined,
onCloseDevServer: () => undefined,
});
afterCreate?.({
environments: {
app: {
config: { dev: { client: { path: '/custom-runtime-hmr' } } },
webSocketToken: 'token with /?+%= punctuation',
},
},
});
afterCreate?.({ environments: { app: {} } });
return Object.freeze({
context: Object.freeze({
devServer: Object.freeze({ hostname: '127.0.0.1', https: false, port: 41_103 }),
Expand All @@ -1777,9 +1751,7 @@ test('uses the bound Rsbuild dev-server context instead of a stale port-zero sta
try {
expect(session.clientSurface('mcp.edit-timeline')).toMatchObject({
httpOrigin: 'http://127.0.0.1:41103',
webSocketOrigin: 'ws://127.0.0.1:41103',
webSocketPath: '/custom-runtime-hmr',
webSocketToken: 'token with /?+%= punctuation',
subscribeReload: expect.any(Function),
});
} finally {
await session.close();
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export {
export { createWorkbenchAssetSource, type WorkbenchAssetSourceOptions } from './workbench-assets.ts';
export {
RuntimeClientSurfaceProxy,
runtimeClientSurfaceReloadChannelPath,
type RuntimeClientSurfaceConnectionEvent,
} from './runtime-client-surface-proxy.ts';
export { RuntimeRoutes, type RuntimeRoutesOptions } from './runtime-routes.ts';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export interface McpAppPreviewSnapshotBase {
}

export interface McpAppPreviewAppsSnapshot extends McpAppPreviewSnapshotBase {
readonly clientSurface: Readonly<{ readonly bootstrapUrl: string; readonly origin: string; readonly webSocketPath: '/rsbuild-hmr' }>;
readonly clientSurface: Readonly<{ readonly bootstrapUrl: string; readonly origin: string }>;
readonly documentPolicy: McpAppDocumentPolicySnapshot;
readonly kind: 'apps';
readonly profile: McpAppAppsHostProfile;
Expand Down Expand Up @@ -468,7 +468,7 @@ export class McpAppRuntimePreviewService implements McpAppRuntimeRoutePreviewSer
if (profile.kind === 'apps' && proxy !== undefined) {
snapshot = Object.freeze({
...base,
clientSurface: Object.freeze({ bootstrapUrl: proxy.bootstrapUrl, origin: proxy.origin, webSocketPath: '/rsbuild-hmr' as const }),
clientSurface: Object.freeze({ bootstrapUrl: proxy.bootstrapUrl, origin: proxy.origin }),
documentPolicy,
kind: 'apps' as const,
profile,
Expand Down
Loading
Loading