From 816c3d0a7c8d92fc8005f28b9a34a4226075de36 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 17:19:25 +0000 Subject: [PATCH 1/2] feat(dev): carry Runtime App reloads over a provider-owned channel Replace the raw Rsbuild HMR WebSocket relay with a provider-owned reload channel. The client-surface endpoint now exposes subscribeReload, fed by the App environment compile hook; the proxy hosts its own one-way reload WebSocket, replays the current generation on reconnect, and refreshes the opaque App child only when the generation strictly advances. Rsbuild's private frames, dev.client.path, and webSocketToken leave the contract. Fixes #73 Co-authored-by: Zack Jackson --- .changeset/runtime-app-reload-channel.md | 14 + examples/rsc-agent-runtime/rsbuild.config.ts | 31 +- .../src/dev/rsbuild-runtime-session.ts | 57 ++- .../tests/dev-provider.integration.test.ts | 86 ++--- packages/agent-bundle/src/dev/index.ts | 1 + .../dev/mcp-app-runtime-preview-service.ts | 4 +- .../src/dev/runtime-client-surface-proxy.ts | 275 +++++++------ .../agent-bundle/src/dev/runtime-provider.ts | 14 +- .../agent-bundle/tests/dev-workbench.test.ts | 8 +- .../mcp-app-runtime-preview-service.test.ts | 5 +- .../runtime-client-surface-proxy.test.ts | 363 ++++++++---------- .../tests/runtime-provider.test.ts | 4 +- packages/workbench/src/mcp/mcp-app-client.ts | 5 +- .../workbench/tests/mcp-app-client.test.ts | 2 +- .../workbench/tests/mcp-app-frame.test.ts | 2 +- .../tests/mcp-app-preview-browser.test.ts | 2 +- .../workbench/tests/mcp-app-preview.test.ts | 2 +- .../workbench/tests/mcp-app-real.e2e.test.ts | 14 +- .../tests/mcp-page-app-browser.test.ts | 2 +- packages/workbench/tests/overview.e2e.test.ts | 16 +- .../tests/runtime-app-bridge.test.ts | 8 +- 21 files changed, 418 insertions(+), 497 deletions(-) create mode 100644 .changeset/runtime-app-reload-channel.md diff --git a/.changeset/runtime-app-reload-channel.md b/.changeset/runtime-app-reload-channel.md new file mode 100644 index 000000000..cbbf06a96 --- /dev/null +++ b/.changeset/runtime-app-reload-channel.md @@ -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. diff --git a/examples/rsc-agent-runtime/rsbuild.config.ts b/examples/rsc-agent-runtime/rsbuild.config.ts index 54dcc6fdc..0f874bca8 100644 --- a/examples/rsc-agent-runtime/rsbuild.config.ts +++ b/examples/rsc-agent-runtime/rsbuild.config.ts @@ -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: { @@ -43,21 +45,18 @@ export interface RscRuntimeRsbuildConfigOptions { }>; } -const runtimeAppHmrTokenPlugin = ( - capture: NonNullable, +const runtimeAppReloadPlugin = ( + onAppReload: NonNullable, ): 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; @@ -73,7 +72,7 @@ const runtimeAppHmrTokenPlugin = ( if (lastAppCompilation === compilation) return; lastAppCompilation = compilation; if (isFirstCompile) return; - devServer?.environments.app.hot.send('full-reload'); + onAppReload(); }); }, }; @@ -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: { 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 8c3f64499..fb7079570 100644 --- a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts +++ b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts @@ -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; @@ -675,8 +673,11 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { readonly #workers = new Map(); readonly #failedAttempts = new Set(); #active: RuntimeGeneration | 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 void }>>(); #clientSurface: DevRuntimeClientSurfaceEndpoint | undefined; #closePromise: Promise | undefined; #closed = false; @@ -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, @@ -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[0]['onCompile']> { @@ -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.')); 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 d15410f74..8e013a578 100644 --- a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts @@ -207,19 +207,18 @@ const introduceWorkerSyntaxError = async (projectRoot: string): Promise => ); }; -test('captures the App compiler HMR credential only through the public Rsbuild environment hook', async () => { - const captured: Array> = []; +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[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; }, @@ -227,23 +226,23 @@ test('captures the App compiler HMR credential only through the public Rsbuild e 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[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; @@ -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' } }); @@ -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', () => { @@ -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([ @@ -1739,8 +1720,8 @@ 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; }, @@ -1748,14 +1729,7 @@ test('uses the bound Rsbuild dev-server context instead of a stale port-zero sta 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 }), @@ -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(); diff --git a/packages/agent-bundle/src/dev/index.ts b/packages/agent-bundle/src/dev/index.ts index 672eb54ac..c5a0007d6 100644 --- a/packages/agent-bundle/src/dev/index.ts +++ b/packages/agent-bundle/src/dev/index.ts @@ -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'; diff --git a/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts b/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts index 54f4f280c..74eda9769 100644 --- a/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts +++ b/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts @@ -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; @@ -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, diff --git a/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts b/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts index 7cfce1179..98b22eb48 100644 --- a/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts +++ b/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts @@ -23,21 +23,24 @@ import { const appAssetLimit = 4 * 1024 * 1024; const headerLimit = 16 * 1024; -const webSocketBufferLimit = 2 * 1024 * 1024; -const webSocketMessageLimit = 1_048_576; -const pendingWebSocketMessageLimit = 64; -const upstreamHandshakeTimeout = 15_000; const upstreamRequestTimeout = 15_000; const loopbackHosts = new Set(['127.0.0.1', '::1']); -const hmrTokenMaxLength = 4_096; +/** + * Proxy-owned browser push channel. The proxy authors both ends: its server + * broadcasts only the owned reload frame below, and the bootstrap shell it + * serves is the only intended client. Rsbuild's WebSocket envelope is never + * dialed, parsed, or forwarded here, so Rsbuild upgrades cannot silently + * change Runtime App reload behavior. + */ +export const runtimeClientSurfaceReloadChannelPath = '/__agent_bundle_runtime/reload'; +const reloadMessageKind = 'runtime-app-reload'; +const reloadMessageLimit = 256; const endpointKeys = Object.freeze([ 'entryPath', 'httpOrigin', 'httpPathPrefixes', + 'subscribeReload', 'surfaceId', - 'webSocketOrigin', - 'webSocketPath', - 'webSocketToken', ] as const); const contentPolicyKeys = Object.freeze(['contentSecurityPolicy'] as const); @@ -62,10 +65,8 @@ interface ValidatedEndpoint { readonly host: string; readonly httpOrigin: URL; readonly httpPathPrefixes: readonly string[]; + readonly subscribeReload: (listener: () => void) => () => void; readonly surfaceId: string; - readonly webSocketOrigin: URL; - readonly webSocketPath: string; - readonly webSocketToken: string; } const invalidEndpoint = (message: string): never => { @@ -176,7 +177,7 @@ const runtimeProxyShell = ( entryDocument: string, hostOrigin: string, childContentSecurityPolicy: string, - webSocketPath: string, + initialReloadGeneration: number, ): string => ` @@ -190,10 +191,11 @@ const runtimeProxyShell = ( const initialEntry = ${escapedScriptValue(entryDocument)}; const hostOrigin = ${escapedScriptValue(hostOrigin)}; const childContentSecurityPolicy = ${escapedScriptValue(childContentSecurityPolicy)}; - const webSocketPath = ${escapedScriptValue(webSocketPath)}; + const reloadChannelPath = ${escapedScriptValue(runtimeClientSurfaceReloadChannelPath)}; + const reloadMessageKind = ${escapedScriptValue(reloadMessageKind)}; const maxAppToHostMessageBytes = ${String(runtimeAppMessageLimits.appToHostBytes)}; const maxHostToAppMessageBytes = ${String(runtimeAppMessageLimits.hostToAppBytes)}; - const maxHmrMessageBytes = maxAppToHostMessageBytes; + const maxReloadMessageBytes = ${String(reloadMessageLimit)}; const maxEntryBytes = ${String(appAssetLimit)}; const finiteOrdinaryJsonByteLength = ${runtimeAppFiniteOrdinaryJsonByteLength.toString()}; const allowedKeys = new Set(['error', 'id', 'jsonrpc', 'method', 'params', 'result']); @@ -201,9 +203,10 @@ const runtimeProxyShell = ( let lifecycle = 'created'; const maxPendingHostMessages = 32; let pendingHostMessages = []; - let hmr; - let hmrReconnectAttempts = 0; - let hmrReconnectTimer; + let reloadSocket; + let reloadReconnectAttempts = 0; + let reloadReconnectTimer; + let reloadGeneration = ${String(initialReloadGeneration)}; let refreshController; let refreshGeneration = 0; let refreshing = false; @@ -263,46 +266,50 @@ const runtimeProxyShell = ( } }; installEntry(initialEntry); - const reconnectHmr = () => { - if (lifecycle === 'closed' || hmrReconnectTimer !== undefined) return; - const delay = Math.min(1_000, 100 * (2 ** Math.min(hmrReconnectAttempts, 4))); - hmrReconnectAttempts += 1; - hmrReconnectTimer = setTimeout(() => { - hmrReconnectTimer = undefined; - openHmr(); + const reconnectReloadChannel = () => { + if (lifecycle === 'closed' || reloadReconnectTimer !== undefined) return; + const delay = Math.min(1_000, 100 * (2 ** Math.min(reloadReconnectAttempts, 4))); + reloadReconnectAttempts += 1; + reloadReconnectTimer = setTimeout(() => { + reloadReconnectTimer = undefined; + openReloadChannel(); }, delay); }; - const openHmr = () => { - if (lifecycle === 'closed' || hmr !== undefined) return; + const openReloadChannel = () => { + if (lifecycle === 'closed' || reloadSocket !== undefined) return; let socket; - try { socket = new WebSocket(new URL(webSocketPath, location.origin).href); } - catch { reconnectHmr(); return; } - hmr = socket; + try { socket = new WebSocket(new URL(reloadChannelPath, location.origin).href); } + catch { reconnectReloadChannel(); return; } + reloadSocket = socket; const reconnect = () => { - if (hmr !== socket) return; - hmr = undefined; + if (reloadSocket !== socket) return; + reloadSocket = undefined; try { socket.close(); } catch {} - reconnectHmr(); + reconnectReloadChannel(); }; socket.addEventListener('open', () => { - if (hmr !== socket || lifecycle === 'closed') return; - hmrReconnectAttempts = 0; + if (reloadSocket !== socket || lifecycle === 'closed') return; + reloadReconnectAttempts = 0; }); socket.addEventListener('message', (event) => { - if (hmr !== socket || lifecycle === 'closed' || typeof event.data !== 'string' || event.data.length > maxHmrMessageBytes) return; + if (reloadSocket !== socket || lifecycle === 'closed' || typeof event.data !== 'string' || event.data.length > maxReloadMessageBytes) return; let message; try { message = JSON.parse(event.data); } catch { return; } - if (!isRecord(message) || typeof message.type !== 'string') return; - // Rsbuild documents Environment.hot.send('full-reload'), but not this - // raw WebSocket envelope. In 2.2.1 it empirically arrives as JSON with a - // top-level type; only our provider-emitted reload kind is actionable. - // Private kinds such as ok/hash and unknown future kinds stay inert. - if (message.type === 'full-reload') void refreshEntry(); + // The proxy authors this channel end to end; only its owned reload kind + // exists. The strictly increasing generation makes duplicate frames and + // the on-connect resync idempotent, and it catches up on reloads that + // fired while the socket was down: the server replays its current + // generation on every accepted connection. + if (!isRecord(message) || message.kind !== reloadMessageKind) return; + const generation = message.generation; + if (typeof generation !== 'number' || !Number.isSafeInteger(generation) || generation <= reloadGeneration) return; + reloadGeneration = generation; + void refreshEntry(); }); socket.addEventListener('close', reconnect); socket.addEventListener('error', reconnect); }; - openHmr(); + openReloadChannel(); addEventListener('message', (event) => { if (lifecycle === 'closed') return; if (event.source === parent) { @@ -344,11 +351,11 @@ const runtimeProxyShell = ( addEventListener('pagehide', () => { lifecycle = 'closed'; pendingHostMessages = []; - if (hmrReconnectTimer !== undefined) clearTimeout(hmrReconnectTimer); - hmrReconnectTimer = undefined; - const activeHmr = hmr; - hmr = undefined; - try { activeHmr?.close(); } catch {} + if (reloadReconnectTimer !== undefined) clearTimeout(reloadReconnectTimer); + reloadReconnectTimer = undefined; + const activeReloadSocket = reloadSocket; + reloadSocket = undefined; + try { activeReloadSocket?.close(); } catch {} const activeRefresh = refreshController; refreshController = undefined; try { activeRefresh?.abort(); } catch {} @@ -367,12 +374,6 @@ const hasBody = (request: IncomingMessage): boolean => { return request.headers['transfer-encoding'] !== undefined; }; -const rawDataBytes = (data: WebSocket.RawData): number => typeof data === 'string' - ? Buffer.byteLength(data) - : Array.isArray(data) - ? data.reduce((total, part) => total + part.byteLength, 0) - : data.byteLength; - interface CanonicalPath { readonly normalized: string; readonly upstream: string; @@ -416,19 +417,19 @@ const literalHost = (value: URL): string => value.hostname.startsWith('[') && va ? value.hostname.slice(1, -1) : value.hostname; -const origin = (value: string, protocol: 'http:' | 'ws:'): URL => { +const origin = (value: string): URL => { let parsed: URL; try { parsed = new URL(value); } catch { - return invalidEndpoint(`a literal loopback ${protocol === 'http:' ? 'HTTP' : 'WebSocket'} origin`); + return invalidEndpoint('a literal loopback HTTP origin'); } if ( - parsed.protocol !== protocol || !loopbackHosts.has(literalHost(parsed)) || + parsed.protocol !== 'http:' || !loopbackHosts.has(literalHost(parsed)) || parsed.username.length > 0 || parsed.password.length > 0 || parsed.pathname !== '/' || parsed.search.length > 0 || parsed.hash.length > 0 ) { - return invalidEndpoint(`a literal loopback ${protocol === 'http:' ? 'HTTP' : 'WebSocket'} origin`); + return invalidEndpoint('a literal loopback HTTP origin'); } return parsed; }; @@ -470,12 +471,8 @@ const endpoint = (input: DevRuntimeClientSurfaceEndpoint): ValidatedEndpoint => if (typeof surfaceId !== 'string' || surfaceId.length === 0 || surfaceId.includes('\0')) { invalidEndpoint('a nonempty surface id'); } - const httpOrigin = origin(endpointValue(input, 'httpOrigin'), 'http:'); - const webSocketOrigin = origin(endpointValue(input, 'webSocketOrigin'), 'ws:'); + const httpOrigin = origin(endpointValue(input, 'httpOrigin')); const host = literalHost(httpOrigin); - if (host !== literalHost(webSocketOrigin) || httpOrigin.port !== webSocketOrigin.port) { - invalidEndpoint('matching host and port for HTTP and WebSocket origins'); - } const declaredPrefixes = endpointValue(input, 'httpPathPrefixes'); if (!Array.isArray(declaredPrefixes) || declaredPrefixes.length === 0) { invalidEndpoint('at least one declared HTTP path prefix'); @@ -483,22 +480,15 @@ const endpoint = (input: DevRuntimeClientSurfaceEndpoint): ValidatedEndpoint => const httpPathPrefixes = Object.freeze([...new Set(declaredPrefixes.map(prefix))]); const entryPath = canonicalPath(endpointValue(input, 'entryPath')).normalized; if (!matchesPrefix(entryPath, httpPathPrefixes)) invalidEndpoint('an entry path within a declared HTTP prefix'); - const webSocketPath = canonicalPath(endpointValue(input, 'webSocketPath')).upstream; - const webSocketToken = endpointValue(input, 'webSocketToken'); - // Rsbuild documents webSocketToken only as a string; its current - // base64url-like alphabet and length are empirical, not API guarantees. - if (typeof webSocketToken !== 'string' || webSocketToken.length === 0 || webSocketToken.length > hmrTokenMaxLength) { - invalidEndpoint('a nonempty bounded Rsbuild WebSocket token'); - } + const subscribeReload = endpointValue(input, 'subscribeReload'); + if (typeof subscribeReload !== 'function') invalidEndpoint('a provider-owned subscribeReload function'); return Object.freeze({ entryPath, host, httpOrigin, httpPathPrefixes, + subscribeReload: subscribeReload as ValidatedEndpoint['subscribeReload'], surfaceId, - webSocketOrigin, - webSocketPath, - webSocketToken, }); }; @@ -602,12 +592,14 @@ export class RuntimeClientSurfaceProxy { const upstreamAborts = new Set<() => void>(); const upstreamRequests = new Set(); const upstreamSockets = new Set(); - const webSockets = new Set(); - const webSocketServer = new WebSocketServer({ maxPayload: webSocketMessageLimit, noServer: true }); + const reloadClients = new Set(); + const webSocketServer = new WebSocketServer({ maxPayload: reloadMessageLimit, noServer: true }); let activeConnections = 0; let bootstrapUsed = false; let closed = false; let closePromise: Promise | undefined; + let reloadGeneration = 0; + let reloadSubscription: (() => void) | undefined; const emit = (type: RuntimeClientSurfaceConnectionEvent['type']): void => { try { @@ -617,6 +609,21 @@ export class RuntimeClientSurfaceProxy { } }; + const sendReloadFrame = (client: WebSocket): void => { + if (client.readyState !== WebSocket.OPEN) return; + try { + client.send(JSON.stringify({ generation: reloadGeneration, kind: reloadMessageKind })); + } catch { + client.terminate(); + } + }; + + const announceReload = (): void => { + if (closed) return; + reloadGeneration += 1; + for (const client of [...reloadClients]) sendReloadFrame(client); + }; + const isAuthenticated = (request: IncomingMessage): boolean => cookieValue(request, cookieName) === sessionCapability; @@ -685,6 +692,10 @@ export class RuntimeClientSurfaceProxy { return; } bootstrapUsed = true; + // Capture the reload generation before fetching the entry: a reload + // that lands during the fetch then reads as newer than this shell's + // baseline, so the channel refreshes it instead of losing it. + const bootstrapReloadGeneration = reloadGeneration; let entryDocument: string; try { entryDocument = await readCurrentEntry(); @@ -705,7 +716,7 @@ export class RuntimeClientSurfaceProxy { entryDocument, trustedHostOrigin, trustedContentSecurityPolicy, - trusted.webSocketPath, + bootstrapReloadGeneration, )); return; } @@ -838,76 +849,37 @@ export class RuntimeClientSurfaceProxy { return reject(404); } if (!isAuthenticated(request)) return reject(403); - if (requestUrl.pathname !== trusted.webSocketPath) return reject(404); + if (requestUrl.pathname !== runtimeClientSurfaceReloadChannelPath) return reject(404); if (requestUrl.search.length > 0) return reject(404); if (request.headers.origin !== proxyOrigin) return reject(403); - const requestedProtocols = typeof request.headers['sec-websocket-protocol'] === 'string' - ? request.headers['sec-websocket-protocol'].split(',').map((protocol) => protocol.trim()).filter(Boolean) - : []; - webSocketServer.handleUpgrade(request, socket, head, (downstream) => { - const upstreamUrl = new URL(trusted.webSocketPath, trusted.webSocketOrigin); - upstreamUrl.searchParams.set('token', trusted.webSocketToken); - const upstream = new WebSocket(upstreamUrl, requestedProtocols.length > 0 ? requestedProtocols : undefined, { - handshakeTimeout: upstreamHandshakeTimeout, - maxPayload: webSocketMessageLimit, - }); - webSockets.add(downstream); - webSockets.add(upstream); - let announced = false; - let pairClosed = false; - let pendingDownstreamBytes = 0; - let pendingDownstreamMessages = 0; - const pendingDownstream: Array> = []; - const closePair = (): void => { - if (pairClosed) return; - pairClosed = true; - if (announced) { - activeConnections -= 1; - emit('disconnected'); - } - webSockets.delete(downstream); - webSockets.delete(upstream); - pendingDownstream.length = 0; - pendingDownstreamBytes = 0; - pendingDownstreamMessages = 0; - if (downstream.readyState === WebSocket.OPEN || downstream.readyState === WebSocket.CONNECTING) downstream.terminate(); - if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) upstream.terminate(); - }; - const overBackpressure = (): boolean => downstream.bufferedAmount > webSocketBufferLimit || upstream.bufferedAmount > webSocketBufferLimit; - const forward = (destination: WebSocket, data: WebSocket.RawData, isBinary: boolean): void => { - if (pairClosed || destination.readyState !== WebSocket.OPEN) return; - destination.send(data, { binary: isBinary }, () => { - if (overBackpressure()) closePair(); - }); - if (overBackpressure()) closePair(); + // The owned channel has no subprotocols; a client negotiating one is + // not the relay shell this proxy installed. + if (request.headers['sec-websocket-protocol'] !== undefined) return reject(403); + webSocketServer.handleUpgrade(request, socket, head, (client) => { + if (closed) { + client.terminate(); + return; + } + reloadClients.add(client); + activeConnections += 1; + emit('connected'); + let released = false; + const release = (): void => { + if (released) return; + released = true; + reloadClients.delete(client); + activeConnections -= 1; + emit('disconnected'); + if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) client.terminate(); }; - upstream.once('open', () => { - if (pairClosed) return; - announced = true; - activeConnections += 1; - emit('connected'); - for (const pending of pendingDownstream.splice(0)) forward(upstream, pending.data, pending.isBinary); - pendingDownstreamBytes = 0; - pendingDownstreamMessages = 0; - }); - downstream.on('message', (data, isBinary) => { - if (upstream.readyState === WebSocket.CONNECTING) { - pendingDownstreamBytes += rawDataBytes(data); - pendingDownstreamMessages += 1; - if (pendingDownstreamBytes > webSocketMessageLimit || pendingDownstreamMessages > pendingWebSocketMessageLimit) { - closePair(); - return; - } - pendingDownstream.push(Object.freeze({ data, isBinary })); - return; - } - forward(upstream, data, isBinary); - }); - upstream.on('message', (data, isBinary) => forward(downstream, data, isBinary)); - downstream.once('close', closePair); - upstream.once('close', closePair); - downstream.once('error', closePair); - upstream.once('error', closePair); + // The channel is strictly proxy-to-relay; an inbound frame means the + // peer is not the installed shell, so release it instead of buffering. + client.on('message', release); + client.once('close', release); + client.once('error', release); + // Replay the current generation so a shell that reconnects after a + // missed reload refreshes instead of silently staying stale. + sendReloadFrame(client); }); }); @@ -934,23 +906,38 @@ export class RuntimeClientSurfaceProxy { if (closePromise !== undefined) return closePromise; closed = true; closePromise = (async () => { + try { + reloadSubscription?.(); + } catch { + // Provider-side unsubscribe failures must not block browser release. + } + reloadSubscription = undefined; for (const abort of [...upstreamAborts]) abort(); for (const request of upstreamRequests) request.destroy(); upstreamAgent.destroy(); for (const socket of upstreamSockets) socket.destroy(); - for (const socket of webSockets) socket.terminate(); + for (const socket of reloadClients) socket.terminate(); for (const socket of sockets) socket.destroy(); webSocketServer.close(); await closeServer(server); })(); return closePromise; }; + try { + const subscription = trusted.subscribeReload(announceReload); + if (typeof subscription !== 'function') { + throw new TypeError('Runtime client surface endpoint must return a reload unsubscriber.'); + } + reloadSubscription = subscription; + } catch (error) { + await close(); + throw error; + } return Object.freeze({ bootstrapUrl: `${proxyOrigin}${bootstrapPath}`, close, origin: proxyOrigin, surfaceId: trusted.surfaceId, - webSocketPath: trusted.webSocketPath, }); } } diff --git a/packages/agent-bundle/src/dev/runtime-provider.ts b/packages/agent-bundle/src/dev/runtime-provider.ts index f9bc203f8..c082204ce 100644 --- a/packages/agent-bundle/src/dev/runtime-provider.ts +++ b/packages/agent-bundle/src/dev/runtime-provider.ts @@ -26,12 +26,15 @@ export interface DevRuntimeClientSurfaceEndpoint { readonly entryPath: string; readonly httpOrigin: string; readonly httpPathPrefixes: readonly string[]; + /** + * Provider-owned Runtime App reload authority. The provider invokes every + * subscribed listener after a successful, changed App environment compile; + * the returned function detaches that listener. This is the only reload + * signal the core relay consumes — Rsbuild's private WebSocket frames are + * not part of the contract. + */ + readonly subscribeReload: (listener: () => void) => () => void; readonly surfaceId: string; - readonly webSocketOrigin: string; - /** Normalized public `dev.client.path` from the runtime compiler. */ - readonly webSocketPath: string; - /** Rsbuild compiler credential; server-only and never serialized to a browser surface. */ - readonly webSocketToken: string; } /** Core-owned, server-only proxy handle; the host plan may embed only bootstrapUrl. */ @@ -39,7 +42,6 @@ export interface DevRuntimeClientSurfaceProxyBinding { readonly bootstrapUrl: string; readonly origin: string; readonly surfaceId: string; - readonly webSocketPath: string; close(): Promise; } diff --git a/packages/agent-bundle/tests/dev-workbench.test.ts b/packages/agent-bundle/tests/dev-workbench.test.ts index 8ba7841db..226697ef7 100644 --- a/packages/agent-bundle/tests/dev-workbench.test.ts +++ b/packages/agent-bundle/tests/dev-workbench.test.ts @@ -1104,8 +1104,8 @@ it('prepares the optional runtime once with the development config context befor let server: Awaited> | undefined; let failedServer: Awaited> | undefined; const boundOrigins: string[] = []; - let resolveSurface: ((binding: { readonly bootstrapUrl: string; close(): Promise; readonly origin: string; readonly surfaceId: string; readonly webSocketPath: '/rsbuild-hmr' }) => void) | undefined; - const pendingSurface = new Promise<{ readonly bootstrapUrl: string; close(): Promise; readonly origin: string; readonly surfaceId: string; readonly webSocketPath: '/rsbuild-hmr' }>((resolvePromise) => { + let resolveSurface: ((binding: { readonly bootstrapUrl: string; close(): Promise; readonly origin: string; readonly surfaceId: string }) => void) | undefined; + const pendingSurface = new Promise<{ readonly bootstrapUrl: string; close(): Promise; readonly origin: string; readonly surfaceId: string }>((resolvePromise) => { resolveSurface = resolvePromise; }); let proxyCalls = 0; @@ -1129,7 +1129,7 @@ it('prepares the optional runtime once with the development config context befor ' storageRoot: context.storageRoot,', ' }));', ' return {', - " clientSurface: (surfaceId) => surfaceId === 'timeline' ? { entryPath: '/', httpOrigin: 'http://127.0.0.1:41111', httpPathPrefixes: ['/'], surfaceId, webSocketOrigin: 'ws://127.0.0.1:41111', webSocketPath: '/rsbuild-hmr', webSocketToken: 'rsbuild-token-1234' } : undefined,", + " clientSurface: (surfaceId) => surfaceId === 'timeline' ? { entryPath: '/', httpOrigin: 'http://127.0.0.1:41111', httpPathPrefixes: ['/'], subscribeReload: () => () => undefined, surfaceId } : undefined,", ' close: async () => undefined,', ' mcpRegistry: {},', ' providerSessionId: context.providerSessionId,', @@ -1204,7 +1204,6 @@ it('prepares the optional runtime once with the development config context befor close: async () => { surfaceCloseCalls += 1; }, origin: 'http://127.0.0.1:41112', surfaceId: 'timeline', - webSocketPath: '/rsbuild-hmr', }); await expect(opening).rejects.toThrow('closed'); await expect(closing).resolves.toBeUndefined(); @@ -1223,7 +1222,6 @@ it('prepares the optional runtime once with the development config context befor close: async () => { failedCloseCalls += 1; throw new Error('Completed client surface close failed.'); }, origin: 'http://127.0.0.1:41113', surfaceId: 'timeline', - webSocketPath: '/rsbuild-hmr', }), }, }); diff --git a/packages/agent-bundle/tests/mcp-app-runtime-preview-service.test.ts b/packages/agent-bundle/tests/mcp-app-runtime-preview-service.test.ts index 1e028872d..deabb2450 100644 --- a/packages/agent-bundle/tests/mcp-app-runtime-preview-service.test.ts +++ b/packages/agent-bundle/tests/mcp-app-runtime-preview-service.test.ts @@ -157,7 +157,7 @@ const createRuntimeFixture = (options: Readonly<{ openedClientSurfacePolicies.push(policy[0]); if (options.failProxyOpen === true) throw new Error('proxy open failed'); if (options.openRuntimeClientSurface !== undefined) return options.openRuntimeClientSurface(surfaceId, ...policy); - return Object.freeze({ bootstrapUrl: `http://proxy.test/${surfaceId}`, close: options.closeProxy ?? (async () => undefined), origin: 'http://proxy.test', surfaceId, webSocketPath: '/rsbuild-hmr' }); + return Object.freeze({ bootstrapUrl: `http://proxy.test/${surfaceId}`, close: options.closeProxy ?? (async () => undefined), origin: 'http://proxy.test', surfaceId }); }, runtime, ...(options.operationClock === undefined ? {} : { operationClock: options.operationClock }), @@ -234,7 +234,7 @@ it('derives an Apps preview only from one stored succeeded run and opens its dis expect(preview).toMatchObject({ binding: { sessionId: 'session-a', sessionRevision: 2 }, - clientSurface: { bootstrapUrl: 'http://proxy.test/mcp.edit-weather', origin: 'http://proxy.test', webSocketPath: '/rsbuild-hmr' }, + clientSurface: { bootstrapUrl: 'http://proxy.test/mcp.edit-weather', origin: 'http://proxy.test' }, kind: 'apps', result: { isError: false, modelVisible: { content: [{ text: 'Sunny', type: 'text' }] } }, session: { state: 'ready' }, @@ -510,7 +510,6 @@ it('joins a late runtime client-surface acquisition before manual session cleanu close: async () => { proxyCloseCalls += 1; }, origin: 'http://proxy.test', surfaceId: 'app.weather', - webSocketPath: '/rsbuild-hmr', })); await closing; await expect(creating).rejects.toThrow('stable session changed'); diff --git a/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts b/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts index bd068bd4a..44d9121f4 100644 --- a/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts +++ b/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts @@ -2,12 +2,31 @@ import { createServer, get as httpGet, globalAgent, type IncomingMessage, type S import type { AddressInfo } from 'node:net'; import { expect, it } from '@rstest/core'; -import WebSocket, { WebSocketServer } from 'ws'; +import WebSocket from 'ws'; -import { RuntimeClientSurfaceProxy as RuntimeClientSurfaceProxyImplementation, type DevRuntimeClientSurfaceEndpoint } from '../src/dev/index.ts'; +import { + RuntimeClientSurfaceProxy as RuntimeClientSurfaceProxyImplementation, + runtimeClientSurfaceReloadChannelPath, + type DevRuntimeClientSurfaceEndpoint, +} from '../src/dev/index.ts'; import { runtimeAppMessageLimits } from '../src/dev/runtime-app-message-limits.ts'; const foregroundOrigin = 'http://127.0.0.1:41999'; +const noopSubscribeReload = (): (() => void) => () => undefined; +const reloadFrame = (generation: number): string => JSON.stringify({ generation, kind: 'runtime-app-reload' }); + +/** Provider-side reload authority stub: the trusted channel the proxy relays. */ +const createReloadSource = () => { + const listeners = new Set<() => void>(); + return Object.freeze({ + emit: (): void => { for (const listener of [...listeners]) listener(); }, + listenerCount: (): number => listeners.size, + subscribeReload: (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + }); +}; const RuntimeClientSurfaceProxy = Object.freeze({ open: ( input: DevRuntimeClientSurfaceEndpoint, @@ -197,9 +216,7 @@ it('keeps malformed opaque-child initialize messages from advancing the trusted httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined); try { const shell = await runtimeProxyShellHarness(binding); @@ -248,7 +265,7 @@ it('keeps malformed opaque-child initialize messages from advancing the trusted } }); -it('refreshes only for explicit full-reload frames while reconnecting the outer HMR socket', async () => { +it('refreshes only for owned reload frames with advancing generations while reconnecting the channel', async () => { const upstream = createServer((request, response) => { if (serveBootstrapEntry(request, response)) return; response.writeHead(404).end(); @@ -259,29 +276,37 @@ it('refreshes only for explicit full-reload frames while reconnecting the outer httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined); try { const shell = await runtimeProxyShellHarness(binding); - expect(shell.sockets.map((socket) => socket.url)).toEqual([`${binding.origin}/rsbuild-hmr`]); + expect(shell.sockets.map((socket) => socket.url)).toEqual([`${binding.origin}${runtimeClientSurfaceReloadChannelPath}`]); shell.sockets[0]!.emit('close'); shell.sockets[0]!.emit('error'); expect(shell.pendingTimers()).toBe(1); shell.runTimers(); expect(shell.sockets.map((socket) => socket.url)).toEqual([ - `${binding.origin}/rsbuild-hmr`, - `${binding.origin}/rsbuild-hmr`, + `${binding.origin}${runtimeClientSurfaceReloadChannelPath}`, + `${binding.origin}${runtimeClientSurfaceReloadChannelPath}`, ]); shell.sockets[1]!.emit('open'); - shell.sockets[1]!.emit('message', { data: JSON.stringify({ type: 'ok' }) }); + // Frames that are not this channel's owned reload protocol stay inert: + // Rsbuild's private envelope (including its full-reload kind), unknown + // kinds, missing/invalid generations, and non-advancing generations. + shell.sockets[1]!.emit('message', { data: JSON.stringify({ type: 'full-reload' }) }); shell.sockets[1]!.emit('message', { data: JSON.stringify({ type: 'ok' }) }); shell.sockets[1]!.emit('message', { data: JSON.stringify({ type: 'hash', data: 'private-hash' }) }); - shell.sockets[1]!.emit('message', { data: JSON.stringify({ type: 'future-private-frame' }) }); + shell.sockets[1]!.emit('message', { data: JSON.stringify({ kind: 'runtime-app-reload' }) }); + shell.sockets[1]!.emit('message', { data: JSON.stringify({ generation: 1.5, kind: 'runtime-app-reload' }) }); + shell.sockets[1]!.emit('message', { data: JSON.stringify({ generation: 0, kind: 'runtime-app-reload' }) }); + shell.sockets[1]!.emit('message', { data: JSON.stringify({ generation: 1, kind: 'future-private-frame' }) }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(shell.entries).toHaveLength(1); - shell.sockets[1]!.emit('message', { data: JSON.stringify({ type: 'full-reload' }) }); + shell.sockets[1]!.emit('message', { data: reloadFrame(1) }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(shell.entries).toHaveLength(2); + // Replaying an already-seen generation never refreshes the child again. + shell.sockets[1]!.emit('message', { data: reloadFrame(1) }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(shell.entries).toHaveLength(2); shell.pagehide(); @@ -313,9 +338,7 @@ it('prefixes every opaque child entry with the closed CSP supplied by its truste httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined, foregroundOrigin, Object.freeze({ contentSecurityPolicy: childPolicy }) as never); try { const shell = await runtimeProxyShellHarness(binding, { @@ -325,9 +348,11 @@ it('prefixes every opaque child entry with the closed CSP supplied by its truste }); const prefix = ''; expect(shell.entries).toEqual([`${prefix}${initialEntry}`]); - expect(shell.source).not.toContain('rsbuild-token-1234'); + expect(shell.source).not.toContain('full-reload'); + expect(shell.source).toContain(runtimeClientSurfaceReloadChannelPath); - shell.sockets[0]!.emit('message', { data: JSON.stringify({ type: 'full-reload' }) }); + shell.sockets[0]!.emit('open'); + shell.sockets[0]!.emit('message', { data: reloadFrame(1) }); await new Promise((resolvePromise) => setTimeout(resolvePromise, 0)); expect(shell.entries).toEqual([`${prefix}${initialEntry}`, `${prefix}${refreshedEntry}`]); } finally { @@ -348,9 +373,7 @@ it('uses an exact empty-domain child CSP for direct non-MCP surface opens', asyn httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined); try { const shell = await runtimeProxyShellHarness(binding); @@ -375,9 +398,7 @@ it('rejects a custom-prototype child policy before opening a proxy binding', asy httpOrigin: 'http://127.0.0.1:41998', httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: 'ws://127.0.0.1:41998', - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined, foregroundOrigin, policy as never)).rejects.toThrow('plain policy record'); }); @@ -392,9 +413,7 @@ it('does not reinstall the opaque child when a held refresh fetch resolves after httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined); let resolveFetch: ((response: Response) => void) | undefined; let refreshSignal: AbortSignal | null | undefined; @@ -406,7 +425,8 @@ it('does not reinstall the opaque child when a held refresh fetch resolves after return heldFetch; }, }); - shell.sockets[0]!.emit('message', { data: JSON.stringify({ type: 'full-reload' }) }); + shell.sockets[0]!.emit('open'); + shell.sockets[0]!.emit('message', { data: reloadFrame(1) }); shell.pagehide(); resolveFetch?.(new Response('
late fetch
', { headers: { 'content-type': 'text/html; charset=utf-8' }, @@ -436,9 +456,7 @@ it('does not reinstall the opaque child when held refresh text resolves after pa httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined); let resolveText: ((entry: string) => void) | undefined; let refreshSignal: AbortSignal | null | undefined; @@ -459,7 +477,8 @@ it('does not reinstall the opaque child when held refresh text resolves after pa return response; }, }); - shell.sockets[0]!.emit('message', { data: JSON.stringify({ type: 'full-reload' }) }); + shell.sockets[0]!.emit('open'); + shell.sockets[0]!.emit('message', { data: reloadFrame(1) }); await new Promise((resolvePromise) => setTimeout(resolvePromise, 0)); expect(textStarted).toBe(true); shell.pagehide(); @@ -478,7 +497,7 @@ it('does not reinstall the opaque child when held refresh text resolves after pa } }); -it('uses a one-use bootstrap capability before proxying only the declared app and Rsbuild HMR endpoints', async () => { +it('uses a one-use bootstrap capability before serving declared app assets and the owned reload channel', async () => { const upstream = createServer((request, response) => { if (request.url === '/app/index.html') { response.writeHead(200, { 'content-type': 'text/html' }).end('
runtime app
'); @@ -486,29 +505,24 @@ it('uses a one-use bootstrap capability before proxying only the declared app an } response.writeHead(404).end(); }); - const origin = await listen(upstream); - const webSocketServer = new WebSocketServer({ noServer: true }); - let receivedUpgrade: { readonly protocol: string | undefined; readonly url: string | undefined } | undefined; - upstream.on('upgrade', (request, socket, head) => { - receivedUpgrade = { protocol: request.headers['sec-websocket-protocol'], url: request.url }; - webSocketServer.handleUpgrade(request, socket, head, (client) => { - webSocketServer.emit('connection', client, request); - }); + let upstreamUpgrades = 0; + upstream.on('upgrade', (_request, socket) => { + upstreamUpgrades += 1; + socket.destroy(); }); - webSocketServer.on('connection', (socket) => socket.on('message', (message) => socket.send(message))); - + const origin = await listen(upstream); + const reloadSource = createReloadSource(); const events: unknown[] = []; const binding = await RuntimeClientSurfaceProxy.open({ entryPath: '/app/index.html', httpOrigin: origin, - httpPathPrefixes: ['/app/', '/rsbuild-hmr'], + httpPathPrefixes: ['/app/'], + subscribeReload: reloadSource.subscribeReload, surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', } satisfies DevRuntimeClientSurfaceEndpoint, (event) => events.push(event)); try { + expect(reloadSource.listenerCount()).toBe(1); const first = await fetch(binding.bootstrapUrl, { redirect: 'manual' }); expect(first.status).toBe(200); expect(first.headers.get('content-security-policy')).toContain(`frame-ancestors ${foregroundOrigin}`); @@ -539,26 +553,31 @@ it('uses a one-use bootstrap capability before proxying only the declared app an await expect(asset.text()).resolves.toContain('runtime app'); expect((await fetch(`${binding.origin}/not-declared.js`, { headers: { cookie } })).status).toBe(404); - const proxied = new WebSocket(`${binding.origin.replace('http:', 'ws:')}/rsbuild-hmr`, 'rsbuild', { + const client = new WebSocket(`${binding.origin.replace('http:', 'ws:')}${runtimeClientSurfaceReloadChannelPath}`, { headers: { cookie, origin: binding.origin }, }); - const response = new Promise((resolvePromise, rejectPromise) => { - proxied.once('message', (message) => resolvePromise(message.toString())); - proxied.once('error', rejectPromise); - }); + const frames: string[] = []; + client.on('message', (message) => frames.push(message.toString())); await new Promise((resolvePromise, rejectPromise) => { - proxied.once('open', resolvePromise); - proxied.once('error', rejectPromise); + client.once('open', resolvePromise); + client.once('error', rejectPromise); }); - proxied.send('refresh'); - await expect(response).resolves.toBe('refresh'); - proxied.close(); + // The proxy replays its current generation on connect, then broadcasts + // one advanced generation per provider reload event. + await expect.poll(() => frames, { timeout: 5_000 }).toEqual([reloadFrame(0)]); + reloadSource.emit(); + reloadSource.emit(); + await expect.poll(() => frames, { timeout: 5_000 }).toEqual([reloadFrame(0), reloadFrame(1), reloadFrame(2)]); + client.close(); - expect(receivedUpgrade).toEqual({ protocol: 'rsbuild', url: '/rsbuild-hmr?token=rsbuild-token-1234' }); + // The compiler upstream saw HTTP asset requests only; the reload channel + // terminates at the proxy and never dials Rsbuild's WebSocket. + expect(upstreamUpgrades).toBe(0); expect(events).toContainEqual({ connectionCount: 1, surfaceId: 'app.weather', type: 'connected' }); + await binding.close(); + expect(reloadSource.listenerCount()).toBe(0); } finally { await binding.close(); - webSocketServer.clients.forEach((client) => client.terminate()); upstream.closeAllConnections(); await close(upstream); } @@ -575,9 +594,7 @@ it('stops serving the bootstrap path after its binding closes', async () => { httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined); try { @@ -597,105 +614,71 @@ it('rejects noncanonical foreground origins before exposing a bootstrap capabili httpOrigin: 'http://127.0.0.1:39001', httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: 'ws://127.0.0.1:39001', - webSocketPath: '/rsbuild-hmr' as const, - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }; await expect(RuntimeClientSurfaceProxyImplementation.open(endpoint, () => undefined, 'http://127.0.0.1:42000/not-origin')) .rejects.toThrow('canonical foreground'); }); -it('percent-encodes a bounded public HMR token while keeping it out of the browser URL', async () => { +it('admits only clean authenticated same-origin upgrades on the owned reload channel', async () => { const upstream = createServer((request, response) => { if (serveBootstrapEntry(request, response)) return; response.writeHead(404).end(); }); const origin = await listen(upstream); - const webSocketServer = new WebSocketServer({ noServer: true }); - let receivedUpgrade: Readonly<{ - readonly cookie: string | undefined; - readonly origin: string | undefined; - readonly url: string | undefined; - }> | undefined; - let resolveUpgrade!: () => void; - const upgraded = new Promise((resolvePromise) => { resolveUpgrade = resolvePromise; }); - upstream.on('upgrade', (request, socket, head) => { - receivedUpgrade = Object.freeze({ - cookie: request.headers.cookie, - origin: request.headers.origin, - url: request.url, - }); - resolveUpgrade(); - webSocketServer.handleUpgrade(request, socket, head, (client) => webSocketServer.emit('connection', client, request)); - }); - const webSocketToken = 'token with /?+%= punctuation'; - const webSocketPath = '/custom%20runtime-hmr'; const binding = await RuntimeClientSurfaceProxy.open({ entryPath: '/app/index.html', httpOrigin: origin, httpPathPrefixes: ['/app/'], + subscribeReload: noopSubscribeReload, surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath, - webSocketToken, - } as DevRuntimeClientSurfaceEndpoint, () => undefined); + }, () => undefined); - try { - const cookie = await bootstrapCookie(binding); - expect(binding.webSocketPath).toBe(webSocketPath); - const rejected = new WebSocket(`${binding.origin.replace('http:', 'ws:')}${webSocketPath}?token=leaked`, { - headers: { cookie, origin: 'null' }, + const connectionState = async (url: string, headers: Record, protocols?: string[]): Promise<'close' | 'error' | 'open'> => { + const client = new WebSocket(url, protocols, { headers }); + const state = await new Promise<'close' | 'error' | 'open'>((resolvePromise) => { + client.once('close', () => resolvePromise('close')); + client.once('error', () => resolvePromise('error')); + client.once('open', () => resolvePromise('open')); }); - const rejectedState = await new Promise<'close' | 'error' | 'open'>((resolvePromise) => { - rejected.once('close', () => resolvePromise('close')); - rejected.once('error', () => resolvePromise('error')); - rejected.once('open', () => resolvePromise('open')); - }); - if (rejectedState === 'open') rejected.close(); - expect(rejectedState).not.toBe('open'); + if (state === 'open') client.close(); + return state; + }; - const client = new WebSocket(`${binding.origin.replace('http:', 'ws:')}${webSocketPath}`, { - headers: { cookie, origin: binding.origin }, - }); - await new Promise((resolvePromise, rejectPromise) => { - client.once('open', resolvePromise); - client.once('error', rejectPromise); - }); - await upgraded; - client.close(); - expect(receivedUpgrade).toEqual({ - cookie: undefined, - origin: undefined, - url: `${webSocketPath}?${new URLSearchParams({ token: webSocketToken }).toString()}`, - }); + try { + const cookie = await bootstrapCookie(binding); + const channelUrl = `${binding.origin.replace('http:', 'ws:')}${runtimeClientSurfaceReloadChannelPath}`; + // Query strings, foreign origins, missing cookies, undeclared paths, and + // subprotocol negotiation never reach the owned channel. + await expect(connectionState(`${channelUrl}?token=leaked`, { cookie, origin: binding.origin })).resolves.not.toBe('open'); + await expect(connectionState(channelUrl, { cookie, origin: 'null' })).resolves.not.toBe('open'); + await expect(connectionState(channelUrl, { origin: binding.origin })).resolves.not.toBe('open'); + await expect(connectionState(`${binding.origin.replace('http:', 'ws:')}/rsbuild-hmr`, { cookie, origin: binding.origin })).resolves.not.toBe('open'); + await expect(connectionState(channelUrl, { cookie, origin: binding.origin }, ['rsbuild'])).resolves.not.toBe('open'); + await expect(connectionState(channelUrl, { cookie, origin: binding.origin })).resolves.toBe('open'); } finally { await binding.close(); - webSocketServer.clients.forEach((client) => client.terminate()); upstream.closeAllConnections(); await close(upstream); } }); -it('refuses non-loopback and mismatched compiler endpoints before opening a browser origin', async () => { +it('refuses non-loopback compiler endpoints and reload channels without a provider authority', async () => { await expect(RuntimeClientSurfaceProxy.open({ entryPath: '/app/index.html', httpOrigin: 'https://compiler.example', httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: 'wss://compiler.example', - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined)).rejects.toThrow('loopback'); await expect(RuntimeClientSurfaceProxy.open({ entryPath: '/app/index.html', httpOrigin: 'http://127.0.0.1:3000', httpPathPrefixes: ['/app/'], + subscribeReload: undefined as unknown as DevRuntimeClientSurfaceEndpoint['subscribeReload'], surfaceId: 'app.weather', - webSocketOrigin: 'ws://127.0.0.1:3001', - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', - }, () => undefined)).rejects.toThrow('matching host and port'); + }, () => undefined)).rejects.toThrow('subscribeReload'); }); it('never lets double-encoded traversal or delimiters escape a declared HTTP prefix', async () => { @@ -714,9 +697,7 @@ it('never lets double-encoded traversal or delimiters escape a declared HTTP pre httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined); try { @@ -738,18 +719,12 @@ it('isolates browser cookie capabilities across concurrent client-surface bindin response.writeHead(200).end('ok'); }); const origin = await listen(upstream); - const webSocketServer = new WebSocketServer({ noServer: true }); - upstream.on('upgrade', (request, socket, head) => webSocketServer.handleUpgrade(request, socket, head, (client) => { - webSocketServer.emit('connection', client, request); - })); const endpoint = { entryPath: '/app/index.html', httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr' as const, - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }; const [first, second] = await Promise.all([ RuntimeClientSurfaceProxy.open(endpoint, () => undefined), @@ -761,7 +736,7 @@ it('isolates browser cookie capabilities across concurrent client-surface bindin expect((await fetch(`${first.origin}/app/index.html`, { headers: { cookie } })).status).toBe(200); expect((await fetch(`${second.origin}/app/index.html`, { headers: { cookie } })).status).toBe(200); await Promise.all([first, second].map((binding) => new Promise((resolvePromise, rejectPromise) => { - const client = new WebSocket(`${binding.origin.replace('http:', 'ws:')}/rsbuild-hmr`, { headers: { cookie, origin: binding.origin } }); + const client = new WebSocket(`${binding.origin.replace('http:', 'ws:')}${runtimeClientSurfaceReloadChannelPath}`, { headers: { cookie, origin: binding.origin } }); client.once('open', () => { client.close(); resolvePromise(); @@ -770,7 +745,6 @@ it('isolates browser cookie capabilities across concurrent client-surface bindin }))); } finally { await Promise.all([first.close(), second.close()]); - webSocketServer.clients.forEach((client) => client.terminate()); upstream.closeAllConnections(); await close(upstream); } @@ -789,9 +763,7 @@ it('aborts a hanging upstream request when the downstream closes or its binding httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined); try { @@ -840,9 +812,7 @@ it('keeps an in-flight binding B request alive when binding A closes on the same httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr' as const, - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }; const originalMaxSockets = globalAgent.maxSockets; globalAgent.maxSockets = 1; @@ -878,9 +848,7 @@ it('bounds an upstream HTTP request before headers arrive', async () => { httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined); try { @@ -895,64 +863,55 @@ it('bounds an upstream HTTP request before headers arrive', async () => { } }, 20_000); -it('bounds an unacknowledged upstream HMR handshake and pre-open message floods', async () => { - const upstreamSockets = new Set(); +it('releases a reload-channel client that writes into the strictly one-way channel', async () => { const upstream = createServer((request, response) => { serveBootstrapEntry(request, response); }); - upstream.on('upgrade', (_request, socket) => { - upstreamSockets.add(socket); - socket.once('close', () => upstreamSockets.delete(socket)); - }); const origin = await listen(upstream); - const endpoint = { + const events: unknown[] = []; + const reloadSource = createReloadSource(); + const binding = await RuntimeClientSurfaceProxy.open({ entryPath: '/app/index.html', httpOrigin: origin, httpPathPrefixes: ['/app/'], + subscribeReload: reloadSource.subscribeReload, surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr' as const, - webSocketToken: 'rsbuild-token-1234', - }; - - const floodUntilClosed = async (payload: Buffer): Promise => { - const binding = await RuntimeClientSurfaceProxy.open(endpoint, () => undefined); - try { - const cookie = await bootstrapCookie(binding); - const client = new WebSocket(`${binding.origin.replace('http:', 'ws:')}/rsbuild-hmr`, { headers: { cookie, origin: binding.origin } }); - const closed = new Promise((resolvePromise) => client.once('close', () => resolvePromise())); - client.once('error', () => undefined); - await new Promise((resolvePromise, rejectPromise) => { - client.once('open', resolvePromise); - client.once('error', rejectPromise); - }); - for (let index = 0; index < 65; index += 1) client.send(payload); - await expect(within(closed, 1_000)).resolves.toBeUndefined(); - } finally { - await binding.close(); - } - }; + }, (event) => events.push(event)); try { - await floodUntilClosed(Buffer.alloc(0)); - await floodUntilClosed(Buffer.from([1])); - - const binding = await RuntimeClientSurfaceProxy.open(endpoint, () => undefined); - try { - const cookie = await bootstrapCookie(binding); - const client = new WebSocket(`${binding.origin.replace('http:', 'ws:')}/rsbuild-hmr`, { headers: { cookie, origin: binding.origin } }); + const cookie = await bootstrapCookie(binding); + const connectAndSend = async (payload: Buffer | string): Promise => { + const client = new WebSocket(`${binding.origin.replace('http:', 'ws:')}${runtimeClientSurfaceReloadChannelPath}`, { headers: { cookie, origin: binding.origin } }); const closed = new Promise((resolvePromise) => client.once('close', () => resolvePromise())); client.once('error', () => undefined); await new Promise((resolvePromise, rejectPromise) => { client.once('open', resolvePromise); client.once('error', rejectPromise); }); - await expect(within(closed, 16_000)).resolves.toBeUndefined(); - } finally { - await binding.close(); - } + client.send(payload); + await expect(within(closed, 1_000)).resolves.toBeUndefined(); + }; + await connectAndSend(Buffer.alloc(0)); + await connectAndSend('{"kind":"runtime-app-reload","generation":99}'); + // Every writing client was released, so the channel still serves a + // well-behaved relay afterwards. + const frames: string[] = []; + const client = new WebSocket(`${binding.origin.replace('http:', 'ws:')}${runtimeClientSurfaceReloadChannelPath}`, { headers: { cookie, origin: binding.origin } }); + client.on('message', (message) => frames.push(message.toString())); + await new Promise((resolvePromise, rejectPromise) => { + client.once('open', resolvePromise); + client.once('error', rejectPromise); + }); + reloadSource.emit(); + await expect.poll(() => frames, { timeout: 5_000 }).toEqual([reloadFrame(0), reloadFrame(1)]); + client.close(); + expect(events.filter((event) => (event as { readonly type: string }).type === 'connected')).toHaveLength(3); + await expect.poll( + () => events.filter((event) => (event as { readonly type: string }).type === 'disconnected'), + { timeout: 5_000 }, + ).toHaveLength(3); } finally { - for (const socket of upstreamSockets) socket.destroy(); + await binding.close(); upstream.closeAllConnections(); await close(upstream); } @@ -973,9 +932,7 @@ it('destroys declared-oversize upstream bodies instead of releasing their bindin httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined); try { @@ -1004,9 +961,7 @@ it('destroys endless redirect bodies before releasing their binding', async () = httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined); try { @@ -1035,9 +990,7 @@ it('keeps a completed 502 response intact when a response body stalls after head httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined); try { @@ -1071,9 +1024,7 @@ it('bounds chunked upstream assets and releases their socket immediately', async httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.weather', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined); try { @@ -1093,9 +1044,7 @@ it('accepts matched literal IPv6 loopback endpoints and rejects other IPv6 hosts httpOrigin: 'http://[::1]:39201', httpPathPrefixes: ['/app/'], surfaceId: 'app.ipv6', - webSocketOrigin: 'ws://[::1]:39201', - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined); await binding.close(); @@ -1104,9 +1053,7 @@ it('accepts matched literal IPv6 loopback endpoints and rejects other IPv6 hosts httpOrigin: 'http://[::2]:39201', httpPathPrefixes: ['/app/'], surfaceId: 'app.ipv6', - webSocketOrigin: 'ws://[::2]:39201', - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined)).rejects.toThrow('loopback'); }); @@ -1122,9 +1069,7 @@ it('proxies through literal IPv6 loopback when the host supports it', async () = httpOrigin: origin, httpPathPrefixes: ['/app/'], surfaceId: 'app.ipv6', - webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + subscribeReload: noopSubscribeReload, }, () => undefined); try { diff --git a/packages/agent-bundle/tests/runtime-provider.test.ts b/packages/agent-bundle/tests/runtime-provider.test.ts index 8b7dd0df0..f02986043 100644 --- a/packages/agent-bundle/tests/runtime-provider.test.ts +++ b/packages/agent-bundle/tests/runtime-provider.test.ts @@ -222,10 +222,8 @@ it('refreshes controller endpoint snapshots before publishing a later runtime ac entryPath: '/', httpOrigin: 'http://127.0.0.1:43111', httpPathPrefixes: ['/'], + subscribeReload: () => () => undefined, surfaceId: surface.id, - webSocketOrigin: 'ws://127.0.0.1:43111', - webSocketPath: '/rsbuild-hmr' as const, - webSocketToken: 'rsbuild-token-1234', }; const session = { clientSurface: () => activated ? endpoint : undefined, diff --git a/packages/workbench/src/mcp/mcp-app-client.ts b/packages/workbench/src/mcp/mcp-app-client.ts index 9d9bc93fd..efaa52ace 100644 --- a/packages/workbench/src/mcp/mcp-app-client.ts +++ b/packages/workbench/src/mcp/mcp-app-client.ts @@ -741,8 +741,7 @@ const runtimePreview = (value: unknown, foregroundOrigin: string): McpAppPreview session: runtimeSession(record.session, binding), }; if (record.kind === 'fallback') return Object.freeze({ ...base, kind: 'fallback', profile: runtimeProfile(record.profile, binding, 'fallback') }) as unknown as McpAppPreviewSnapshot; - const clientSurface = runtimeRecord(record.clientSurface, ['bootstrapUrl', 'origin', 'webSocketPath']); - if (clientSurface.webSocketPath !== '/rsbuild-hmr') runtimeInvalid('Runtime MCP App route returned an invalid client surface.'); + const clientSurface = runtimeRecord(record.clientSurface, ['bootstrapUrl', 'origin']); const bootstrapUrl = runtimeText(clientSurface.bootstrapUrl, 'client bootstrap URL'); const clientOrigin = origin(clientSurface.origin); let bootstrap: URL; @@ -756,7 +755,7 @@ const runtimePreview = (value: unknown, foregroundOrigin: string): McpAppPreview } return Object.freeze({ ...base, - clientSurface: Object.freeze({ bootstrapUrl, origin: clientOrigin, webSocketPath: '/rsbuild-hmr' }), + clientSurface: Object.freeze({ bootstrapUrl, origin: clientOrigin }), documentPolicy: runtimeDocumentPolicy(record.documentPolicy), kind: 'apps', profile: runtimeProfile(record.profile, binding, 'apps'), diff --git a/packages/workbench/tests/mcp-app-client.test.ts b/packages/workbench/tests/mcp-app-client.test.ts index 75a3e9f37..e70ad2cc0 100644 --- a/packages/workbench/tests/mcp-app-client.test.ts +++ b/packages/workbench/tests/mcp-app-client.test.ts @@ -125,7 +125,7 @@ const runtimePreview = Object.freeze({ serverDigest: 'server-a', serverName: 'weather', sessionId: 'runtime-session-a', sessionRevision: 2, target: 'portable', transportDigest: 'transport-a', }), - clientSurface: Object.freeze({ bootstrapUrl: 'http://127.0.0.1:43124/app-bootstrap', origin: 'http://127.0.0.1:43124', webSocketPath: '/rsbuild-hmr' }), + clientSurface: Object.freeze({ bootstrapUrl: 'http://127.0.0.1:43124/app-bootstrap', origin: 'http://127.0.0.1:43124' }), documentPolicy: runtimePolicy, kind: 'apps', metadata: Object.freeze({ resource: runtimeMetadata, result: runtimeMetadata, tool: runtimeMetadata }), diff --git a/packages/workbench/tests/mcp-app-frame.test.ts b/packages/workbench/tests/mcp-app-frame.test.ts index 0f97a6f73..5141823f2 100644 --- a/packages/workbench/tests/mcp-app-frame.test.ts +++ b/packages/workbench/tests/mcp-app-frame.test.ts @@ -113,7 +113,7 @@ const mountedSecureRendererFixture = async () => { `const bootstrapUrl = ${JSON.stringify(bootstrapUrl)};`, "const policy = { allow: '', approvedPermissions: {}, revision: 1, warnings: [] };", "const metadata = { extensions: { claude: {}, openai: {} }, provenance: {}, raw: {}, standard: {} };", - "const preview = { binding: { definitionDigest: 'definition-a', evidence: 'simulated', id: 'runtime-binding', profileId: 'portable', profileVersion: 'agent-bundle:mcp-apps:2026-01-26', registryRevision: 3, runVector: { runtimeGenerationId: 'generation-a', sourceRevision: 'source-a', stateVersion: 1 }, serverDigest: 'server-a', serverName: 'weather', sessionId: 'runtime-session-a', sessionRevision: 2, target: 'portable', transportDigest: 'transport-a' }, clientSurface: { bootstrapUrl, origin: new URL(bootstrapUrl).origin, webSocketPath: '/rsbuild-hmr' }, documentPolicy: policy, kind: 'apps', metadata: { resource: metadata, result: metadata, tool: metadata }, operations: [], profile: { bootstrap: { kind: 'none' }, configExtensions: { entries: [], sourceRevision: 'source-a' }, descriptor: { claimsRealHostParity: false, evidence: 'simulated', id: 'portable', label: 'Portable MCP Apps', version: 'agent-bundle:mcp-apps:2026-01-26' }, hostContext: { availableDisplayModes: ['inline'], containerDimensions: { height: 720, width: 1024 }, deviceCapabilities: {}, displayMode: 'inline', locale: 'en-US', platform: 'web', safeAreaInsets: { bottom: 0, left: 0, right: 0, top: 0 }, styles: {}, theme: 'light', timeZone: 'UTC', toolInfo: {}, userAgent: 'agent-bundle-runtime-mcp-app/1' }, kind: 'apps', metadata, permissions: { camera: {}, geolocation: {} }, resourceUri: 'ui://weather/app.html', warnings: [] }, resource: { html: '
Weather
', permissions: { camera: {}, geolocation: {} } }, result: { appVisible: { content: [] }, isError: false, modelVisible: {} }, session: { binding: { definitionDigest: 'definition-a', registryRevision: 3, serverDigest: 'server-a', serverName: 'weather', sessionId: 'runtime-session-a', sessionRevision: 2, target: 'portable', transportDigest: 'transport-a' }, connection: { capabilities: { tools: {} }, protocolEra: 'modern', protocolVersion: '2026-01-26', server: { name: 'weather', version: '1.0.0' } }, state: 'ready' } };", + "const preview = { binding: { definitionDigest: 'definition-a', evidence: 'simulated', id: 'runtime-binding', profileId: 'portable', profileVersion: 'agent-bundle:mcp-apps:2026-01-26', registryRevision: 3, runVector: { runtimeGenerationId: 'generation-a', sourceRevision: 'source-a', stateVersion: 1 }, serverDigest: 'server-a', serverName: 'weather', sessionId: 'runtime-session-a', sessionRevision: 2, target: 'portable', transportDigest: 'transport-a' }, clientSurface: { bootstrapUrl, origin: new URL(bootstrapUrl).origin }, documentPolicy: policy, kind: 'apps', metadata: { resource: metadata, result: metadata, tool: metadata }, operations: [], profile: { bootstrap: { kind: 'none' }, configExtensions: { entries: [], sourceRevision: 'source-a' }, descriptor: { claimsRealHostParity: false, evidence: 'simulated', id: 'portable', label: 'Portable MCP Apps', version: 'agent-bundle:mcp-apps:2026-01-26' }, hostContext: { availableDisplayModes: ['inline'], containerDimensions: { height: 720, width: 1024 }, deviceCapabilities: {}, displayMode: 'inline', locale: 'en-US', platform: 'web', safeAreaInsets: { bottom: 0, left: 0, right: 0, top: 0 }, styles: {}, theme: 'light', timeZone: 'UTC', toolInfo: {}, userAgent: 'agent-bundle-runtime-mcp-app/1' }, kind: 'apps', metadata, permissions: { camera: {}, geolocation: {} }, resourceUri: 'ui://weather/app.html', warnings: [] }, resource: { html: '
Weather
', permissions: { camera: {}, geolocation: {} } }, result: { appVisible: { content: [] }, isError: false, modelVisible: {} }, session: { binding: { definitionDigest: 'definition-a', registryRevision: 3, serverDigest: 'server-a', serverName: 'weather', sessionId: 'runtime-session-a', sessionRevision: 2, target: 'portable', transportDigest: 'transport-a' }, connection: { capabilities: { tools: {} }, protocolEra: 'modern', protocolVersion: '2026-01-26', server: { name: 'weather', version: '1.0.0' } }, state: 'ready' } };", "const response = (body) => new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' }, status: 200 });", "const foreground = new ForegroundRouteClient({ fetch: async (input, init) => { const path = new URL(String(input), location.origin).pathname; if (path === '/api/project/session') return response({ cookieName: 'agent-bundle-foreground-session-0123456789abcdef0123456789abcdef', instanceId: 'foreground-instance-a', origin: location.origin, token: 'foreground-secret' }); if (path === '/api/runtime/apps') return response({ preview }); if (path === '/api/runtime/apps/runtime-binding' && init?.method === 'DELETE') return response({ closed: true }); throw new Error('Unexpected runtime fixture request ' + path); } }); const runtime = new McpAppClient({ foreground });", "const trace = []; const iframeNodes = new Set();", diff --git a/packages/workbench/tests/mcp-app-preview-browser.test.ts b/packages/workbench/tests/mcp-app-preview-browser.test.ts index 7ed22153d..af15cba02 100644 --- a/packages/workbench/tests/mcp-app-preview-browser.test.ts +++ b/packages/workbench/tests/mcp-app-preview-browser.test.ts @@ -56,7 +56,7 @@ const mountedPreviewFixture = async () => { `const runtimeBootstrapUrl = ${JSON.stringify(bootstrapUrl)};`, "const response = (body) => new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' }, status: 200 });", "const initialRuntimePolicy = () => ({ allow: '', approvedPermissions: {}, revision: 1, warnings: [] });", - "const runtimePreviewFor = (suffix) => { const generation = 'generation-' + suffix; const source = 'source-' + suffix; const sessionId = 'runtime-session-' + suffix; const bindingId = 'runtime-binding-' + suffix; const binding = { definitionDigest: 'definition-' + suffix, evidence: 'simulated', id: bindingId, profileId: 'portable', profileVersion: 'agent-bundle:mcp-apps:2026-01-26', registryRevision: 3, runVector: { runtimeGenerationId: generation, sourceRevision: source, stateVersion: suffix === 'a' ? 1 : 2 }, serverDigest: 'server-' + suffix, serverName: 'weather', sessionId, sessionRevision: suffix === 'a' ? 2 : 3, target: 'weather', transportDigest: 'transport-' + suffix }; const stable = { definitionDigest: binding.definitionDigest, registryRevision: binding.registryRevision, serverDigest: binding.serverDigest, serverName: binding.serverName, sessionId: binding.sessionId, sessionRevision: binding.sessionRevision, target: binding.target, transportDigest: binding.transportDigest }; const metadata = { extensions: { claude: { nativeHooks: 'native-hooks-must-stay-hidden', opaque: 'opaque-value-must-stay-hidden', root: '/private/workspace/agent-bundle.config.ts' }, openai: { opaque: 'unregistered-openai-must-stay-hidden' } }, provenance: {}, raw: {}, standard: {} }; const configExtensions = { entries: [{ configured: true, id: 'extension:claude', key: 'claude', provenance: { kind: 'config', sourcePath: 'agent-bundle.config.ts' }, target: 'claude' }, { configured: true, id: 'extension:codex', key: 'codex', provenance: { kind: 'config', sourcePath: 'agent-bundle.config.ts' }, target: 'codex' }, { configured: true, id: 'extension:portable', key: 'portable', provenance: { kind: 'config', sourcePath: 'agent-bundle.config.ts' }, target: 'portable' }], sourceRevision: source }; return { binding, clientSurface: { bootstrapUrl: runtimeBootstrapUrl, origin: new URL(runtimeBootstrapUrl).origin, webSocketPath: '/rsbuild-hmr' }, documentPolicy: initialRuntimePolicy(), kind: 'apps', metadata: { resource: metadata, result: metadata, tool: metadata }, operations: [], profile: { bootstrap: { kind: 'none' }, configExtensions, descriptor: { claimsRealHostParity: false, evidence: 'simulated', id: 'portable', label: 'Portable MCP Apps', version: 'agent-bundle:mcp-apps:2026-01-26' }, hostContext: { availableDisplayModes: ['inline'], containerDimensions: { height: 720, width: 1024 }, deviceCapabilities: {}, displayMode: 'inline', locale: 'en-US', platform: 'web', safeAreaInsets: { bottom: 0, left: 0, right: 0, top: 0 }, styles: {}, theme: 'light', timeZone: 'UTC', toolInfo: {}, userAgent: 'agent-bundle-runtime-mcp-app/1' }, kind: 'apps', metadata, permissions: { camera: {} }, resourceUri: 'ui://weather/app.html', warnings: [] }, resource: { html: '
Weather
', permissions: { camera: {} } }, result: { appVisible: { content: [] }, isError: false, modelVisible: {} }, session: { binding: stable, connection: { capabilities: { tools: {} }, protocolEra: 'modern', protocolVersion: '2026-01-26', server: { name: 'weather', version: '1.0.0' } }, state: 'ready' } }; };", + "const runtimePreviewFor = (suffix) => { const generation = 'generation-' + suffix; const source = 'source-' + suffix; const sessionId = 'runtime-session-' + suffix; const bindingId = 'runtime-binding-' + suffix; const binding = { definitionDigest: 'definition-' + suffix, evidence: 'simulated', id: bindingId, profileId: 'portable', profileVersion: 'agent-bundle:mcp-apps:2026-01-26', registryRevision: 3, runVector: { runtimeGenerationId: generation, sourceRevision: source, stateVersion: suffix === 'a' ? 1 : 2 }, serverDigest: 'server-' + suffix, serverName: 'weather', sessionId, sessionRevision: suffix === 'a' ? 2 : 3, target: 'weather', transportDigest: 'transport-' + suffix }; const stable = { definitionDigest: binding.definitionDigest, registryRevision: binding.registryRevision, serverDigest: binding.serverDigest, serverName: binding.serverName, sessionId: binding.sessionId, sessionRevision: binding.sessionRevision, target: binding.target, transportDigest: binding.transportDigest }; const metadata = { extensions: { claude: { nativeHooks: 'native-hooks-must-stay-hidden', opaque: 'opaque-value-must-stay-hidden', root: '/private/workspace/agent-bundle.config.ts' }, openai: { opaque: 'unregistered-openai-must-stay-hidden' } }, provenance: {}, raw: {}, standard: {} }; const configExtensions = { entries: [{ configured: true, id: 'extension:claude', key: 'claude', provenance: { kind: 'config', sourcePath: 'agent-bundle.config.ts' }, target: 'claude' }, { configured: true, id: 'extension:codex', key: 'codex', provenance: { kind: 'config', sourcePath: 'agent-bundle.config.ts' }, target: 'codex' }, { configured: true, id: 'extension:portable', key: 'portable', provenance: { kind: 'config', sourcePath: 'agent-bundle.config.ts' }, target: 'portable' }], sourceRevision: source }; return { binding, clientSurface: { bootstrapUrl: runtimeBootstrapUrl, origin: new URL(runtimeBootstrapUrl).origin }, documentPolicy: initialRuntimePolicy(), kind: 'apps', metadata: { resource: metadata, result: metadata, tool: metadata }, operations: [], profile: { bootstrap: { kind: 'none' }, configExtensions, descriptor: { claimsRealHostParity: false, evidence: 'simulated', id: 'portable', label: 'Portable MCP Apps', version: 'agent-bundle:mcp-apps:2026-01-26' }, hostContext: { availableDisplayModes: ['inline'], containerDimensions: { height: 720, width: 1024 }, deviceCapabilities: {}, displayMode: 'inline', locale: 'en-US', platform: 'web', safeAreaInsets: { bottom: 0, left: 0, right: 0, top: 0 }, styles: {}, theme: 'light', timeZone: 'UTC', toolInfo: {}, userAgent: 'agent-bundle-runtime-mcp-app/1' }, kind: 'apps', metadata, permissions: { camera: {} }, resourceUri: 'ui://weather/app.html', warnings: [] }, resource: { html: '
Weather
', permissions: { camera: {} } }, result: { appVisible: { content: [] }, isError: false, modelVisible: {} }, session: { binding: stable, connection: { capabilities: { tools: {} }, protocolEra: 'modern', protocolVersion: '2026-01-26', server: { name: 'weather', version: '1.0.0' } }, state: 'ready' } }; };", "let runtimePreview; let runtimeOperationTraces = []; let operationSequence = 0; let heldCreate; let bridgeCloseFailures = 0; let backendCloseFailures = 0; let unregisterThrows = 0; let currentHandle; let suspendedTransition; const runtimeEvents = []; const runtimeIframes = new Set(); const runtimeTrace = [];", "const originalSetAttribute = Element.prototype.setAttribute; Element.prototype.setAttribute = function(name, value) { if (this instanceof HTMLIFrameElement && ['allow', 'referrerpolicy', 'sandbox', 'src'].includes(name)) runtimeTrace.push({ name, value: String(value) }); return originalSetAttribute.call(this, name, value); };", "const observer = new MutationObserver((records) => { for (const record of records) for (const node of record.addedNodes) { if (node instanceof HTMLIFrameElement) runtimeIframes.add(node); if (node instanceof Element) node.querySelectorAll('iframe').forEach((frame) => runtimeIframes.add(frame)); } }); observer.observe(document.documentElement, { childList: true, subtree: true });", diff --git a/packages/workbench/tests/mcp-app-preview.test.ts b/packages/workbench/tests/mcp-app-preview.test.ts index 39fc6d4cf..53df1ad7f 100644 --- a/packages/workbench/tests/mcp-app-preview.test.ts +++ b/packages/workbench/tests/mcp-app-preview.test.ts @@ -137,7 +137,7 @@ const runtimePreview = Object.freeze({ target: 'weather', transportDigest: 'transport-weather', }), - clientSurface: Object.freeze({ bootstrapUrl: 'http://127.0.0.1:43124/runtime-app', origin: 'http://127.0.0.1:43124', webSocketPath: '/rsbuild-hmr' as const }), + clientSurface: Object.freeze({ bootstrapUrl: 'http://127.0.0.1:43124/runtime-app', origin: 'http://127.0.0.1:43124' }), documentPolicy: Object.freeze({ allow: '', approvedPermissions: Object.freeze({}), revision: 1, warnings: Object.freeze([]) }), kind: 'apps' as const, metadata: Object.freeze({ resource: Object.freeze({}), result: Object.freeze({}), tool: Object.freeze({}) }), diff --git a/packages/workbench/tests/mcp-app-real.e2e.test.ts b/packages/workbench/tests/mcp-app-real.e2e.test.ts index f5abf0b79..5dead3b7f 100644 --- a/packages/workbench/tests/mcp-app-real.e2e.test.ts +++ b/packages/workbench/tests/mcp-app-real.e2e.test.ts @@ -6,6 +6,7 @@ import type { Page, WebSocketRoute } from 'playwright'; import { agentBundleNodeModules, workbenchNodeModules } from '../../agent-bundle/tests/helpers/workspace-paths.ts'; import { createWorkbenchAssetSource } from '../../agent-bundle/src/dev/workbench-assets.ts'; +import { runtimeClientSurfaceReloadChannelPath } from '../../agent-bundle/src/dev/index.ts'; import { startDevServer } from '../../agent-bundle/src/dev/workbench-server.ts'; import { createProjectFixture, removeProjectFixture } from '../../agent-bundle/tests/helpers/project-fixture.ts'; import { startRuntimePlaygroundFixture } from './helpers/runtime-playground-fixture.ts'; @@ -515,7 +516,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await route.abort(); }); page.on('websocket', (socket) => { - if (new URL(socket.url()).pathname === '/rsbuild-hmr') runtimePreviewSockets.push(socket.url()); + if (new URL(socket.url()).pathname === runtimeClientSurfaceReloadChannelPath) runtimePreviewSockets.push(socket.url()); }); page.on('request', (request) => { const url = new URL(request.url()); @@ -553,7 +554,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', clientPage.on('pageerror', (error) => pageErrors.push(error)); clientPage.on('console', (message) => { clientSurfaceConsole.push(`${message.type()}:${message.text()}`); }); clientPage.on('request', (request) => { - if (new URL(request.url()).pathname !== '/rsbuild-hmr') return; + if (new URL(request.url()).pathname !== runtimeClientSurfaceReloadChannelPath) return; clientSurfaceHmrRequests.push(Object.freeze({ headers: request.headers(), url: request.url() })); }); clientPage.on('response', (response) => { @@ -575,13 +576,13 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', sockets: clientSurfaceSockets, })}`); } - expect(clientSurfaceSockets).toEqual([`${clientSurface.origin.replace('http:', 'ws:')}/rsbuild-hmr`]); + expect(clientSurfaceSockets).toEqual([`${clientSurface.origin.replace('http:', 'ws:')}${runtimeClientSurfaceReloadChannelPath}`]); expect(clientSurfaceSockets.every((socket) => new URL(socket).search.length === 0)).toBe(true); expect(clientSurfaceHmrRequests.every((request) => new URL(request.url).search.length === 0)).toBe(true); await clientPage.close(); clientPage = undefined; await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 3_000 }).toBe('0'); - await page.routeWebSocket((url) => url.pathname === '/rsbuild-hmr', (route) => { + await page.routeWebSocket((url) => url.pathname === runtimeClientSurfaceReloadChannelPath, (route) => { runtimePreviewHmrRoutes.push(route); route.connectToServer(); }); @@ -637,7 +638,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await expect(outerFrame).toHaveAttribute('sandbox', 'allow-scripts allow-same-origin'); await expect(outerFrame).toHaveAttribute('referrerpolicy', 'no-referrer'); await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 15_000 * timeScale }).toBe('1'); - expect(runtimePreviewSockets).toEqual([`${created.preview.clientSurface.origin.replace('http:', 'ws:')}/rsbuild-hmr`]); + expect(runtimePreviewSockets).toEqual([`${created.preview.clientSurface.origin.replace('http:', 'ws:')}${runtimeClientSurfaceReloadChannelPath}`]); const runtimeAppFrame = async () => { for (const frame of page.frames()) { if (await frame.getByRole('heading', { name: 'Runtime edit timeline' }).count() === 1) return frame; @@ -904,7 +905,10 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 15_000 * timeScale }).toBe('0'); await expect.poll(() => runtimePreviewHmrRoutes.length, { timeout: 15_000 * timeScale }).toBe(2); await expect.poll(() => runtimeIdentity.getAttribute('data-runtime-hmr-client-count'), { timeout: 15_000 * timeScale }).toBe('1'); + // Simulate an upgraded relay frame: only the owned reload protocol with a + // strictly newer generation may reinstall the App child. runtimePreviewHmrRoutes[1]!.send(JSON.stringify({ type: 'full-reload' })); + runtimePreviewHmrRoutes[1]!.send(JSON.stringify({ generation: 1_000_000, kind: 'runtime-app-reload' })); await expect.poll(() => initializeRequests().length, { timeout: 15_000 * timeScale }).toBe(initialInitializeCount + 1); await expect(outerFrame).toHaveCount(1); await expect.poll(() => runtimeAppResponses.filter((entry) => entry.method === 'POST' && entry.path === '/api/runtime/apps').length, { timeout: 15_000 * timeScale }).toBe(1); diff --git a/packages/workbench/tests/mcp-page-app-browser.test.ts b/packages/workbench/tests/mcp-page-app-browser.test.ts index 16b0d5f06..cd7d26d35 100644 --- a/packages/workbench/tests/mcp-page-app-browser.test.ts +++ b/packages/workbench/tests/mcp-page-app-browser.test.ts @@ -129,7 +129,7 @@ const mountedPageFixture = async (mode: 'artifact' | 'runtime' | 'runtime-direct "const runtimeBinding = { definitionDigest: 'definition-runtime-weather', evidence: 'simulated', id: 'runtime-binding-weather', profileId: 'portable', profileVersion: 'agent-bundle:mcp-apps:2026-01-26', registryRevision: 4, runVector: { runtimeGenerationId: 'generation-runtime-weather', sourceRevision: 'source-runtime-weather', stateVersion: 1 }, serverDigest: 'server-runtime-weather', serverName: 'runtime-weather', sessionId: 'runtime-session-weather', sessionRevision: 2, target: 'portable', transportDigest: 'transport-runtime-weather' };", "const runtimeStableBinding = { definitionDigest: runtimeBinding.definitionDigest, registryRevision: runtimeBinding.registryRevision, serverDigest: runtimeBinding.serverDigest, serverName: runtimeBinding.serverName, sessionId: runtimeBinding.sessionId, sessionRevision: runtimeBinding.sessionRevision, target: runtimeBinding.target, transportDigest: runtimeBinding.transportDigest };", "const runtimeMetadata = { extensions: { claude: {}, openai: {} }, provenance: {}, raw: {}, standard: {} };", - "const runtimeResponseBinding = { ...runtimeStableBinding }; const runtimePreview = { binding: runtimeBinding, clientSurface: { bootstrapUrl: runtimeBootstrapUrl, origin: new URL(runtimeBootstrapUrl).origin, webSocketPath: '/rsbuild-hmr' }, documentPolicy: runtimePolicy, kind: 'apps', metadata: { resource: runtimeMetadata, result: runtimeMetadata, tool: runtimeMetadata }, operations: [], profile: { bootstrap: { kind: 'none' }, configExtensions: { entries: [], sourceRevision: 'source-runtime-weather' }, descriptor: { claimsRealHostParity: false, evidence: 'simulated', id: 'portable', label: 'Portable MCP Apps', version: 'agent-bundle:mcp-apps:2026-01-26' }, hostContext: { availableDisplayModes: ['inline'], containerDimensions: { height: 720, width: 1024 }, deviceCapabilities: {}, displayMode: 'inline', locale: 'en-US', platform: 'web', safeAreaInsets: { bottom: 0, left: 0, right: 0, top: 0 }, styles: {}, theme: 'light', timeZone: 'UTC', toolInfo: {}, userAgent: 'agent-bundle-runtime-mcp-app/1' }, kind: 'apps', metadata: runtimeMetadata, permissions: {}, resourceUri: 'ui://weather/runtime.html', warnings: [] }, resource: { html: '
Runtime weather
', permissions: {} }, result: { appVisible: { content: [] }, isError: false, modelVisible: {} }, session: { binding: runtimeResponseBinding, connection: { capabilities: { tools: {} }, protocolEra: 'modern', protocolVersion: '2026-01-26', server: { name: 'runtime-weather', version: '1.0.0' } }, state: 'ready' } };", + "const runtimeResponseBinding = { ...runtimeStableBinding }; const runtimePreview = { binding: runtimeBinding, clientSurface: { bootstrapUrl: runtimeBootstrapUrl, origin: new URL(runtimeBootstrapUrl).origin }, documentPolicy: runtimePolicy, kind: 'apps', metadata: { resource: runtimeMetadata, result: runtimeMetadata, tool: runtimeMetadata }, operations: [], profile: { bootstrap: { kind: 'none' }, configExtensions: { entries: [], sourceRevision: 'source-runtime-weather' }, descriptor: { claimsRealHostParity: false, evidence: 'simulated', id: 'portable', label: 'Portable MCP Apps', version: 'agent-bundle:mcp-apps:2026-01-26' }, hostContext: { availableDisplayModes: ['inline'], containerDimensions: { height: 720, width: 1024 }, deviceCapabilities: {}, displayMode: 'inline', locale: 'en-US', platform: 'web', safeAreaInsets: { bottom: 0, left: 0, right: 0, top: 0 }, styles: {}, theme: 'light', timeZone: 'UTC', toolInfo: {}, userAgent: 'agent-bundle-runtime-mcp-app/1' }, kind: 'apps', metadata: runtimeMetadata, permissions: {}, resourceUri: 'ui://weather/runtime.html', warnings: [] }, resource: { html: '
Runtime weather
', permissions: {} }, result: { appVisible: { content: [] }, isError: false, modelVisible: {} }, session: { binding: runtimeResponseBinding, connection: { capabilities: { tools: {} }, protocolEra: 'modern', protocolVersion: '2026-01-26', server: { name: 'runtime-weather', version: '1.0.0' } }, state: 'ready' } };", "const runtimeRun = { completedAt: '2026-08-16T00:00:01.000Z', id: 'runtime-run-weather', input: { city: 'Paris' }, result: { app: { mcpBinding: runtimeStableBinding, resourceUri: 'ui://weather/runtime.html', surfaceId: 'mcp.edit-weather' }, modelVisible: { temperature: 22 }, trace: [], tree: [] }, startedAt: '2026-08-16T00:00:00.000Z', status: 'succeeded', surfaceId: 'mcp.render-weather', target: 'portable', vector: { runtimeGenerationId: 'generation-runtime-weather', sourceRevision: 'source-runtime-weather', stateVersion: 1 } };", "const runtimeProfile = { claimsRealHostParity: false, evidence: 'simulated', id: 'portable', label: 'Portable MCP Apps', version: 'agent-bundle:mcp-apps:2026-01-26' }; const runtimeSurface = { fixtures: [], id: 'mcp.render-weather', kind: 'mcp-app', label: 'Runtime weather', readOnly: false, targets: ['portable'] };", "const runtimeEvents = []; let heldCreate = deferred(); let bridgeCloseFailures = 0; let backendCloseFailures = 0; let registeredPreviewClose; const foreground = new ForegroundRouteClient({ fetch: async (input, init) => { const path = new URL(String(input), location.origin).pathname; if (path === '/api/project/session') { runtimeEvents.push('bootstrap'); return response({ cookieName: 'agent-bundle-foreground-session-0123456789abcdef0123456789abcdef', instanceId: 'foreground-instance-a', origin: location.origin, token: 'foreground-secret' }); } if (path === '/api/runtime/apps' && init?.method === 'POST') { const request = JSON.parse(String(init?.body)); runtimeEvents.push('create:' + request.runId + ':' + request.profileId + ':' + request.expectedGenerationId); return heldCreate.promise; } if (path.startsWith('/api/runtime/apps/') && init?.method === 'DELETE') { const bindingId = decodeURIComponent(path.slice('/api/runtime/apps/'.length)); runtimeEvents.push('backend:' + bindingId); if (backendCloseFailures > 0) { backendCloseFailures -= 1; runtimeEvents.push('backend-failed:' + bindingId); throw new Error('runtime backend close failed'); } return response({ closed: true }); } throw new Error('Unexpected runtime fixture request ' + path); } }); const runtime = new McpAppClient({ foreground });", diff --git a/packages/workbench/tests/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts index a7758f888..58024e4a5 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -10,7 +10,7 @@ import { ArtifactInspectionService } from '../../agent-bundle/src/dev/artifacts/ import { ArtifactService } from '../../agent-bundle/src/dev/artifacts/artifact-service.ts'; import { EpochStore } from '../../agent-bundle/src/dev/epoch-store.ts'; import { EvalService } from '../../agent-bundle/src/dev/eval/eval-service.ts'; -import { ProjectEventHub, startForegroundServer } from '../../agent-bundle/src/dev/index.ts'; +import { ProjectEventHub, runtimeClientSurfaceReloadChannelPath, startForegroundServer } from '../../agent-bundle/src/dev/index.ts'; import { ProjectService } from '../../agent-bundle/src/dev/project-service.ts'; import { SkillDocumentService } from '../../agent-bundle/src/dev/skill-document-service.ts'; import type { ProjectStatus } from '../../agent-bundle/src/dev/types.ts'; @@ -215,7 +215,7 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime }); page.on('pageerror', (error) => pageErrors.push(error)); page.on('websocket', (socket) => { - if (new URL(socket.url()).pathname !== '/rsbuild-hmr') return; + if (new URL(socket.url()).pathname !== runtimeClientSurfaceReloadChannelPath) return; runtimePreviewHmrSockets.push(socket.url()); socket.on('framereceived', (frame) => { runtimePreviewHmrMessages.push(typeof frame.payload === 'string' ? frame.payload : frame.payload.toString()); @@ -360,7 +360,17 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime replaceWatchedSource(fixture.root, fixture.widgetAppSource, editedSource), replaceWatchedSource(fixture.root, fixture.appStyles, editedStyles), ]); - await expect.poll(() => runtimePreviewHmrMessages.some((message) => message === JSON.stringify({ type: 'full-reload' })), { timeout: browserTimeout }) + // The owned reload channel carries provider-authored frames only; a + // changed App compile advances the generation past the connect replay. + const ownedReloadFrames = (): readonly number[] => runtimePreviewHmrMessages.flatMap((message) => { + try { + const parsed = JSON.parse(message) as Readonly<{ readonly generation?: unknown; readonly kind?: unknown }>; + return parsed.kind === 'runtime-app-reload' && typeof parsed.generation === 'number' ? [parsed.generation] : []; + } catch { + return []; + } + }); + await expect.poll(() => ownedReloadFrames().some((generation) => generation > 0), { timeout: browserTimeout }) .toBe(true); const refreshedWidget = async () => { for (const frame of page.frames()) { diff --git a/packages/workbench/tests/runtime-app-bridge.test.ts b/packages/workbench/tests/runtime-app-bridge.test.ts index 201c623b3..e3626247a 100644 --- a/packages/workbench/tests/runtime-app-bridge.test.ts +++ b/packages/workbench/tests/runtime-app-bridge.test.ts @@ -127,7 +127,7 @@ const runtimeFactory = ( onTrace: () => undefined, preview: Object.freeze({ binding: Object.freeze({ id: 'runtime-binding', profileVersion: 'agent-bundle:mcp-apps:2026-01-26', sessionId: 'runtime-session-a', sessionRevision: 3 }), - clientSurface: Object.freeze({ bootstrapUrl: 'https://apps.example.test/proxy', origin: 'https://apps.example.test', webSocketPath: '/rsbuild-hmr' as const }), + clientSurface: Object.freeze({ bootstrapUrl: 'https://apps.example.test/proxy', origin: 'https://apps.example.test' }), documentPolicy: policy.snapshot, kind: 'apps' as const, profile: Object.freeze({ hostContext: Object.freeze({ availableDisplayModes: Object.freeze(['inline']), displayMode: 'inline', safeAreaInsets: Object.freeze({ bottom: 0, left: 0, right: 0, top: 0 }), theme: 'light' }) }), @@ -183,7 +183,7 @@ it('forwards an initialized App tools/call through the controller-owned client t onTrace: () => undefined, preview: Object.freeze({ binding: Object.freeze({ id: 'runtime-binding', profileVersion: 'agent-bundle:mcp-apps:2026-01-26', sessionId: 'runtime-session-a', sessionRevision: 3 }), - clientSurface: Object.freeze({ bootstrapUrl: 'https://apps.example.test/proxy', origin: 'https://apps.example.test', webSocketPath: '/rsbuild-hmr' as const }), + clientSurface: Object.freeze({ bootstrapUrl: 'https://apps.example.test/proxy', origin: 'https://apps.example.test' }), documentPolicy: policy.snapshot, kind: 'apps' as const, profile: Object.freeze({ hostContext: Object.freeze({ availableDisplayModes: Object.freeze(['inline']), displayMode: 'inline', safeAreaInsets: Object.freeze({ bottom: 0, left: 0, right: 0, top: 0 }), theme: 'light' }) }), @@ -903,7 +903,7 @@ it('closes the exact attachment once on bridge setup failure and leaves its retr onTrace: () => undefined, preview: Object.freeze({ binding: Object.freeze({ id: 'runtime-binding', profileVersion: 'agent-bundle:mcp-apps:2026-01-26', sessionId: 'runtime-session-a', sessionRevision: 3 }), - clientSurface: Object.freeze({ bootstrapUrl: 'https://apps.example.test/proxy', origin: 'https://apps.example.test', webSocketPath: '/rsbuild-hmr' as const }), + clientSurface: Object.freeze({ bootstrapUrl: 'https://apps.example.test/proxy', origin: 'https://apps.example.test' }), documentPolicy: policy.snapshot, kind: 'apps' as const, profile: Object.freeze({ hostContext: Object.freeze({ availableDisplayModes: Object.freeze(['inline']), displayMode: 'inline', safeAreaInsets: Object.freeze({ bottom: 0, left: 0, right: 0, top: 0 }), theme: 'light' }) }), @@ -956,7 +956,7 @@ it('guards the official bridge to one source and origin while routing only tools }); const preview = Object.freeze({ binding: Object.freeze({ id: 'runtime-binding', profileVersion: 'agent-bundle:mcp-apps:2026-01-26', sessionId: 'runtime-session-a', sessionRevision: 3 }), - clientSurface: Object.freeze({ bootstrapUrl: 'https://apps.example.test/proxy', origin: 'https://apps.example.test', webSocketPath: '/rsbuild-hmr' as const }), + clientSurface: Object.freeze({ bootstrapUrl: 'https://apps.example.test/proxy', origin: 'https://apps.example.test' }), documentPolicy: policy.snapshot, kind: 'apps' as const, profile: Object.freeze({ hostContext: Object.freeze({ availableDisplayModes: Object.freeze(['inline', 'fullscreen']), displayMode: 'inline', safeAreaInsets: Object.freeze({ bottom: 0, left: 0, right: 0, top: 0 }), theme: 'light' }) }), From 8df8f6ea0b42342bef65706c506064573b8946ee Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:56:08 +0000 Subject: [PATCH 2/2] fix(dev): apply reloads announced during an in-flight shell refresh A reload frame that arrived while the shell's refresh fetch was still in flight advanced the seen generation without being applied, so the newer compilation never installed and the server's replay-on-reconnect stayed inert. Track the applied generation separately from the requested one: advance it only when a refresh actually installs an entry, and run a catch-up refresh when a newer generation was announced mid-flight. A failed refresh now leaves its generation unapplied, so a replayed frame retries instead of being swallowed. --- .../src/dev/runtime-client-surface-proxy.ts | 28 ++++-- .../runtime-client-surface-proxy.test.ts | 96 +++++++++++++++++++ 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts b/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts index 98b22eb48..203a2e036 100644 --- a/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts +++ b/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts @@ -207,6 +207,7 @@ const runtimeProxyShell = ( let reloadReconnectAttempts = 0; let reloadReconnectTimer; let reloadGeneration = ${String(initialReloadGeneration)}; + let appliedReloadGeneration = reloadGeneration; let refreshController; let refreshGeneration = 0; let refreshing = false; @@ -243,6 +244,9 @@ const runtimeProxyShell = ( if (refreshing || lifecycle === 'closed') return; refreshing = true; const generation = refreshGeneration; + // The requested generation this attempt can prove: the fetch starts now, + // so its response reflects at least every reload announced before now. + const target = reloadGeneration; const controller = new AbortController(); refreshController = controller; const current = () => lifecycle !== 'closed' && refreshGeneration === generation && refreshController === controller && !controller.signal.aborted; @@ -255,13 +259,19 @@ const runtimeProxyShell = ( if (!/^text\\/html(?:;|$)/i.test(type)) return; const entry = await response.text(); if (!current()) return; - installEntry(entry); + if (installEntry(entry)) appliedReloadGeneration = target; } catch { - // A failed reload must leave the already-admitted child and its bridge intact. + // A failed reload must leave the already-admitted child and its bridge + // intact. appliedReloadGeneration stays behind, so the next frame — the + // server replays its current generation on every reconnect — retries. } finally { if (refreshController === controller) { refreshController = undefined; refreshing = false; + // A reload announced while this refresh was in flight must not be + // swallowed: this fetch may predate that compilation's output, so + // run another refresh for the newer generation. + if (lifecycle !== 'closed' && reloadGeneration > target) void refreshEntry(); } } }; @@ -296,14 +306,16 @@ const runtimeProxyShell = ( let message; try { message = JSON.parse(event.data); } catch { return; } // The proxy authors this channel end to end; only its owned reload kind - // exists. The strictly increasing generation makes duplicate frames and - // the on-connect resync idempotent, and it catches up on reloads that - // fired while the socket was down: the server replays its current - // generation on every accepted connection. + // exists. Generations already applied stay inert, so duplicate frames + // and the on-connect resync are idempotent, while a generation newer + // than the last applied one always drives a refresh — catching up on + // reloads that fired while the socket was down (the server replays its + // current generation on every accepted connection), that arrived while + // a refresh fetch was already in flight, or whose refresh failed. if (!isRecord(message) || message.kind !== reloadMessageKind) return; const generation = message.generation; - if (typeof generation !== 'number' || !Number.isSafeInteger(generation) || generation <= reloadGeneration) return; - reloadGeneration = generation; + if (typeof generation !== 'number' || !Number.isSafeInteger(generation) || generation <= appliedReloadGeneration) return; + if (generation > reloadGeneration) reloadGeneration = generation; void refreshEntry(); }); socket.addEventListener('close', reconnect); diff --git a/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts b/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts index 44d9121f4..22d9b803f 100644 --- a/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts +++ b/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts @@ -321,6 +321,102 @@ it('refreshes only for owned reload frames with advancing generations while reco } }); +it('applies a reload announced while a refresh fetch is in flight once that fetch settles', async () => { + const upstream = createServer((request, response) => { + if (serveBootstrapEntry(request, response)) return; + response.writeHead(404).end(); + }); + const origin = await listen(upstream); + const binding = await RuntimeClientSurfaceProxy.open({ + entryPath: '/app/index.html', + httpOrigin: origin, + httpPathPrefixes: ['/app/'], + surfaceId: 'app.weather', + subscribeReload: noopSubscribeReload, + }, () => undefined); + const pendingFetches: Array<(response: Response) => void> = []; + try { + const shell = await runtimeProxyShellHarness(binding, { + fetch: async () => new Promise((resolvePromise) => { pendingFetches.push(resolvePromise); }), + }); + shell.sockets[0]!.emit('open'); + shell.sockets[0]!.emit('message', { data: reloadFrame(1) }); + expect(pendingFetches).toHaveLength(1); + // A newer reload lands while the generation-1 fetch is still in flight. + // The single-flight refresh must not swallow it: that fetch may predate + // the newer compilation's output. + shell.sockets[0]!.emit('message', { data: reloadFrame(2) }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(pendingFetches).toHaveLength(1); + pendingFetches[0]!(new Response('
generation one
', { + headers: { 'content-type': 'text/html; charset=utf-8' }, + })); + // Settling the in-flight fetch installs its entry, then immediately runs + // a catch-up refresh for the generation announced mid-flight. + await expect.poll(() => pendingFetches.length, { timeout: 5_000 }).toBe(2); + expect(shell.entries).toHaveLength(2); + pendingFetches[1]!(new Response('
generation two
', { + headers: { 'content-type': 'text/html; charset=utf-8' }, + })); + await expect.poll(() => shell.entries.length, { timeout: 5_000 }).toBe(3); + expect(shell.entries[2]).toContain('generation two'); + // The catch-up refresh proved generation 2, so replaying it stays inert. + shell.sockets[0]!.emit('message', { data: reloadFrame(2) }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(pendingFetches).toHaveLength(2); + } finally { + await binding.close(); + upstream.closeAllConnections(); + await close(upstream); + } +}); + +it('retries a reload whose refresh failed when the channel replays that generation', async () => { + const upstream = createServer((request, response) => { + if (serveBootstrapEntry(request, response)) return; + response.writeHead(404).end(); + }); + const origin = await listen(upstream); + const binding = await RuntimeClientSurfaceProxy.open({ + entryPath: '/app/index.html', + httpOrigin: origin, + httpPathPrefixes: ['/app/'], + surfaceId: 'app.weather', + subscribeReload: noopSubscribeReload, + }, () => undefined); + let attempts = 0; + try { + const shell = await runtimeProxyShellHarness(binding, { + fetch: async () => { + attempts += 1; + if (attempts === 1) throw new Error('transient refresh failure'); + return new Response('
recovered
', { + headers: { 'content-type': 'text/html; charset=utf-8' }, + }); + }, + }); + shell.sockets[0]!.emit('open'); + shell.sockets[0]!.emit('message', { data: reloadFrame(1) }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // The failed refresh leaves the admitted child intact and the reload + // unapplied — a failure must not burn the generation. + expect(shell.entries).toHaveLength(1); + // The channel drops and reconnects; the server replays generation 1 on + // every accepted connection, which retries the unapplied reload. + shell.sockets[0]!.emit('close'); + shell.runTimers(); + shell.sockets[1]!.emit('open'); + shell.sockets[1]!.emit('message', { data: reloadFrame(1) }); + await expect.poll(() => shell.entries.length, { timeout: 5_000 }).toBe(2); + expect(shell.entries[1]).toContain('recovered'); + expect(attempts).toBe(2); + } finally { + await binding.close(); + upstream.closeAllConnections(); + await close(upstream); + } +}); + it('prefixes every opaque child entry with the closed CSP supplied by its trusted binding', async () => { const initialEntry = ''; const refreshedEntry = '';