/** * `renderToStream` — streaming SSR with **automatic state transfer** (#48, #49). * * It renders `mount(runtime, Root)` to an HTML stream via `react-dom/server`, * waits for every suspended query to settle (`stream.allReady`), then appends the * settled state — serializable-atom values and resolved query results — as one * JSON ``; /** * The one erased Effect boundary: `renderToStream`'s own signature has already * proven the layer provides the tree's services and the loading obligation is * discharged, so the inner `mount` call does not re-check. */ type ErasedMount = ( source: ManagedRuntime.ManagedRuntime, rec: unknown, options?: MountOptions, ) => ReactNode; const mountChecked = mount as unknown as ErasedMount; /** * Render a root REC to an HTML `ReadableStream` with the settled state embedded — * the server half of effract's hydration story. Mirrors `mount`'s signature and * compile-time checks: a missing service or an undischarged loading obligation is * a type error here too (pass `{ loading }` or put a `.suspense` in the tree — * during streaming SSR a pending query really does stream its fallback first). * * The returned stream is complete (`allReady` has resolved): every query has * settled, so the embedded state is the state the HTML was rendered from. A * `Layer` source builds a fresh runtime for the render and disposes it before * returning — per-request isolation by default, exactly like the RSC `mount`. * Pass a pre-built `ManagedRuntime` to share or pre-seed one (mutate an atom, * run an effect) — you own its lifecycle. */ export async function renderToStream( source: Layer.Layer | ManagedRuntime.ManagedRuntime, rec: REC, RE, R, S, V, L> & // `QueryCache` is built in: `renderToStream` provides it (backed by the real // store — this path *has* one), so a tree that reads it never counts it as // missing. ([Effective] extends [ROut | QueryCache] ? unknown : MissingServices, ROut | QueryCache>>) & ([S] extends [never] ? unknown : LoadingNotHandled) & ([V] extends [never] ? unknown : VolatileNotHandled), options?: RenderToStreamOptions, ): Promise>; export async function renderToStream( source: Layer.Layer | ManagedRuntime.ManagedRuntime, rec: REC, RE, R, S, V, L> & ([Effective] extends [ROut | QueryCache] ? unknown : MissingServices, ROut | QueryCache>>) & // `{ loading }` discharges only the loading obligation; the tree it produces // hydrates into the interactive client `mount`, so a volatile subscription // must still be discharged in the tree (`defer(atom)` or `.hot()`). ([V] extends [never] ? unknown : VolatileNotHandled), options: RenderToStreamOptions & { readonly loading: ReactNode }, ): Promise>; export async function renderToStream( source: Layer.Layer | ManagedRuntime.ManagedRuntime, rec: REC, RE, R, S, V, L>, options?: RenderToStreamOptions, ): Promise> { const { loading, shell, ...streamOptions } = options ?? {}; const injected = ManagedRuntime.isManagedRuntime(source); // A `Layer` source gets the built-in QueryCache merged in — backed by the real // store, scoped (via the thunk, set right after `make`) to this render's // runtime, exactly like the client `mount`'s Layer branch. An injected runtime // was built outside effract and must carry the cache itself. const scopeRef: { current: string | null } = { current: null }; const runtime = ( injected ? source : ManagedRuntime.make( Layer.provideMerge( source as Layer.Layer, Layer.succeed( QueryCache, makeQueryCache(() => scopeRef.current), ), ), ) ) as ManagedRuntime.ManagedRuntime; scopeRef.current = runtimeScopeOf(runtime); try { const app = mountChecked(runtime, rec, loading === undefined ? undefined : { loading }); const node = shell === undefined ? app : shell(app); const stream = await renderToReadableStream(node, streamOptions); // Post-settlement: every suspended query has resolved and its HTML is in the // stream, so the state collected now is exactly what the HTML shows. await stream.allReady; const atoms = dehydrate(); const queries = collectSeededQueries(runtimeScopeOf(runtime)); const state: EmbeddedState = { ...(Object.keys(atoms).length === 0 ? {} : { atoms }), ...(queries.length === 0 ? {} : { queries }), }; if (Object.keys(state).length === 0) { return stream; // nothing crossed the wire — no script to append } const script = new TextEncoder().encode(stateScript(state)); return stream.pipeThrough( new TransformStream({ flush(controller) { controller.enqueue(script); }, }), ); } finally { // Everything settled (or the render threw) — a runtime built here is done. if (!injected) { await runtime.dispose(); } } }