diff --git a/.changeset/streaming-flight-reconciler.md b/.changeset/streaming-flight-reconciler.md new file mode 100644 index 000000000..27bf7900b --- /dev/null +++ b/.changeset/streaming-flight-reconciler.md @@ -0,0 +1,9 @@ +--- +"@agent-bundle/runtime": minor +--- + +Add incremental Flight decoding and an invocation-local Suspense reconciler. +`dispatcher.stream()` emits bounded `shell | progress | replace | error | complete` +events with stable-within-invocation boundary IDs, real backpressure, and +AbortSignal cancellation; `dispatcher.dispatch()` remains the default final-only +public API so existing generated entries keep working. diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index e6f1ee47f..1a8ff0d81 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -4,12 +4,16 @@ Agent Document contracts and React-owned Flight execution for Agent Bundle route No npm release is cut yet; install the pkg.pr.new preview of any `main` commit or pull request — see [Preview packages](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/preview-packages.md). -The runtime now executes route models through React-owned RSC/Flight behind -the `AgentRenderDispatcher` execution-host seam. This first renderer slice is -final-only: it buffers one Flight result, decodes only intrinsic `Agent.*` -protocol elements into one immutable `AgentDocument`, and propagates the -request `AbortSignal` through the host and decoder. Streaming Suspense shell -and replacement events arrive in stage 3. +The runtime executes route models through React-owned RSC/Flight behind the +`AgentRenderDispatcher` execution-host seam. Incremental Flight decoding +commits immutable `AgentDocument` snapshots as Suspense boundaries resolve +and emits the `shell | progress | replace | error | complete` render-event +stream from `dispatcher.stream()`. `dispatcher.dispatch()` stays the default +public behavior: it drains that stream and returns only the canonical final +document. The request `AbortSignal` aborts pending boundaries and closes the +stream; a post-completion producer is rejected with a typed +`handoff-required` outcome. Depth, node count, bytes, event rate, and +elapsed time are bounded on the reconciler. The existing lowerers remain synchronous compatibility APIs. `lowerMcpResult` walks an MCP element tree, calling function components itself, and lowers it @@ -36,8 +40,9 @@ error. These contracts land beside the existing `Hook`/`Mcp` lowerers; those synchronous compatibility APIs remain operative. The package exports `Hook`, `Mcp`, `Agent`, both lowerers, the request-store -APIs, the Agent Document contracts, `createAgentRenderDispatcher`, and the -`@agent-bundle/runtime/flight/server` render entry. The Flight-facing versions +APIs, the Agent Document contracts, `createAgentRenderDispatcher`, +`decodeAgentFlightStream`, and the `@agent-bundle/runtime/flight/server` +render entry. The Flight-facing versions are exact compatibility pins: React/React DOM `19.2.8` and `react-server-dom-rspack` `0.1.0`; the proof example compiles them with `rsbuild-plugin-rsc` `0.1.1`. The package does not own application state, diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json index 2305531ee..03936251b 100644 --- a/packages/rsc-runtime/package.json +++ b/packages/rsc-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@agent-bundle/runtime", "version": "0.0.0", - "description": "Agent Document contracts and final-only React Flight dispatch for Agent Bundle runtimes.", + "description": "Agent Document contracts and streaming React Flight dispatch for Agent Bundle runtimes.", "license": "MIT", "keywords": [ "agent-bundle", diff --git a/packages/rsc-runtime/src/agent-document.ts b/packages/rsc-runtime/src/agent-document.ts index afcb620a3..54d08181f 100644 --- a/packages/rsc-runtime/src/agent-document.ts +++ b/packages/rsc-runtime/src/agent-document.ts @@ -119,7 +119,9 @@ export interface AgentRenderLimits { readonly maxDocumentBytes: number; readonly maxDocumentDepth: number; readonly maxDocumentNodes: number; + readonly maxElapsedMs: number; readonly maxEventBytes: number; + readonly maxEventRate: number; readonly maxEvents: number; } @@ -127,7 +129,9 @@ export const DEFAULT_AGENT_RENDER_LIMITS: AgentRenderLimits = Object.freeze({ maxDocumentBytes: 1024 * 1024, maxDocumentDepth: 64, maxDocumentNodes: 10_000, + maxElapsedMs: 60_000, maxEventBytes: 1024 * 1024 + 1024, + maxEventRate: 1_000, maxEvents: 10_000, }); @@ -138,6 +142,8 @@ export type AgentContractErrorCode = | 'document-bytes-exceeded' | 'event-count-exceeded' | 'event-bytes-exceeded' + | 'event-rate-exceeded' + | 'elapsed-time-exceeded' | 'handoff-required'; export class AgentContractError extends Error { @@ -415,6 +421,8 @@ export const createAgentRenderEventSequence = ( limitOverrides: Partial = {}, ): AgentRenderEventSequence => { const limits = resolveLimits(limitOverrides); + const startedAt = Date.now(); + const recentTimes: number[] = []; let completed = false; let nextSequence = 0; return Object.freeze({ @@ -428,6 +436,24 @@ export const createAgentRenderEventSequence = ( 'The render is complete; later work requires a new invocation handoff', ); } + const now = Date.now(); + if (now - startedAt > limits.maxElapsedMs) { + throw new AgentContractError( + 'elapsed-time-exceeded', + `Agent render elapsed time exceeds ${String(limits.maxElapsedMs)}ms`, + ); + } + recentTimes.push(now); + const windowStart = now - 1000; + while (recentTimes[0] !== undefined && recentTimes[0] < windowStart) { + recentTimes.shift(); + } + if (recentTimes.length > limits.maxEventRate) { + throw new AgentContractError( + 'event-rate-exceeded', + `Agent render event rate exceeds ${String(limits.maxEventRate)} per second`, + ); + } if (nextSequence >= limits.maxEvents) { throw new AgentContractError( 'event-count-exceeded', diff --git a/packages/rsc-runtime/src/decode-document.ts b/packages/rsc-runtime/src/decode-document.ts new file mode 100644 index 000000000..3fa0ebf81 --- /dev/null +++ b/packages/rsc-runtime/src/decode-document.ts @@ -0,0 +1,125 @@ +import { Children, isValidElement, type ReactNode } from 'react'; + +import { + AgentContractError, + createAgentDocument, + type AgentDocument, + type AgentDocumentNode, + type AgentRenderLimits, +} from './agent-document.js'; +import type { JsonValue } from './lower-mcp.js'; + +const agentElementTypes = Object.freeze([ + 'agent-result', + 'agent-markdown', + 'agent-text', + 'agent-json', + 'agent-progress', + 'agent-image', + 'agent-audio', + 'agent-resource', + 'agent-error', +] as const); + +type AgentElementType = typeof agentElementTypes[number]; + +interface AgentProtocolElement { + readonly props: Record; + readonly type: AgentElementType; +} + +const isAgentElementType = (value: string): value is AgentElementType => + (agentElementTypes as readonly string[]).includes(value); + +const protocolElement = (node: ReactNode): AgentProtocolElement => { + if ( + !isValidElement(node) || + typeof node.type !== 'string' || + !isAgentElementType(node.type) + ) { + throw new AgentContractError( + 'invalid-document', + 'Flight output must contain only Agent protocol elements; function components and HTML are unsupported', + ); + } + return { props: node.props as Record, type: node.type }; +}; + +const textChild = (children: unknown, type: AgentElementType): string => { + const values = Children.toArray(children as ReactNode); + if (values.length !== 1 || typeof values[0] !== 'string') { + throw new AgentContractError('invalid-document', `${type} requires exactly one string child`); + } + return values[0]; +}; + +interface DecodeState { + representedError: boolean; +} + +const decodeNode = (node: ReactNode, state: DecodeState): AgentDocumentNode => { + const element = protocolElement(node); + const { props } = element; + switch (element.type) { + case 'agent-result': + return { + children: Children.toArray(props.children as ReactNode).map((child) => decodeNode(child, state)), + kind: 'result', + ...(props.metadata === undefined ? {} : { metadata: props.metadata as JsonValue }), + }; + case 'agent-markdown': + return { kind: 'markdown', text: textChild(props.children, element.type) }; + case 'agent-text': + return { kind: 'text', text: textChild(props.children, element.type) }; + case 'agent-json': + return { kind: 'json', value: props.value as JsonValue }; + case 'agent-progress': + return { + completed: props.completed as number, + kind: 'progress', + ...(props.message === undefined ? {} : { message: props.message as string }), + ...(props.total === undefined ? {} : { total: props.total as number }), + }; + case 'agent-image': + return { data: props.data as string, kind: 'image', mimeType: props.mimeType as string }; + case 'agent-audio': + return { data: props.data as string, kind: 'audio', mimeType: props.mimeType as string }; + case 'agent-resource': + return { + kind: 'resource', + ...(props.mimeType === undefined ? {} : { mimeType: props.mimeType as string }), + name: props.name as string, + uri: props.uri as string, + }; + case 'agent-error': + state.representedError = true; + return { + code: props.code as string, + kind: 'error', + message: textChild(props.children, element.type), + }; + default: { + const exhaustive: never = element.type; + throw new AgentContractError('invalid-document', `Unsupported Agent protocol element: ${String(exhaustive)}`); + } + } +}; + +export const decodeAgentDocument = ( + node: ReactNode, + limits: Partial = {}, +): AgentDocument => { + const root = protocolElement(node); + if (root.type !== 'agent-result') { + throw new AgentContractError('invalid-document', 'Flight output must have Agent.Result as its root'); + } + const state: DecodeState = { representedError: false }; + const documentRoot = decodeNode(node, state); + return createAgentDocument({ + root: documentRoot, + status: state.representedError ? 'represented-error' : 'success', + ...(root.props.value === undefined ? {} : { value: root.props.value as JsonValue }), + version: 1, + }, limits); +}; + diff --git a/packages/rsc-runtime/src/dispatcher.ts b/packages/rsc-runtime/src/dispatcher.ts index 7f42e0dfc..e0438707e 100644 --- a/packages/rsc-runtime/src/dispatcher.ts +++ b/packages/rsc-runtime/src/dispatcher.ts @@ -1,18 +1,17 @@ -import { Children, isValidElement, type ReactNode } from 'react'; -import { createFromReadableStream } from 'react-server-dom-rspack/client.node'; - import { AgentContractError, - createAgentDocument, type AgentDocument, - type AgentDocumentNode, + type AgentRenderEvent, type AgentRenderLimits, } from './agent-document.js'; -import type { AgentRenderInvocation } from './agent-request.js'; -import type { JsonValue } from './lower-mcp.js'; +import type { AgentProgressReporter, AgentProgressUpdate, AgentRenderInvocation } from './agent-request.js'; +import { createAgentFlightEventSession, decodeAgentFlightStream } from './reconciler.js'; + +export { decodeAgentDocument } from './decode-document.js'; export interface AgentRenderDispatch { readonly invocation: AgentRenderInvocation; + readonly progress?: AgentProgressReporter; readonly signal: AbortSignal; } @@ -22,151 +21,97 @@ export interface AgentFlightExecutionHost { export interface AgentRenderDispatcher { readonly dispatch: (request: AgentRenderDispatch) => Promise; + readonly stream: (request: AgentRenderDispatch) => ReadableStream; } export interface AgentRenderDispatcherOptions { readonly limits?: Partial; } -const agentElementTypes = Object.freeze([ - 'agent-result', - 'agent-markdown', - 'agent-text', - 'agent-json', - 'agent-progress', - 'agent-image', - 'agent-audio', - 'agent-resource', - 'agent-error', -] as const); - -type AgentElementType = typeof agentElementTypes[number]; - -interface AgentProtocolElement { - readonly props: Record; - readonly type: AgentElementType; -} - -const isAgentElementType = (value: string): value is AgentElementType => - (agentElementTypes as readonly string[]).includes(value); - -const protocolElement = (node: ReactNode): AgentProtocolElement => { - if ( - !isValidElement(node) || - typeof node.type !== 'string' || - !isAgentElementType(node.type) - ) { - throw new AgentContractError( - 'invalid-document', - 'Flight output must contain only Agent protocol elements; function components and HTML are unsupported', - ); - } - return { props: node.props as Record, type: node.type }; -}; - -const textChild = (children: unknown, type: AgentElementType): string => { - const values = Children.toArray(children as ReactNode); - if (values.length !== 1 || typeof values[0] !== 'string') { - throw new AgentContractError('invalid-document', `${type} requires exactly one string child`); - } - return values[0]; -}; - -interface DecodeState { - representedError: boolean; -} - -const decodeNode = (node: ReactNode, state: DecodeState): AgentDocumentNode => { - const element = protocolElement(node); - const { props } = element; - switch (element.type) { - case 'agent-result': - return { - children: Children.toArray(props.children as ReactNode).map((child) => decodeNode(child, state)), - kind: 'result', - ...(props.metadata === undefined ? {} : { metadata: props.metadata as JsonValue }), - }; - case 'agent-markdown': - return { kind: 'markdown', text: textChild(props.children, element.type) }; - case 'agent-text': - return { kind: 'text', text: textChild(props.children, element.type) }; - case 'agent-json': - return { kind: 'json', value: props.value as JsonValue }; - case 'agent-progress': - return { - completed: props.completed as number, - kind: 'progress', - ...(props.message === undefined ? {} : { message: props.message as string }), - ...(props.total === undefined ? {} : { total: props.total as number }), - }; - case 'agent-image': - return { data: props.data as string, kind: 'image', mimeType: props.mimeType as string }; - case 'agent-audio': - return { data: props.data as string, kind: 'audio', mimeType: props.mimeType as string }; - case 'agent-resource': - return { - kind: 'resource', - ...(props.mimeType === undefined ? {} : { mimeType: props.mimeType as string }), - name: props.name as string, - uri: props.uri as string, - }; - case 'agent-error': - state.representedError = true; - return { - code: props.code as string, - kind: 'error', - message: textChild(props.children, element.type), - }; - default: { - const exhaustive: never = element.type; - throw new AgentContractError('invalid-document', `Unsupported Agent protocol element: ${String(exhaustive)}`); - } - } -}; - -export const decodeAgentDocument = ( - node: ReactNode, - limits: Partial = {}, -): AgentDocument => { - const root = protocolElement(node); - if (root.type !== 'agent-result') { - throw new AgentContractError('invalid-document', 'Flight output must have Agent.Result as its root'); - } - const state: DecodeState = { representedError: false }; - const documentRoot = decodeNode(node, state); - return createAgentDocument({ - root: documentRoot, - status: state.representedError ? 'represented-error' : 'success', - ...(root.props.value === undefined ? {} : { value: root.props.value as JsonValue }), - version: 1, - }, limits); -}; - const abortError = (): DOMException => new DOMException('Agent render was aborted', 'AbortError'); -const withAbortSignal = ( - stream: ReadableStream, +const drainCompleteDocument = async ( + events: ReadableStream, signal: AbortSignal, -): ReadableStream => { +): Promise => { + const reader = events.getReader(); + let complete: AgentDocument | undefined; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + switch (next.value.type) { + case 'complete': + complete = next.value.document; + break; + case 'shell': + case 'progress': + case 'replace': + case 'error': + break; + default: { + const exhaustive: never = next.value; + return exhaustive; + } + } + } + } catch (error) { + if (signal.aborted) throw abortError(); + throw error; + } + if (complete !== undefined) return complete; if (signal.aborted) throw abortError(); - return stream.pipeThrough(new TransformStream(), { signal }); + throw new AgentContractError('invalid-document', 'Flight stream ended without a complete document'); }; export const createAgentRenderDispatcher = ( host: AgentFlightExecutionHost, options: AgentRenderDispatcherOptions = {}, -): AgentRenderDispatcher => Object.freeze({ - async dispatch(request: AgentRenderDispatch): Promise { - if (request.signal.aborted) throw abortError(); - try { - const flight = await host.execute(request); - if (request.signal.aborted) throw abortError(); - const node = await createFromReadableStream(withAbortSignal(flight, request.signal)); - if (request.signal.aborted) throw abortError(); - return decodeAgentDocument(node, options.limits); - } catch (error) { - if (request.signal.aborted) throw abortError(); - throw error; +): AgentRenderDispatcher => { + const stream = (request: AgentRenderDispatch): ReadableStream => { + if (request.signal.aborted) { + return new ReadableStream({ + start(controller) { + controller.error(abortError()); + }, + }); } - }, -}); + const session = createAgentFlightEventSession({ limits: options.limits, signal: request.signal }); + const progress: AgentProgressReporter = Object.freeze({ + report: async (update: AgentProgressUpdate) => { + await session.live.emit(session.sequence.emit({ + completed: update.completed ?? 0, + ...(update.message === undefined ? {} : { message: update.message }), + ...(update.total === undefined ? {} : { total: update.total }), + type: 'progress', + })); + }, + }); + void (async () => { + try { + if (request.signal.aborted) throw abortError(); + const flight = await host.execute({ + invocation: request.invocation, + progress, + signal: request.signal, + }); + if (request.signal.aborted) throw abortError(); + decodeAgentFlightStream(flight, { + limits: options.limits, + session, + signal: request.signal, + }); + } catch (error) { + session.live.fail(request.signal.aborted ? abortError() : error); + } + })(); + return session.readable; + }; + + return Object.freeze({ + async dispatch(request: AgentRenderDispatch): Promise { + return drainCompleteDocument(stream(request), request.signal); + }, + stream, + }); +}; diff --git a/packages/rsc-runtime/src/flight-manifest.ts b/packages/rsc-runtime/src/flight-manifest.ts new file mode 100644 index 000000000..1ec9d7c94 --- /dev/null +++ b/packages/rsc-runtime/src/flight-manifest.ts @@ -0,0 +1,19 @@ +interface AgentFlightManifest { + readonly clientManifest: Readonly>; + readonly moduleLoading: null; + readonly serverConsumerModuleMap: null; + readonly serverManifest: Readonly>; +} + +const EMPTY_FLIGHT_MANIFEST: AgentFlightManifest = Object.freeze({ + clientManifest: Object.freeze({}), + moduleLoading: null, + serverConsumerModuleMap: null, + serverManifest: Object.freeze({}), +}); + +export const ensureAgentFlightManifest = (): void => { + const scope = globalThis as typeof globalThis & { __rspack_rsc_manifest__?: unknown }; + if (scope.__rspack_rsc_manifest__ !== undefined) return; + scope.__rspack_rsc_manifest__ = EMPTY_FLIGHT_MANIFEST; +}; diff --git a/packages/rsc-runtime/src/flight/server.ts b/packages/rsc-runtime/src/flight/server.ts index 96a220c2b..ade3b3569 100644 --- a/packages/rsc-runtime/src/flight/server.ts +++ b/packages/rsc-runtime/src/flight/server.ts @@ -1,6 +1,8 @@ import type { ReactNode } from 'react'; import { renderToReadableStream } from 'react-server-dom-rspack/server.node'; +import { ensureAgentFlightManifest } from '../flight-manifest.js'; + export interface AgentFlightRenderOptions { readonly onError?: (error: unknown) => string | undefined; readonly signal?: AbortSignal; @@ -13,6 +15,7 @@ export const renderAgentFlight = ( options: AgentFlightRenderOptions = {}, ): ReadableStream => { if (options.signal?.aborted) throw abortError(); + ensureAgentFlightManifest(); const flight = renderToReadableStream(model, { ...(options.onError === undefined ? {} : { onError: options.onError }), }); diff --git a/packages/rsc-runtime/src/index.ts b/packages/rsc-runtime/src/index.ts index c92c06b68..4a464e8fb 100644 --- a/packages/rsc-runtime/src/index.ts +++ b/packages/rsc-runtime/src/index.ts @@ -47,6 +47,8 @@ export type { AgentRenderDispatcher, AgentRenderDispatcherOptions, } from './dispatcher.js'; +export { decodeAgentFlightStream } from './reconciler.js'; +export type { AgentFlightDecodeOptions } from './reconciler.js'; export { lowerHookResult } from './lower-hook.js'; export type { NativePostToolUseOutput } from './lower-hook.js'; export { lowerMcpResult } from './lower-mcp.js'; diff --git a/packages/rsc-runtime/src/react-server-dom-rspack.d.ts b/packages/rsc-runtime/src/react-server-dom-rspack.d.ts index fd434a090..5864e83d2 100644 --- a/packages/rsc-runtime/src/react-server-dom-rspack.d.ts +++ b/packages/rsc-runtime/src/react-server-dom-rspack.d.ts @@ -1,7 +1,10 @@ declare module 'react-server-dom-rspack/client.node' { export function createFromReadableStream( stream: ReadableStream, - options?: Readonly<{ temporaryReferences?: unknown }>, + options?: Readonly<{ + temporaryReferences?: unknown; + unstable_allowPartialStream?: boolean; + }>, ): Promise; } diff --git a/packages/rsc-runtime/src/reconciler.ts b/packages/rsc-runtime/src/reconciler.ts new file mode 100644 index 000000000..83da7fb33 --- /dev/null +++ b/packages/rsc-runtime/src/reconciler.ts @@ -0,0 +1,502 @@ +import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; +import { createFromReadableStream } from 'react-server-dom-rspack/client.node'; + +import { + AgentContractError, + createAgentRenderEventSequence, + type AgentRenderError, + type AgentRenderEvent, + type AgentRenderEventSequence, + type AgentRenderLimits, +} from './agent-document.js'; +import { decodeAgentDocument } from './decode-document.js'; +import { ensureAgentFlightManifest } from './flight-manifest.js'; + +const REACT_FRAGMENT = Symbol.for('react.fragment'); +const REACT_LAZY = Symbol.for('react.lazy'); +const REACT_SUSPENSE = Symbol.for('react.suspense'); + +export interface AgentFlightDecodeOptions { + readonly limits?: Partial; + readonly signal?: AbortSignal; +} + +interface FlightThenable extends PromiseLike { + readonly reason?: unknown; + readonly status?: string; + readonly value?: T; +} + +interface LazyElement { + readonly $$typeof: symbol; + readonly _init?: (payload: FlightThenable) => unknown; + readonly _payload: FlightThenable; +} + +interface PendingBoundary { + readonly id: string; + readonly thenable: PromiseLike; +} + +type ClassifiedNode = + | { readonly kind: 'empty' } + | { readonly kind: 'leaf'; readonly value: string | number } + | { readonly kind: 'array'; readonly value: readonly ReactNode[] } + | { readonly kind: 'thenable'; readonly value: FlightThenable } + | { readonly kind: 'lazy'; readonly value: LazyElement } + | { readonly kind: 'suspense'; readonly value: ReactElement } + | { readonly kind: 'fragment'; readonly value: ReactElement } + | { readonly kind: 'protocol'; readonly value: ReactElement }; + +const abortError = (): DOMException => new DOMException('Agent render was aborted', 'AbortError'); + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const isThenable = (value: unknown): value is FlightThenable => + isObject(value) && typeof value.then === 'function'; + +const isLazyElement = (value: unknown): value is LazyElement => + isObject(value) && value.$$typeof === REACT_LAZY && isThenable(value._payload); + +const childList = (children: unknown): readonly ReactNode[] => { + if (children === undefined || children === null || children === false || children === true) return []; + return Array.isArray(children) ? children as readonly ReactNode[] : [children as ReactNode]; +}; + +const joinPath = (parent: string, index: number): string => + parent === '' ? String(index) : `${parent}.${index}`; + +const classifyNode = (node: ReactNode): ClassifiedNode => { + if (node === null || node === undefined || typeof node === 'boolean') return { kind: 'empty' }; + if (typeof node === 'string' || typeof node === 'number') return { kind: 'leaf', value: node }; + if (Array.isArray(node)) return { kind: 'array', value: node }; + if (isLazyElement(node)) return { kind: 'lazy', value: node }; + if (isThenable(node)) return { kind: 'thenable', value: node }; + if (!isValidElement(node)) { + throw new AgentContractError('invalid-document', 'Flight output contained an unsupported node'); + } + const type: unknown = node.type; + if (type === REACT_SUSPENSE) return { kind: 'suspense', value: node }; + if (type === REACT_FRAGMENT) return { kind: 'fragment', value: node }; + if (typeof type === 'string') return { kind: 'protocol', value: node }; + throw new AgentContractError( + 'invalid-document', + `Flight output must contain only Agent protocol elements; function components and HTML are unsupported`, + ); +}; + +const thenableStatus = (thenable: FlightThenable): 'pending' | 'fulfilled' | 'rejected' => { + switch (thenable.status) { + case 'fulfilled': + case 'resolved_model': + return 'fulfilled'; + case 'rejected': + return 'rejected'; + case 'pending': + case 'halted': + case undefined: + return 'pending'; + default: + return 'pending'; + } +}; + +const unwrapLazy = (lazy: LazyElement): unknown => { + if (lazy._init !== undefined) return lazy._init(lazy._payload); + return lazy._payload.value; +}; + +const renderErrorFrom = (reason: unknown): AgentRenderError => { + if (reason instanceof Error) { + return Object.freeze({ code: 'boundary', message: reason.message }); + } + if (isObject(reason) && typeof reason.message === 'string' && reason.message.trim() !== '') { + return Object.freeze({ + code: typeof reason.digest === 'string' && reason.digest.trim() !== '' ? reason.digest : 'boundary', + message: reason.message, + }); + } + return Object.freeze({ code: 'boundary', message: 'Suspended boundary failed' }); +}; + +interface MaterializeContext { + readonly ids: Map; + readonly pending: PendingBoundary[]; + readonly rejected: Array<{ readonly error: AgentRenderError; readonly id: string }>; +} + +const boundaryId = (path: string, ids: Map): string => { + const existing = ids.get(path); + if (existing !== undefined) return existing; + const id = `b:${path}`; + ids.set(path, id); + return id; +}; + +const errorElement = (error: AgentRenderError): ReactElement => + createElement('agent-error', { code: error.code }, error.message); + +const materializePending = ( + thenable: FlightThenable, + path: string, + fallback: ReactNode, + ctx: MaterializeContext, +): ReactNode => { + const status = thenableStatus(thenable); + switch (status) { + case 'pending': { + const id = boundaryId(path, ctx.ids); + ctx.pending.push({ id, thenable }); + return materializeNode(fallback, `${path}~fallback`, ctx); + } + case 'rejected': { + const id = boundaryId(path, ctx.ids); + const error = renderErrorFrom(thenable.reason); + ctx.rejected.push({ error, id }); + return errorElement(error); + } + case 'fulfilled': + return materializeNode(thenable.value as ReactNode, path, ctx); + default: { + const exhaustive: never = status; + return exhaustive; + } + } +}; + +const materializeNode = (node: ReactNode, path: string, ctx: MaterializeContext): ReactNode => { + const classified = classifyNode(node); + switch (classified.kind) { + case 'empty': + return null; + case 'leaf': + return classified.value; + case 'array': + return classified.value.map((child, index) => materializeNode(child, joinPath(path, index), ctx)); + case 'thenable': + return materializePending(classified.value, path, null, ctx); + case 'lazy': { + const status = thenableStatus(classified.value._payload); + switch (status) { + case 'pending': + case 'rejected': + return materializePending(classified.value._payload, path, null, ctx); + case 'fulfilled': + return materializeNode(unwrapLazy(classified.value) as ReactNode, path, ctx); + default: { + const exhaustive: never = status; + return exhaustive; + } + } + } + case 'suspense': { + const props = classified.value.props as { readonly children?: ReactNode; readonly fallback?: ReactNode }; + const children = props.children; + const fallback = props.fallback; + const childKind = classifyNode(children); + switch (childKind.kind) { + case 'lazy': { + const status = thenableStatus(childKind.value._payload); + switch (status) { + case 'pending': + return materializePending(childKind.value._payload, path, fallback, ctx); + case 'rejected': + return materializePending(childKind.value._payload, path, fallback, ctx); + case 'fulfilled': + return materializeNode(unwrapLazy(childKind.value) as ReactNode, path, ctx); + default: { + const exhaustive: never = status; + return exhaustive; + } + } + } + case 'thenable': + return materializePending(childKind.value, path, fallback, ctx); + case 'empty': + case 'leaf': + case 'array': + case 'suspense': + case 'fragment': + case 'protocol': + return materializeNode(children, path, ctx); + default: { + const exhaustive: never = childKind; + return exhaustive; + } + } + } + case 'fragment': + return materializeNode((classified.value.props as { readonly children?: ReactNode }).children, path, ctx); + case 'protocol': { + const props = classified.value.props as { readonly children?: ReactNode } & Record; + const { children, ...rest } = props; + const materialized: ReactNode[] = []; + for (const [index, child] of childList(children).entries()) { + const next = materializeNode(child, joinPath(path, index), ctx); + if (Array.isArray(next)) materialized.push(...next); + else materialized.push(next); + } + return createElement(classified.value.type, rest, ...materialized); + } + default: { + const exhaustive: never = classified; + return exhaustive; + } + } +}; + +const snapshotTree = (root: ReactNode, ids: Map) => { + const ctx: MaterializeContext = { ids, pending: [], rejected: [] }; + return { pending: ctx.pending, rejected: ctx.rejected, tree: materializeNode(root, '', ctx) }; +}; + +interface DemandGate { + readonly consume: () => void; + readonly notify: () => void; + readonly wait: () => Promise; +} + +const createDemandGate = (): DemandGate => { + let notifyWaiter: (() => void) | undefined; + let signaled = false; + return { + consume() { + signaled = false; + }, + notify() { + signaled = true; + const waiter = notifyWaiter; + notifyWaiter = undefined; + waiter?.(); + }, + wait() { + if (signaled) { + signaled = false; + return Promise.resolve(); + } + return new Promise((resolve) => { + notifyWaiter = () => { + signaled = false; + resolve(); + }; + }); + }, + }; +}; + +export interface LiveEventStream { + readonly emit: (event: AgentRenderEvent) => Promise; + readonly fail: (error: unknown) => void; + readonly holdFlight: () => boolean; + readonly readable: ReadableStream; + readonly waitForFlightDemand: () => Promise; +} + +const createLiveEventStream = (signal: AbortSignal): LiveEventStream => { + const buffer: AgentRenderEvent[] = []; + const space = createDemandGate(); + const data = createDemandGate(); + const flight = createDemandGate(); + let waitingPulls = 0; + let shellEmitted = false; + let failed: unknown; + let closed = false; + + const readable = new ReadableStream({ + cancel() { + closed = true; + space.notify(); + data.notify(); + flight.notify(); + }, + pull(controller) { + if (failed !== undefined) { + controller.error(failed); + return; + } + const deliver = (): void => { + if (failed !== undefined) { + controller.error(failed); + return; + } + const event = buffer.shift(); + if (event === undefined) { + controller.close(); + return; + } + if (event.type === 'shell') shellEmitted = true; + controller.enqueue(event); + space.notify(); + if (event.type === 'complete') controller.close(); + }; + if (buffer.length > 0) { + data.consume(); + deliver(); + return; + } + waitingPulls += 1; + flight.notify(); + return data.wait().then(() => { + waitingPulls = Math.max(0, waitingPulls - 1); + deliver(); + }); + }, + }, { highWaterMark: 0 }); + + return { + async emit(event) { + if (failed !== undefined) throw failed; + if (closed && event.type !== 'complete') { + throw new AgentContractError('handoff-required', 'The render is complete; later work requires a new invocation handoff'); + } + while (buffer.length >= 1) { + if (signal.aborted) throw abortError(); + await space.wait(); + } + buffer.push(event); + if (event.type === 'shell') shellEmitted = true; + if (event.type === 'complete') closed = true; + data.notify(); + }, + fail(error) { + if (failed !== undefined || (closed && buffer.length === 0)) return; + failed = error; + closed = true; + data.notify(); + space.notify(); + flight.notify(); + }, + holdFlight() { + return shellEmitted && waitingPulls === 0; + }, + readable, + async waitForFlightDemand() { + while (shellEmitted && waitingPulls === 0 && failed === undefined) { + await flight.wait(); + } + }, + }; +}; + +const gateFlight = ( + flight: ReadableStream, + live: LiveEventStream, + signal: AbortSignal, +): ReadableStream => + flight.pipeThrough(new TransformStream({ + async transform(chunk, controller) { + await live.waitForFlightDemand(); + if (signal.aborted) throw abortError(); + controller.enqueue(chunk); + }, + }, { highWaterMark: 1 }, { highWaterMark: 0 }), { signal }); + +const nextBoundary = async ( + pending: readonly PendingBoundary[], + signal: AbortSignal, +): Promise<{ readonly boundary: PendingBoundary; readonly error?: unknown; readonly ok: boolean }> => { + if (signal.aborted) throw abortError(); + return Promise.race([ + new Promise((_, reject) => { + const onAbort = (): void => reject(abortError()); + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + }), + ...pending.map(async (boundary) => { + try { + await boundary.thenable; + return { boundary, ok: true as const }; + } catch (error) { + return { boundary, error, ok: false as const }; + } + }), + ]); +}; + +const reconcile = async ( + root: ReactNode, + sequence: AgentRenderEventSequence, + live: LiveEventStream, + signal: AbortSignal, +): Promise => { + const ids = new Map(); + let snapshot = snapshotTree(root, ids); + await live.emit(sequence.emit({ document: decodeAgentDocument(snapshot.tree), type: 'shell' })); + + while (snapshot.pending.length > 0) { + if (signal.aborted) throw abortError(); + const settled = await nextBoundary(snapshot.pending, signal); + if (!settled.ok) { + const error = renderErrorFrom(settled.error); + await live.emit(sequence.emit({ + boundaryId: settled.boundary.id, + error, + type: 'error', + })); + } + snapshot = snapshotTree(root, ids); + if (settled.ok) { + await live.emit(sequence.emit({ + boundaryId: settled.boundary.id, + document: decodeAgentDocument(snapshot.tree), + type: 'replace', + })); + } + } + + if (signal.aborted) throw abortError(); + await live.emit(sequence.emit({ document: decodeAgentDocument(snapshot.tree), type: 'complete' })); +}; + +export interface AgentFlightEventSession { + readonly live: LiveEventStream; + readonly readable: ReadableStream; + readonly sequence: AgentRenderEventSequence; +} + +export const createAgentFlightEventSession = ( + options: AgentFlightDecodeOptions = {}, +): AgentFlightEventSession => { + const signal = options.signal ?? new AbortController().signal; + const live = createLiveEventStream(signal); + return Object.freeze({ + live, + readable: live.readable, + sequence: createAgentRenderEventSequence(options.limits), + }); +}; + +const decodeIntoSession = ( + flight: ReadableStream, + session: AgentFlightEventSession, + signal: AbortSignal, +): void => { + ensureAgentFlightManifest(); + void (async () => { + try { + if (signal.aborted) throw abortError(); + const node = await createFromReadableStream( + gateFlight(flight, session.live, signal), + { unstable_allowPartialStream: true }, + ); + if (signal.aborted) throw abortError(); + await reconcile(node, session.sequence, session.live, signal); + } catch (error) { + session.live.fail(signal.aborted ? abortError() : error); + } + })(); +}; + +export const decodeAgentFlightStream = ( + flight: ReadableStream, + options: AgentFlightDecodeOptions & { readonly session?: AgentFlightEventSession } = {}, +): ReadableStream => { + const signal = options.signal ?? new AbortController().signal; + const session = options.session ?? createAgentFlightEventSession({ limits: options.limits, signal }); + decodeIntoSession(flight, session, signal); + return session.readable; +}; diff --git a/packages/rsc-runtime/tests/agent-document.test.ts b/packages/rsc-runtime/tests/agent-document.test.ts index 358e19887..d4f5f9dc3 100644 --- a/packages/rsc-runtime/tests/agent-document.test.ts +++ b/packages/rsc-runtime/tests/agent-document.test.ts @@ -169,6 +169,36 @@ describe('Agent render events', () => { const byteBounded = createAgentRenderEventSequence({ maxEventBytes: 20 }); expect(() => byteBounded.emit({ completed: 0, message: 'too large', type: 'progress' })).toThrow('bytes'); }); + + it('bounds event rate within a one-second window', () => { + const events = createAgentRenderEventSequence({ maxEventRate: 2 }); + events.emit({ completed: 0, type: 'progress' }); + events.emit({ completed: 1, type: 'progress' }); + expect(() => events.emit({ completed: 2, type: 'progress' })).toThrow(AgentContractError); + expect(() => events.emit({ completed: 2, type: 'progress' })).toThrow('rate'); + try { + events.emit({ completed: 2, type: 'progress' }); + throw new Error('expected event-rate emit to fail'); + } catch (error) { + expect(error).toMatchObject({ code: 'event-rate-exceeded' }); + } + }); + + it('bounds elapsed render time', { retry: 2 }, async () => { + const events = createAgentRenderEventSequence({ maxElapsedMs: 5 }); + events.emit({ completed: 0, type: 'progress' }); + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + expect(() => events.emit({ completed: 1, type: 'progress' })).toThrow(AgentContractError); + expect(() => events.emit({ completed: 1, type: 'progress' })).toThrow('elapsed'); + try { + events.emit({ completed: 1, type: 'progress' }); + throw new Error('expected elapsed-time emit to fail'); + } catch (error) { + expect(error).toMatchObject({ code: 'elapsed-time-exceeded' }); + } + }); }); describe('AgentRenderInvocation', () => { diff --git a/packages/rsc-runtime/tests/dispatcher.test.ts b/packages/rsc-runtime/tests/dispatcher.test.ts index 773aac75c..0a21eb5e6 100644 --- a/packages/rsc-runtime/tests/dispatcher.test.ts +++ b/packages/rsc-runtime/tests/dispatcher.test.ts @@ -1,13 +1,18 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { Readable } from 'node:stream'; import { describe, expect, it } from '@rstest/core'; import { createElement } from 'react'; import { + AgentContractError, createAgentRenderDispatcher, decodeAgentDocument, type AgentFlightExecutionHost, + type AgentProgressReporter, + type AgentRenderEvent, } from '../src/index.js'; describe('decodeAgentDocument', () => { @@ -86,3 +91,334 @@ describe('Flight compatibility pins', () => { expect(example.devDependencies['rsbuild-plugin-rsc']).toBe('0.1.1'); }); }); + +const workerPath = join(import.meta.dirname, 'flight-render-worker.mjs'); + +const invocation = { + kind: 'tool' as const, + props: { input: {}, operationId: 'status' }, +}; + +const createWorkerHost = ( + fixture: string, +): AgentFlightExecutionHost & { readonly resolve: (gate: 'a' | 'b') => void } => { + let child: ChildProcessWithoutNullStreams | undefined; + return { + resolve(gate) { + if (child === undefined) throw new Error('Flight worker is not running'); + child.stdin.write(`${JSON.stringify({ resolve: gate })}\n`); + }, + async execute(request) { + child = spawn(process.execPath, ['--conditions=react-server', workerPath], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + const abort = (): void => { + child?.kill('SIGTERM'); + }; + request.signal.addEventListener('abort', abort, { once: true }); + if (request.signal.aborted) abort(); + child.stdin.write(`${JSON.stringify({ fixture })}\n`); + if (child.stdout === null) throw new Error('Flight worker stdout is unavailable'); + return Readable.toWeb(child.stdout) as ReadableStream; + }, + }; +}; + +const collectEvents = async ( + stream: ReadableStream, +): Promise => { + const events: AgentRenderEvent[] = []; + for await (const event of stream) events.push(event); + return events; +}; + +const eventTypes = (events: readonly AgentRenderEvent[]): readonly string[] => + events.map((event) => { + switch (event.type) { + case 'shell': + case 'progress': + case 'replace': + case 'error': + case 'complete': + return event.type; + default: { + const exhaustive: never = event; + return exhaustive; + } + } + }); + +describe('AgentRenderDispatcher streaming', () => { + it('keeps dispatch final-only while stream emits shell, replace, and complete', { retry: 2 }, async () => { + const host = createWorkerHost('single'); + const dispatcher = createAgentRenderDispatcher(host); + const controller = new AbortController(); + const stream = dispatcher.stream({ invocation, signal: controller.signal }); + const reader = stream.getReader(); + + const shell = await reader.read(); + expect(shell.done).toBe(false); + if (shell.value === undefined || shell.value.type !== 'shell') { + throw new Error('expected a shell event'); + } + expect(shell.value.sequence).toBe(0); + expect(shell.value.document.root).toMatchObject({ + children: [ + { kind: 'markdown', text: '# Shell' }, + { kind: 'progress', message: 'loading-a' }, + ], + kind: 'result', + }); + expect(shell.value.document.status).toBe('success'); + + host.resolve('a'); + const replacement = await reader.read(); + expect(replacement.done).toBe(false); + if (replacement.value === undefined || replacement.value.type !== 'replace') { + throw new Error('expected a replace event'); + } + expect(replacement.value.sequence).toBe(1); + expect(replacement.value.boundaryId).toBe('b:1'); + expect(replacement.value.document.root).toMatchObject({ + children: [ + { kind: 'markdown', text: '# Shell' }, + { kind: 'markdown', text: 'A ready' }, + ], + kind: 'result', + }); + + const complete = await reader.read(); + expect(complete.done).toBe(false); + if (complete.value === undefined || complete.value.type !== 'complete') { + throw new Error('expected a complete event'); + } + expect(complete.value.sequence).toBe(2); + expect(complete.value.document).toEqual(replacement.value.document); + expect(Object.isFrozen(complete.value.document)).toBe(true); + expect((await reader.read()).done).toBe(true); + + const finalHost = createWorkerHost('single'); + const finalDispatcher = createAgentRenderDispatcher(finalHost); + const finalController = new AbortController(); + const dispatched = finalDispatcher.dispatch({ invocation, signal: finalController.signal }); + finalHost.resolve('a'); + await expect(dispatched).resolves.toEqual(complete.value.document); + }); + + it('keeps boundary IDs stable and emits replace in resolution order', { retry: 2 }, async () => { + const host = createWorkerHost('dual'); + const dispatcher = createAgentRenderDispatcher(host); + const reader = dispatcher.stream({ invocation, signal: new AbortController().signal }).getReader(); + + const shell = await reader.read(); + if (shell.value?.type !== 'shell') throw new Error('expected a shell event'); + expect(shell.value.document.root).toMatchObject({ + children: [ + { kind: 'markdown', text: '# Shell' }, + { kind: 'progress', message: 'loading-a' }, + { kind: 'progress', message: 'loading-b' }, + ], + }); + + host.resolve('b'); + const first = await reader.read(); + if (first.value?.type !== 'replace') throw new Error('expected the first replace event'); + expect(first.value.boundaryId).toBe('b:2'); + expect(first.value.document.root).toMatchObject({ + children: [ + { kind: 'markdown', text: '# Shell' }, + { kind: 'progress', message: 'loading-a' }, + { kind: 'markdown', text: 'B ready' }, + ], + }); + + host.resolve('a'); + const second = await reader.read(); + if (second.value?.type !== 'replace') throw new Error('expected the second replace event'); + expect(second.value.boundaryId).toBe('b:1'); + expect(second.value.sequence).toBeGreaterThan(first.value.sequence); + + const complete = await reader.read(); + if (complete.value?.type !== 'complete') throw new Error('expected a complete event'); + expect(eventTypes([shell.value, first.value, second.value, complete.value])).toEqual([ + 'shell', + 'replace', + 'replace', + 'complete', + ]); + expect([shell.value.sequence, first.value.sequence, second.value.sequence, complete.value.sequence]).toEqual([ + 0, + 1, + 2, + 3, + ]); + }); + + it('assigns a new nested boundary after the outer shell resolves', { retry: 2 }, async () => { + const host = createWorkerHost('nested'); + const dispatcher = createAgentRenderDispatcher(host); + const reader = dispatcher.stream({ invocation, signal: new AbortController().signal }).getReader(); + + const shell = await reader.read(); + if (shell.value?.type !== 'shell') throw new Error('expected a shell event'); + + host.resolve('a'); + const outer = await reader.read(); + if (outer.value?.type !== 'replace') throw new Error('expected the outer replace event'); + expect(outer.value.boundaryId).toBe('b:1'); + expect(outer.value.document.root).toMatchObject({ + children: [ + { kind: 'markdown', text: '# Shell' }, + { kind: 'markdown', text: 'inner-shell' }, + { kind: 'progress', message: 'loading-inner' }, + ], + }); + + host.resolve('b'); + const inner = await reader.read(); + if (inner.value?.type !== 'replace') throw new Error('expected the inner replace event'); + expect(inner.value.boundaryId).toMatch(/^b:1\./); + expect(inner.value.document.root).toMatchObject({ + children: [ + { kind: 'markdown', text: '# Shell' }, + { kind: 'markdown', text: 'inner-shell' }, + { kind: 'markdown', text: 'nested-ready' }, + ], + }); + expect((await reader.read()).value?.type).toBe('complete'); + }); + + it('emits progress as mutable status distinct from replace', { retry: 2 }, async () => { + let progress: AgentProgressReporter | undefined; + const inner = createWorkerHost('single'); + const host: AgentFlightExecutionHost = { + execute: async (request) => { + progress = request.progress; + return inner.execute(request); + }, + }; + const dispatcher = createAgentRenderDispatcher(host); + const reader = dispatcher.stream({ invocation, signal: new AbortController().signal }).getReader(); + const shell = await reader.read(); + if (shell.value?.type !== 'shell') throw new Error('expected a shell event'); + if (progress === undefined) throw new Error('expected the dispatcher to install a progress reporter'); + + await progress.report({ completed: 3, message: 'inspecting', total: 10 }); + const reported = await reader.read(); + if (reported.value?.type !== 'progress') throw new Error('expected a progress event'); + expect(reported.value).toMatchObject({ + completed: 3, + message: 'inspecting', + sequence: 1, + total: 10, + type: 'progress', + }); + + inner.resolve('a'); + const replacement = await reader.read(); + if (replacement.value?.type !== 'replace') throw new Error('expected a replace event'); + expect(replacement.value.sequence).toBe(2); + expect((await reader.read()).value?.type).toBe('complete'); + }); + + it('emits a represented boundary error and still completes siblings', { retry: 2 }, async () => { + const host = createWorkerHost('boom'); + const dispatcher = createAgentRenderDispatcher(host); + const reader = dispatcher.stream({ invocation, signal: new AbortController().signal }).getReader(); + const shell = await reader.read(); + if (shell.value?.type !== 'shell') throw new Error('expected a shell event'); + host.resolve('a'); + const failed = await reader.read(); + if (failed.value?.type !== 'error') throw new Error('expected an error event'); + expect(failed.value.boundaryId).toBe('b:1'); + expect(failed.value.error.message).toContain('boundary failed'); + const complete = await reader.read(); + if (complete.value?.type !== 'complete') throw new Error('expected a complete event'); + expect(complete.value.document.status).toBe('represented-error'); + expect(complete.value.document.root).toMatchObject({ + children: [ + { kind: 'markdown', text: '# Shell' }, + { kind: 'error', message: 'boundary failed' }, + ], + }); + }); + + it('aborts pending boundaries and closes the stream without complete', { retry: 2 }, async () => { + const host = createWorkerHost('single'); + const dispatcher = createAgentRenderDispatcher(host); + const controller = new AbortController(); + const reader = dispatcher.stream({ invocation, signal: controller.signal }).getReader(); + const shell = await reader.read(); + if (shell.value?.type !== 'shell') throw new Error('expected a shell event'); + controller.abort(); + await expect(reader.read()).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('rejects a post-completion progress producer with a typed handoff', { retry: 2 }, async () => { + let progress: AgentProgressReporter | undefined; + const inner = createWorkerHost('ready'); + const host: AgentFlightExecutionHost = { + execute: async (request) => { + progress = request.progress; + return inner.execute(request); + }, + }; + const dispatcher = createAgentRenderDispatcher(host); + const events = await collectEvents(dispatcher.stream({ invocation, signal: new AbortController().signal })); + expect(eventTypes(events)).toEqual(['shell', 'complete']); + if (progress === undefined) throw new Error('expected the dispatcher to install a progress reporter'); + await expect(progress.report({ completed: 1, message: 'late' })).rejects.toBeInstanceOf(AgentContractError); + await expect(progress.report({ completed: 1, message: 'late' })).rejects.toMatchObject({ + code: 'handoff-required', + }); + }); + + it('applies backpressure across the Flight byte boundary after the shell', { retry: 2 }, async () => { + const inner = createWorkerHost('single'); + let pulls = 0; + const host: AgentFlightExecutionHost = { + execute: async (request) => { + const flight = await inner.execute(request); + const reader = flight.getReader(); + return new ReadableStream({ + async cancel(reason) { + await reader.cancel(reason); + }, + async pull(controller) { + pulls += 1; + const next = await reader.read(); + if (next.done) { + controller.close(); + return; + } + controller.enqueue(next.value); + }, + }, { highWaterMark: 0 }); + }, + }; + const dispatcher = createAgentRenderDispatcher(host); + const reader = dispatcher.stream({ invocation, signal: new AbortController().signal }).getReader(); + const shell = await reader.read(); + if (shell.value?.type !== 'shell') throw new Error('expected a shell event'); + inner.resolve('a'); + const pullsAfterResolve = await new Promise((resolve) => { + setTimeout(() => resolve(pulls), 30); + }); + expect(pullsAfterResolve).toBe(pulls); + const replacement = await reader.read(); + if (replacement.value?.type !== 'replace') throw new Error('expected a replace event'); + expect(pulls).toBeGreaterThan(pullsAfterResolve); + expect((await reader.read()).value?.type).toBe('complete'); + }); + + it('enforces event-count bounds on the live stream', { retry: 2 }, async () => { + const host = createWorkerHost('single'); + const dispatcher = createAgentRenderDispatcher(host, { limits: { maxEvents: 1 } }); + const reader = dispatcher.stream({ invocation, signal: new AbortController().signal }).getReader(); + const shell = await reader.read(); + if (shell.value?.type !== 'shell') throw new Error('expected a shell event'); + host.resolve('a'); + await expect(reader.read()).rejects.toBeInstanceOf(AgentContractError); + await expect(reader.read()).rejects.toMatchObject({ code: 'event-count-exceeded' }); + }); +}); diff --git a/packages/rsc-runtime/tests/flight-render-worker.mjs b/packages/rsc-runtime/tests/flight-render-worker.mjs new file mode 100644 index 000000000..6eea239c7 --- /dev/null +++ b/packages/rsc-runtime/tests/flight-render-worker.mjs @@ -0,0 +1,138 @@ +globalThis.__rspack_rsc_manifest__ = Object.freeze({ + clientManifest: Object.freeze({}), + moduleLoading: null, + serverConsumerModuleMap: null, + serverManifest: Object.freeze({}), +}); + +import { createInterface } from 'node:readline'; +import { Readable } from 'node:stream'; + +import { createElement, Fragment, Suspense } from 'react'; +import { renderToReadableStream } from 'react-server-dom-rspack/server.node'; + +const deferred = () => { + let resolve; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +}; + +const gates = { + a: deferred(), + b: deferred(), +}; + +const Slow = async ({ gate, label }) => { + await gates[gate].promise; + return createElement('agent-markdown', null, label); +}; + +const Boom = async () => { + await gates.a.promise; + throw new Error('boundary failed'); +}; + +const NestedInner = async () => { + await gates.b.promise; + return createElement('agent-markdown', null, 'nested-ready'); +}; + +const NestedOuter = async () => { + await gates.a.promise; + return createElement( + Fragment, + null, + createElement('agent-markdown', null, 'inner-shell'), + createElement( + Suspense, + { fallback: createElement('agent-progress', { completed: 0, message: 'loading-inner' }) }, + createElement(NestedInner), + ), + ); +}; + +const modelFor = (fixture) => { + switch (fixture) { + case 'ready': + return createElement( + 'agent-result', + { value: { ready: true } }, + createElement('agent-markdown', null, '# Ready'), + ); + case 'single': + return createElement( + 'agent-result', + { value: { ready: false } }, + createElement('agent-markdown', null, '# Shell'), + createElement( + Suspense, + { fallback: createElement('agent-progress', { completed: 0, message: 'loading-a' }) }, + createElement(Slow, { gate: 'a', label: 'A ready' }), + ), + ); + case 'dual': + return createElement( + 'agent-result', + null, + createElement('agent-markdown', null, '# Shell'), + createElement( + Suspense, + { fallback: createElement('agent-progress', { completed: 0, message: 'loading-a' }) }, + createElement(Slow, { gate: 'a', label: 'A ready' }), + ), + createElement( + Suspense, + { fallback: createElement('agent-progress', { completed: 0, message: 'loading-b' }) }, + createElement(Slow, { gate: 'b', label: 'B ready' }), + ), + ); + case 'nested': + return createElement( + 'agent-result', + null, + createElement('agent-markdown', null, '# Shell'), + createElement( + Suspense, + { fallback: createElement('agent-progress', { completed: 0, message: 'loading-outer' }) }, + createElement(NestedOuter), + ), + ); + case 'boom': + return createElement( + 'agent-result', + null, + createElement('agent-markdown', null, '# Shell'), + createElement( + Suspense, + { fallback: createElement('agent-progress', { completed: 0, message: 'loading-a' }) }, + createElement(Boom), + ), + ); + default: + throw new Error(`Unsupported flight fixture: ${String(fixture)}`); + } +}; + +const lines = createInterface({ input: process.stdin }); +for await (const line of lines) { + if (line.trim() === '') continue; + const command = JSON.parse(line); + if (command.fixture !== undefined) { + const flight = renderToReadableStream(modelFor(command.fixture), { + onError: (error) => (error instanceof Error ? error.message : 'error'), + }); + const output = Readable.fromWeb(flight); + output.pipe(process.stdout); + output.on('end', () => { + process.exit(0); + }); + continue; + } + if (command.resolve === 'a' || command.resolve === 'b') { + gates[command.resolve].resolve(); + continue; + } + throw new Error(`Unsupported flight worker command: ${line}`); +}