/**
* `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();
}
}
}