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..203a2e036 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,11 @@ 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 appliedReloadGeneration = reloadGeneration; let refreshController; let refreshGeneration = 0; let refreshing = false; @@ -240,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; @@ -252,57 +259,69 @@ 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(); } } }; 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. 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 <= appliedReloadGeneration) return; + if (generation > reloadGeneration) 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 +363,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 +386,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 +429,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 +483,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 +492,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 +604,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 +621,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 +704,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 +728,7 @@ export class RuntimeClientSurfaceProxy { entryDocument, trustedHostOrigin, trustedContentSecurityPolicy, - trusted.webSocketPath, + bootstrapReloadGeneration, )); return; } @@ -838,76 +861,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 +918,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..22d9b803f 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(); @@ -296,6 +321,102 @@ it('refreshes only for explicit full-reload frames while reconnecting the outer } }); +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 = ''; @@ -313,9 +434,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 +444,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 +469,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 +494,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 +509,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 +521,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 +552,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 +573,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 +593,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 +601,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 +649,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 +690,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 +710,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 +793,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 +815,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 +832,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 +841,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 +859,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 +908,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 +944,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 +959,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 +1028,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 +1057,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 +1086,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 +1120,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 +1140,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 +1149,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 +1165,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' }) }),