diff --git a/.changeset/dev-runtime-conformance.md b/.changeset/dev-runtime-conformance.md new file mode 100644 index 000000000..8b65026c8 --- /dev/null +++ b/.changeset/dev-runtime-conformance.md @@ -0,0 +1,10 @@ +--- +"agent-bundle": patch +--- + +Harden the Runtime App development relay against Rsbuild protocol changes. +Only the provider-emitted `full-reload` signal now reinstalls the opaque App +child; private `ok` and `hash` frames and unknown future frame kinds are +ignored. Runtime compiler WebSocket paths come from normalized Rsbuild +configuration, and bounded credentials are encoded without assuming an +undocumented token alphabet. diff --git a/examples/rsc-agent-runtime/rsbuild.config.ts b/examples/rsc-agent-runtime/rsbuild.config.ts index d85acca9d..b2fef6cdc 100644 --- a/examples/rsc-agent-runtime/rsbuild.config.ts +++ b/examples/rsc-agent-runtime/rsbuild.config.ts @@ -25,7 +25,10 @@ export interface RscRuntimeRsbuildConfigOptions { readonly compilerRoot?: string; readonly mode: 'development' | 'production'; /** Receives the App environment's server-only Rsbuild HMR credential. */ - readonly onAppWebSocketToken?: (token: string) => void; + readonly onAppWebSocketToken?: (input: Readonly<{ + readonly path: string; + readonly token: string; + }>) => void; readonly onCompile?: Readonly<{ beforeAttempt(): string; capture(input: { @@ -49,9 +52,12 @@ const runtimeAppHmrTokenPlugin = ( name: 'agent-bundle:rsc-runtime-app-hmr-token', setup(api) { api.onAfterCreateCompiler(({ environments }) => { - const token = environments.app?.webSocketToken; + 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.'); - capture(token); + 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 })); }); api.onBeforeStartDevServer(({ server }) => { devServer = server; @@ -96,7 +102,20 @@ const runtimeCompileObserverPlugin = ( name: 'agent-bundle:rsc-runtime-compile-observer', setup(api) { api.onBeforeDevCompile(() => { - pendingAttemptIds.push(observer.beforeAttempt()); + // Rsbuild documents global hook order, but not one before/after pair + // per MultiCompiler cohort. FIFO pairing is only empirical in 2.2.1; + // reject identities that cannot be paired unambiguously, while + // retaining legitimate overlapping before callbacks in FIFO order. + const attemptId = observer.beforeAttempt(); + if (pendingAttemptIds.includes(attemptId)) { + const pairingError = new Error(`RSC runtime compile produced duplicate pending attempt identity ${JSON.stringify(attemptId)}.`); + for (const unmatchedAttemptId of [...pendingAttemptIds, attemptId]) { + observer.failAttempt(unmatchedAttemptId, pairingError, 'provider-lifecycle'); + } + pendingAttemptIds.length = 0; + throw pairingError; + } + pendingAttemptIds.push(attemptId); }); api.onAfterDevCompile(async ({ stats }) => { const attemptId = pendingAttemptIds.shift(); @@ -112,6 +131,10 @@ const runtimeCompileObserverPlugin = ( } const json = stats.toJson({ all: false, children: true, hash: true }); const cohortHashes = new Map<'rsc' | 'widget', string>(); + // Rspack documents optional Stats child names, but Rsbuild does not + // promise they equal environment keys. We explicitly name each + // compiler below; the name-based cohort match is otherwise only an + // empirical Rsbuild 2.2.1 behavior. for (const child of json.children ?? []) { if (child.name !== 'rsc' && child.name !== 'widget') continue; if (typeof child.hash !== 'string' || child.hash.length === 0) { @@ -203,6 +226,7 @@ export const createRscRuntimeRsbuildConfig = ( }, tools: { rspack: { + name: 'rsc', module: { rules: [{ parser: { importMeta: { url: false } }, @@ -241,6 +265,9 @@ export const createRscRuntimeRsbuildConfig = ( filename: { js: '[name].js' }, target: 'web', }, + tools: { + rspack: { name: 'widget' }, + }, }, app: { ...(development ? { @@ -280,6 +307,7 @@ export const createRscRuntimeRsbuildConfig = ( }, tools: { rspack: { + name: 'app', module: { parser: { javascript: { dynamicImportMode: 'eager' } } }, }, }, 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 6d211870e..8c3f64499 100644 --- a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts +++ b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts @@ -589,7 +589,8 @@ const sourceBuildDiagnostic = (): DevRuntimeDiagnostic => Object.freeze({ }); const abortReason = (signal: AbortSignal): unknown => signal.reason ?? new Error('RSC runtime provider startup was aborted.'); -const hmrToken = /^[A-Za-z0-9_-]{16,128}$/u; +const hmrPathMaxLength = 2_048; +const hmrTokenMaxLength = 4_096; export interface RsbuildRuntimeSessionStartTesting { readonly createRsbuild?: typeof createRsbuild; @@ -674,6 +675,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { readonly #workers = new Map(); readonly #failedAttempts = new Set(); #active: RuntimeGeneration | undefined; + #appWebSocketPath: string | undefined; #appWebSocketToken: string | undefined; #clientSurface: DevRuntimeClientSurfaceEndpoint | undefined; #closePromise: Promise | undefined; @@ -843,7 +845,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { config: createRscRuntimeRsbuildConfig({ compilerRoot: join(storageRoot, 'compiler'), mode: 'development', - onAppWebSocketToken: (token) => session.#captureAppWebSocketToken(token), + onAppWebSocketToken: (input) => session.#captureAppWebSocketConnection(input), onCompile: session.#compileObserver(), }), cwd: context.projectRoot, @@ -853,7 +855,10 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { await ledger.add(() => started.server.close(), 'rsbuild-dev-server'); context.signal.throwIfAborted(); session.#attachServer(started, rsbuild.context.devServer); - await session.#providerTail; + // startDevServer does not guarantee that the initial compile or async + // onAfterDevCompile work has finished. In 2.2.1 that work often starts + // before this return, but providerTail is not a documented readiness + // barrier; callers intentionally receive a compiling session. context.signal.throwIfAborted(); context.signal.removeEventListener('abort', abort); return session; @@ -2145,6 +2150,8 @@ 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; @@ -2155,18 +2162,34 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { httpPathPrefixes: Object.freeze(['/']), surfaceId: clientSurfaceId, webSocketOrigin: origin.replace(/^http:/u, 'ws:'), - webSocketPath: '/rsbuild-hmr', + webSocketPath, webSocketToken, }); this.#hmrReady = true; this.#setStatus(this.#active === undefined ? 'compiling' : 'active'); } - #captureAppWebSocketToken(token: string): void { - if (!hmrToken.test(token)) throw new Error('RSC runtime App compiler exposed an invalid HMR credential.'); - if (this.#appWebSocketToken !== undefined && this.#appWebSocketToken !== token) { - throw new Error('RSC runtime App compiler changed its HMR credential during startup.'); + #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.'); + } + // 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.'); } + this.#appWebSocketPath = path; this.#appWebSocketToken = token; } @@ -2245,6 +2268,10 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { this.#candidatesByAttempt.set(input.attemptId, candidate); await this.#testing.beforeGenerationCapture?.(); if (this.#closed) throw new Error('RSC runtime session is closed.'); + // Rsbuild does not guarantee that global MultiStats completion is a + // transactional snapshot of parallel writeToDisk roots. In 2.2.1 the + // files empirically correspond to this completed cohort; the checkpoint + // capture below serializes and validates a copied immutable candidate. const snapshot = await captureRuntimeGenerationSnapshot({ attemptId: input.attemptId, candidate, @@ -2313,9 +2340,13 @@ export class RsbuildRuntimeSession implements DevRuntimeSession { // that failed or settled as no-ops, never produce a generation, so // judging them as superseding would drop the newest successful compile // with nothing to replace it - the permanent-staleness wedge in #38. + // Before the first generation commits, an updated prepared declaration + // is reconciled by the queued step after this activation; rejecting the + // only bootstrap generation would leave that step with no active base. check: () => !this.#closed && snapshot.rscCohortRevision === this.#latestRscCohortRevision && - preparedAuthorityDigest === preparedRuntimeAuthorityDigest(this.#latestPreparedRuntime), + (this.#active === undefined || + preparedAuthorityDigest === preparedRuntimeAuthorityDigest(this.#latestPreparedRuntime)), wait: async () => { while (!this.#closed) { const sequence = this.#sequenceFor(snapshot.attemptId); 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 39c790ca5..3f1665f80 100644 --- a/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-provider.integration.test.ts @@ -57,19 +57,32 @@ const compileObserver = (onCompile: NonNullable { after = callback as (input: unknown) => Promise; }, onBeforeDevCompile: (callback: unknown) => { before = callback as () => void; }, }); + const beginAttempt = (): void => { + if (before === undefined) throw new Error('RSC compiler observer before hook is unavailable.'); + before(); + }; + const completeAttempt = async (input: Readonly<{ + readonly children?: readonly unknown[]; + readonly hasErrors?: boolean; + }> = {}): Promise => { + if (after === undefined) throw new Error('RSC compiler observer after hook is unavailable.'); + await after({ + stats: { + hasErrors: () => input.hasErrors ?? false, + toJson: () => ({ children: input.children ?? [{ hash: 'rsc-hash', name: 'rsc' }, { hash: 'widget-hash', name: 'widget' }] }), + }, + }); + }; return Object.freeze({ + beginAttempt, async compile(input: Readonly<{ readonly children?: readonly unknown[]; readonly hasErrors?: boolean; }> = {}): Promise { - before?.(); - await after?.({ - stats: { - hasErrors: () => input.hasErrors ?? false, - toJson: () => ({ children: input.children ?? [{ hash: 'rsc-hash', name: 'rsc' }, { hash: 'widget-hash', name: 'widget' }] }), - }, - }); + beginAttempt(); + await completeAttempt(input); }, + completeAttempt, }); }; @@ -191,11 +204,11 @@ const introduceWorkerSyntaxError = async (projectRoot: string): Promise => }; test('captures the App compiler HMR credential only through the public Rsbuild environment hook', async () => { - const captured: string[] = []; + const captured: Array> = []; const config = createRscRuntimeRsbuildConfig({ compilerRoot: join(tmpdir(), 'rsc-provider-hmr-token'), mode: 'development', - onAppWebSocketToken: (token: string) => { captured.push(token); }, + onAppWebSocketToken: (input) => { captured.push(input); }, } as Parameters[0]); const plugin = (config.plugins as readonly unknown[]).find((candidate): candidate is Readonly<{ readonly name: string; @@ -210,8 +223,8 @@ test('captures the App compiler HMR credential only through the public Rsbuild e onBeforeStartDevServer: () => undefined, onCloseDevServer: () => undefined, }); - afterCreate?.({ environments: { app: { webSocketToken: 'rsbuild-token-1234' } } }); - expect(captured).toEqual(['rsbuild-token-1234']); + afterCreate?.({ environments: { app: { config: { dev: { client: { path: '/custom-hmr' } } }, webSocketToken: 'rsbuild-token-1234' } } }); + expect(captured).toEqual([{ path: '/custom-hmr', token: 'rsbuild-token-1234' }]); }); test('sends one App-only full reload for each later successful App compilation', async () => { @@ -219,7 +232,7 @@ test('sends one App-only full reload for each later successful App compilation', const config = createRscRuntimeRsbuildConfig({ compilerRoot: join(tmpdir(), 'rsc-provider-app-reload'), mode: 'development', - onAppWebSocketToken: (token: string) => { captured.push(token); }, + onAppWebSocketToken: ({ token }) => { captured.push(token); }, } as Parameters[0]); const plugin = (config.plugins as readonly unknown[]).find((candidate): candidate is Readonly<{ readonly name: string; @@ -249,7 +262,12 @@ 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: { webSocketToken: 'rsbuild-app-token-1234' }, widget: { webSocketToken: 'widget-token-must-not-leak' } } }); + afterCompiler?.({ + environments: { + app: { config: { dev: { client: { path: '/rsbuild-hmr' } } }, webSocketToken: 'rsbuild-app-token-1234' }, + widget: { webSocketToken: 'widget-token-must-not-leak' }, + }, + }); afterEnvironmentCompile?.(appBUpdate); expect(appSends).toEqual([]); beforeStartDevServer?.({ @@ -661,6 +679,44 @@ test('keeps malformed compiler stats in the provider lifecycle failure lane', as expect(failures[0]?.[2]).toBe('provider-lifecycle'); }); +test('retains FIFO pairing when MultiCompiler emits overlapping before hooks', async () => { + let attempts = 0; + const captures: string[] = []; + const failures: unknown[][] = []; + const observer = compileObserver({ + beforeAttempt: () => `attempt-${String(++attempts)}`, + capture: async (input) => { + captures.push(input.attemptId); + return undefined; + }, + enqueue: () => undefined, + failAttempt: (...input: unknown[]) => { failures.push(input); }, + }); + + observer.beginAttempt(); + observer.beginAttempt(); + await observer.completeAttempt(); + await observer.completeAttempt(); + + expect(captures).toEqual(['attempt-1', 'attempt-2']); + expect(failures).toEqual([]); +}); + +test('fails duplicate pending attempt identities loudly instead of silently mispairing them', () => { + const failures: unknown[][] = []; + const observer = compileObserver({ + beforeAttempt: () => 'attempt-duplicate', + capture: async () => undefined, + enqueue: () => undefined, + failAttempt: (...input: unknown[]) => { failures.push(input); }, + }); + + observer.beginAttempt(); + expect(() => observer.beginAttempt()).toThrow('duplicate pending attempt identity'); + expect(failures.map(([attemptId]) => attemptId)).toEqual(['attempt-duplicate', 'attempt-duplicate']); + expect(failures.every((failure) => failure[2] === 'provider-lifecycle')).toBe(true); +}); + test('aggregates owned resource closer failures', async () => { const ledger = new ResourceLedger(); const first = new Error('first closer failed'); @@ -1610,6 +1666,56 @@ test('aborts a deferred Rsbuild creation before starting its dev server', async } }); +test('returns a compiling session without treating provider activation work as a startup barrier', async () => { + const copied = await copyProviderExample(); + const activationReached = deferred(); + const releaseActivation = deferred(); + let session: RsbuildRuntimeSession | undefined; + try { + const prepared = await new ProjectService({ includeDevRuntime: true, mode: 'development', root: copied.projectRoot }).prepare('dev'); + const starting = RsbuildRuntimeSession.start(startContext({ + projectRoot: copied.projectRoot, + preparedRuntime: prepared.devRuntime!, + providerSessionId: 'provider-compiling-startup', + signal: new AbortController().signal, + storageRoot: join(copied.projectRoot, '.agent-bundle', 'runtime-compiling-startup'), + }), { + afterActivationPrepare: async (input) => { + if (input.phase !== 'store') return; + activationReached.resolve(); + await releaseActivation.promise; + }, + }); + let returnedSession: RsbuildRuntimeSession | undefined; + void starting.then((started) => { returnedSession = started; }); + await activationReached.promise; + await new Promise((resolve) => { setTimeout(resolve, 0); }); + const returnedBeforeActivation = returnedSession; + expect(returnedBeforeActivation?.status()).toMatchObject({ state: 'compiling' }); + if (returnedBeforeActivation === undefined) throw new Error('RSC runtime session did not return while compiling.'); + const originalApp = prepared.devRuntime!.apps[0]!; + const reconciling = returnedBeforeActivation.reconcilePreparedRuntime({ + ...prepared.devRuntime!, + apps: [{ ...originalApp, name: 'timeline-startup' }], + sourceRevision: `${prepared.devRuntime!.sourceRevision}-startup-reconcile`, + }); + releaseActivation.resolve(); + session = await starting; + + await reconciling; + await waitFor(() => session?.status().state === 'active'); + await expect(session.readAsset({ + path: ['rsc', 'index.html'], + runtimeGenerationId: session.status().activeVector!.runtimeGenerationId, + surfaceId: 'mcp.timeline-startup', + })).resolves.toMatchObject({ contentType: 'text/html' }); + } finally { + releaseActivation.resolve(); + await session?.close(); + await rm(copied.workspaceRoot, { force: true, recursive: true }); + } +}, 60_000); + test('uses the bound Rsbuild dev-server context instead of a stale port-zero start result', async () => { const copied = await copyProviderExample(); try { @@ -1629,7 +1735,14 @@ test('uses the bound Rsbuild dev-server context instead of a stale port-zero sta onBeforeStartDevServer: () => undefined, onCloseDevServer: () => undefined, }); - afterCreate?.({ environments: { app: { webSocketToken: 'rsbuild-token-1234' } } }); + afterCreate?.({ + environments: { + app: { + config: { dev: { client: { path: '/custom-runtime-hmr' } } }, + webSocketToken: 'token with /?+%= punctuation', + }, + }, + }); return Object.freeze({ context: Object.freeze({ devServer: Object.freeze({ hostname: '127.0.0.1', https: false, port: 41_103 }), @@ -1652,6 +1765,8 @@ test('uses the bound Rsbuild dev-server context instead of a stale port-zero sta 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', }); } finally { await session.close(); diff --git a/examples/rsc-agent-runtime/tests/generation-materializer.test.ts b/examples/rsc-agent-runtime/tests/generation-materializer.test.ts index 38237dd71..cd5c89df3 100644 --- a/examples/rsc-agent-runtime/tests/generation-materializer.test.ts +++ b/examples/rsc-agent-runtime/tests/generation-materializer.test.ts @@ -221,8 +221,15 @@ const compilerObserver = (input: Readonly<{ test('resolves the coherent development compiler configuration through Rsbuild', async () => { const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-compiler-')); try { + const developmentConfig = createRscRuntimeRsbuildConfig({ compilerRoot, mode: 'development' }); + const configuredEnvironments = developmentConfig.environments as Readonly }>; + }>>>; + expect(configuredEnvironments.rsc?.tools?.rspack?.name).toBe('rsc'); + expect(configuredEnvironments.widget?.tools?.rspack?.name).toBe('widget'); + expect(configuredEnvironments.app?.tools?.rspack?.name).toBe('app'); const rsbuild = await createRsbuild({ - config: createRscRuntimeRsbuildConfig({ compilerRoot, mode: 'development' }), + config: developmentConfig, cwd: process.cwd(), }); const inspection = await rsbuild.inspectConfig({ mode: 'development' }); 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 ba5dea7a2..7cfce1179 100644 --- a/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts +++ b/packages/agent-bundle/src/dev/runtime-client-surface-proxy.ts @@ -29,7 +29,7 @@ const pendingWebSocketMessageLimit = 64; const upstreamHandshakeTimeout = 15_000; const upstreamRequestTimeout = 15_000; const loopbackHosts = new Set(['127.0.0.1', '::1']); -const hmrToken = /^[A-Za-z0-9_-]{16,128}$/u; +const hmrTokenMaxLength = 4_096; const endpointKeys = Object.freeze([ 'entryPath', 'httpOrigin', @@ -64,6 +64,7 @@ interface ValidatedEndpoint { readonly httpPathPrefixes: readonly string[]; readonly surfaceId: string; readonly webSocketOrigin: URL; + readonly webSocketPath: string; readonly webSocketToken: string; } @@ -170,7 +171,13 @@ const escapedHtmlAttribute = (value: string): string => value.replace(/[&<>"']/g * The same-origin outer document is the sole relay. The compiler App is never * granted that origin: it runs in this document's one opaque nested iframe. */ -const runtimeProxyShell = (entryPath: string, entryDocument: string, hostOrigin: string, childContentSecurityPolicy: string): string => ` +const runtimeProxyShell = ( + entryPath: string, + entryDocument: string, + hostOrigin: string, + childContentSecurityPolicy: string, + webSocketPath: string, +): string => ` Runtime App surface @@ -183,6 +190,7 @@ const runtimeProxyShell = (entryPath: string, entryDocument: string, hostOrigin: const initialEntry = ${escapedScriptValue(entryDocument)}; const hostOrigin = ${escapedScriptValue(hostOrigin)}; const childContentSecurityPolicy = ${escapedScriptValue(childContentSecurityPolicy)}; + const webSocketPath = ${escapedScriptValue(webSocketPath)}; const maxAppToHostMessageBytes = ${String(runtimeAppMessageLimits.appToHostBytes)}; const maxHostToAppMessageBytes = ${String(runtimeAppMessageLimits.hostToAppBytes)}; const maxHmrMessageBytes = maxAppToHostMessageBytes; @@ -267,10 +275,9 @@ const runtimeProxyShell = (entryPath: string, entryDocument: string, hostOrigin: const openHmr = () => { if (lifecycle === 'closed' || hmr !== undefined) return; let socket; - try { socket = new WebSocket(new URL('/rsbuild-hmr', location.origin).href); } + try { socket = new WebSocket(new URL(webSocketPath, location.origin).href); } catch { reconnectHmr(); return; } hmr = socket; - let initialHmrMessage = true; const reconnect = () => { if (hmr !== socket) return; hmr = undefined; @@ -286,10 +293,11 @@ const runtimeProxyShell = (entryPath: string, entryDocument: string, hostOrigin: let message; try { message = JSON.parse(event.data); } catch { return; } if (!isRecord(message) || typeof message.type !== 'string') return; - if (message.type === 'ok') { - if (initialHmrMessage) { initialHmrMessage = false; return; } - void refreshEntry(); - } else if (message.type === 'full-reload') void refreshEntry(); + // 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(); }); socket.addEventListener('close', reconnect); socket.addEventListener('error', reconnect); @@ -475,10 +483,12 @@ 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'); - if (endpointValue(input, 'webSocketPath') !== '/rsbuild-hmr') invalidEndpoint('the exact /rsbuild-hmr WebSocket path'); + const webSocketPath = canonicalPath(endpointValue(input, 'webSocketPath')).upstream; const webSocketToken = endpointValue(input, 'webSocketToken'); - if (typeof webSocketToken !== 'string' || !hmrToken.test(webSocketToken)) { - invalidEndpoint('a bounded Rsbuild WebSocket token'); + // 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'); } return Object.freeze({ entryPath, @@ -487,6 +497,7 @@ const endpoint = (input: DevRuntimeClientSurfaceEndpoint): ValidatedEndpoint => httpPathPrefixes, surfaceId, webSocketOrigin, + webSocketPath, webSocketToken, }); }; @@ -689,7 +700,13 @@ export class RuntimeClientSurfaceProxy { }; headers['set-cookie'] = `${cookieName}=${sessionCapability}; HttpOnly; SameSite=None; Secure; Partitioned; Path=/`; target.writeHead(200, headers); - target.end(runtimeProxyShell(trusted.entryPath, entryDocument, trustedHostOrigin, trustedContentSecurityPolicy)); + target.end(runtimeProxyShell( + trusted.entryPath, + entryDocument, + trustedHostOrigin, + trustedContentSecurityPolicy, + trusted.webSocketPath, + )); return; } if (!isAuthenticated(request)) { @@ -821,14 +838,15 @@ export class RuntimeClientSurfaceProxy { return reject(404); } if (!isAuthenticated(request)) return reject(403); - if (requestUrl.pathname !== '/rsbuild-hmr') return reject(404); + if (requestUrl.pathname !== trusted.webSocketPath) 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 = `${trusted.webSocketOrigin.origin}/rsbuild-hmr?token=${trusted.webSocketToken}`; + 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, @@ -932,7 +950,7 @@ export class RuntimeClientSurfaceProxy { close, origin: proxyOrigin, surfaceId: trusted.surfaceId, - webSocketPath: '/rsbuild-hmr', + webSocketPath: trusted.webSocketPath, }); } } diff --git a/packages/agent-bundle/src/dev/runtime-provider.ts b/packages/agent-bundle/src/dev/runtime-provider.ts index 79758aaf7..f9bc203f8 100644 --- a/packages/agent-bundle/src/dev/runtime-provider.ts +++ b/packages/agent-bundle/src/dev/runtime-provider.ts @@ -28,7 +28,8 @@ export interface DevRuntimeClientSurfaceEndpoint { readonly httpPathPrefixes: readonly string[]; readonly surfaceId: string; readonly webSocketOrigin: string; - readonly webSocketPath: '/rsbuild-hmr'; + /** 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; } @@ -38,7 +39,7 @@ export interface DevRuntimeClientSurfaceProxyBinding { readonly bootstrapUrl: string; readonly origin: string; readonly surfaceId: string; - readonly webSocketPath: '/rsbuild-hmr'; + readonly webSocketPath: string; close(): Promise; } 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 16c14767e..bd068bd4a 100644 --- a/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts +++ b/packages/agent-bundle/tests/runtime-client-surface-proxy.test.ts @@ -248,7 +248,7 @@ it('keeps malformed opaque-child initialize messages from advancing the trusted } }); -it('reconnects one outer HMR socket and stops reconnecting after pagehide', async () => { +it('refreshes only for explicit full-reload frames while reconnecting the outer HMR socket', async () => { const upstream = createServer((request, response) => { if (serveBootstrapEntry(request, response)) return; response.writeHead(404).end(); @@ -277,6 +277,11 @@ it('reconnects one outer HMR socket and stops reconnecting after pagehide', asyn shell.sockets[1]!.emit('open'); shell.sockets[1]!.emit('message', { data: JSON.stringify({ type: 'ok' }) }); 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' }) }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(shell.entries).toHaveLength(1); + shell.sockets[1]!.emit('message', { data: JSON.stringify({ type: 'full-reload' }) }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(shell.entries).toHaveLength(2); shell.pagehide(); @@ -600,7 +605,7 @@ it('rejects noncanonical foreground origins before exposing a bootstrap capabili .rejects.toThrow('canonical foreground'); }); -it('keeps the trusted HMR token out of the browser URL while authenticating an opaque child upgrade', async () => { +it('percent-encodes a bounded public HMR token while keeping it out of the browser URL', async () => { const upstream = createServer((request, response) => { if (serveBootstrapEntry(request, response)) return; response.writeHead(404).end(); @@ -623,19 +628,22 @@ it('keeps the trusted HMR token out of the browser URL while authenticating an o 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/'], surfaceId: 'app.weather', webSocketOrigin: origin.replace('http:', 'ws:'), - webSocketPath: '/rsbuild-hmr', - webSocketToken: 'rsbuild-token-1234', + webSocketPath, + webSocketToken, } as DevRuntimeClientSurfaceEndpoint, () => undefined); try { const cookie = await bootstrapCookie(binding); - const rejected = new WebSocket(`${binding.origin.replace('http:', 'ws:')}/rsbuild-hmr?token=leaked`, { + expect(binding.webSocketPath).toBe(webSocketPath); + const rejected = new WebSocket(`${binding.origin.replace('http:', 'ws:')}${webSocketPath}?token=leaked`, { headers: { cookie, origin: 'null' }, }); const rejectedState = await new Promise<'close' | 'error' | 'open'>((resolvePromise) => { @@ -646,7 +654,7 @@ it('keeps the trusted HMR token out of the browser URL while authenticating an o if (rejectedState === 'open') rejected.close(); expect(rejectedState).not.toBe('open'); - const client = new WebSocket(`${binding.origin.replace('http:', 'ws:')}/rsbuild-hmr`, { + const client = new WebSocket(`${binding.origin.replace('http:', 'ws:')}${webSocketPath}`, { headers: { cookie, origin: binding.origin }, }); await new Promise((resolvePromise, rejectPromise) => { @@ -658,7 +666,7 @@ it('keeps the trusted HMR token out of the browser URL while authenticating an o expect(receivedUpgrade).toEqual({ cookie: undefined, origin: undefined, - url: '/rsbuild-hmr?token=rsbuild-token-1234', + url: `${webSocketPath}?${new URLSearchParams({ token: webSocketToken }).toString()}`, }); } finally { await binding.close();