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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/dev-runtime-conformance.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 32 additions & 4 deletions examples/rsc-agent-runtime/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand Down Expand Up @@ -203,6 +226,7 @@ export const createRscRuntimeRsbuildConfig = (
},
tools: {
rspack: {
name: 'rsc',
module: {
rules: [{
parser: { importMeta: { url: false } },
Expand Down Expand Up @@ -241,6 +265,9 @@ export const createRscRuntimeRsbuildConfig = (
filename: { js: '[name].js' },
target: 'web',
},
tools: {
rspack: { name: 'widget' },
},
},
app: {
...(development ? {
Expand Down Expand Up @@ -280,6 +307,7 @@ export const createRscRuntimeRsbuildConfig = (
},
tools: {
rspack: {
name: 'app',
module: { parser: { javascript: { dynamicImportMode: 'eager' } } },
},
},
Expand Down
49 changes: 40 additions & 9 deletions examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -674,6 +675,7 @@ export class RsbuildRuntimeSession implements DevRuntimeSession {
readonly #workers = new Map<string, InvocationWorker>();
readonly #failedAttempts = new Set<string>();
#active: RuntimeGeneration<RscRuntimeGenerationMetadata> | undefined;
#appWebSocketPath: string | undefined;
#appWebSocketToken: string | undefined;
#clientSurface: DevRuntimeClientSurfaceEndpoint | undefined;
#closePromise: Promise<void> | undefined;
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Comment thread
ScriptedAlchemy marked this conversation as resolved.
context.signal.throwIfAborted();
context.signal.removeEventListener('abort', abort);
return session;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading