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
1 change: 1 addition & 0 deletions docs/architecture/rsc-runtime-workbench.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ examples/
src/build/serialize-definition.ts
src/definition.ts
src/dev/definition-entry.ts
src/dev/environment-checkpoint-store.ts
src/dev/generation-materializer.ts
src/dev/inspection-security.ts
src/dev/invocation-worker.ts
Expand Down
62 changes: 50 additions & 12 deletions examples/rsc-agent-runtime/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,22 @@ import { Layers, pluginRSC } from 'rsbuild-plugin-rsc';
import { emitRuntimeArtifacts } from './src/build/emit-artifacts.js';

export interface RscRuntimeCompileSnapshot {
readonly acceptCompilerAssetCheckpoint?: () => void;
readonly attemptId: string;
readonly candidateId: string;
readonly discardCompilerAssetCheckpoint?: () => void;
readonly preparedRevision: string;
readonly rscCohortRevision: number;
readonly sourceRevision: string;
}

export type RscRuntimeActivationOutcome = 'activated' | 'failed';
export type RscRuntimeCompileFailureKind = 'provider-lifecycle' | 'source-build';
export type RscRuntimeCompileEnvironmentName = 'app' | 'rsc' | 'widget';
export type RscRuntimeCompileEnvironmentHashes = Readonly<Record<RscRuntimeCompileEnvironmentName, string>>;

const compileEnvironmentNames: readonly RscRuntimeCompileEnvironmentName[] = Object.freeze(['app', 'rsc', 'widget'] as const);

const isCompileEnvironmentName = (value: string): value is RscRuntimeCompileEnvironmentName =>
(compileEnvironmentNames as readonly string[]).includes(value);

export interface RscRuntimeRsbuildConfigOptions {
readonly compilerRoot?: string;
Expand All @@ -36,12 +41,24 @@ export interface RscRuntimeRsbuildConfigOptions {
capture(input: {
readonly attemptId: string;
readonly cohortChanged: boolean;
readonly environmentHashes: RscRuntimeCompileEnvironmentHashes;
readonly hasErrors: boolean;
readonly sourceRevision: string;
}): Promise<RscRuntimeCompileSnapshot | undefined>;
/** Queues provider activation but never blocks the Rsbuild compile hook. */
enqueue(snapshot: RscRuntimeCompileSnapshot): unknown;
failAttempt(attemptId: string, error: unknown, kind: RscRuntimeCompileFailureKind): void;
/**
* Stages an immutable checkpoint of one environment's completed output
* root. Awaited inside the environment compiler's `done` hook, where
* Rsbuild blocks that compiler's next write cycle until staging
* finishes, so the copy reads a quiescent output root.
*/
stageEnvironmentCheckpoint(input: {
readonly distPath: string;
readonly environmentName: RscRuntimeCompileEnvironmentName;
readonly statsHash: string;
}): Promise<void>;
}>;
}

Expand Down Expand Up @@ -100,6 +117,25 @@ const runtimeCompileObserverPlugin = (
return {
name: 'agent-bundle:rsc-runtime-compile-observer',
setup(api) {
api.onAfterEnvironmentCompile(async ({ environment, stats }) => {
// Immutable per-environment staging (#74): Rsbuild awaits this hook
// inside the environment compiler's Rspack `done` tap, so the copy
// reads that environment's completed writeToDisk root before its
// next compile can rewrite it. Failed compilations, unexpected
// environment names, and missing hashes stage nothing here; the
// global after-compile hook is the loud failure path for those, and
// rejecting this hook instead would skip that dispatch entirely and
// strand the FIFO attempt pairing.
if (stats === undefined || stats.hasErrors()) return;
const name = environment.name;
if (!isCompileEnvironmentName(name)) return;
if (typeof stats.hash !== 'string' || stats.hash.length === 0) return;
await observer.stageEnvironmentCheckpoint({
distPath: environment.distPath,
environmentName: name,
statsHash: stats.hash,
});
});
api.onBeforeDevCompile(() => {
// Rsbuild documents global hook order, but not one before/after pair
// per MultiCompiler cohort. FIFO pairing is only empirical in 2.2.1;
Expand Down Expand Up @@ -129,13 +165,13 @@ const runtimeCompileObserverPlugin = (
return;
}
const json = stats.toJson({ all: false, children: true, hash: true });
const cohortHashes = new Map<'rsc' | 'widget', string>();
const cohortHashes = new Map<RscRuntimeCompileEnvironmentName, 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 (child.name === undefined || !isCompileEnvironmentName(child.name)) continue;
if (typeof child.hash !== 'string' || child.hash.length === 0) {
throw new Error(`RSC runtime ${child.name} compilation has no hash.`);
}
Expand All @@ -144,14 +180,22 @@ const runtimeCompileObserverPlugin = (
}
cohortHashes.set(child.name, child.hash);
}
if (cohortHashes.size !== 2 || !cohortHashes.has('rsc') || !cohortHashes.has('widget')) {
throw new Error('RSC runtime compile requires exactly one RSC and widget stats child.');
if (cohortHashes.size !== compileEnvironmentNames.length) {
throw new Error('RSC runtime compile requires exactly one RSC, widget, and App stats child.');
}
const environmentHashes = Object.freeze(Object.fromEntries(
compileEnvironmentNames.map((name) => [name, cohortHashes.get(name) as string]),
)) as RscRuntimeCompileEnvironmentHashes;
// The App environment ships through its own dev-server surface, so
// only the rsc and widget children define the source revision that
// decides whether a new runtime generation is needed. The App child
// hash still selects which staged App checkpoint joins the cohort.
const hashes = (['rsc', 'widget'] as const).map((name) => [name, cohortHashes.get(name) as string]);
const sourceRevision = createHash('sha256').update(JSON.stringify(hashes)).digest('hex');
snapshot = await observer.capture({
attemptId,
cohortChanged: sourceRevision !== capturedCohort?.sourceRevision,
environmentHashes,
hasErrors: false,
sourceRevision,
});
Expand All @@ -168,7 +212,6 @@ const runtimeCompileObserverPlugin = (
const completion = queued instanceof Promise
? queued as Promise<RscRuntimeActivationOutcome>
: Promise.resolve(undefined);
snapshot.acceptCompilerAssetCheckpoint?.();
void completion.then((outcome) => {
if (outcome === 'activated' || outcome === undefined) return;
if (capturedCohort?.activationSequence === activationSequence) capturedCohort = undefined;
Expand All @@ -177,11 +220,6 @@ const runtimeCompileObserverPlugin = (
});
}
} catch (error) {
try {
snapshot?.discardCompilerAssetCheckpoint?.();
} catch {
// The original capture/enqueue error remains the attempted failure cause.
}
observer.failAttempt(attemptId, error, 'provider-lifecycle');
}
});
Expand Down
Loading
Loading