import { renderToReadableStream } from 'octane/server';
import { prerender } from 'octane/static';
import { isbot } from 'isbot';
import { createSsrStreamResponse } from '@tanstack/router-core/ssr/server';
import { finalizeBufferedHtml } from './renderRouterToString';
import type { ComponentBody } from 'octane';
import type { StreamInjectionSource } from 'octane/server';
import type { AnyRouter } from '@tanstack/router-core';
type RouterApp = ComponentBody<{ router: AnyRouter }>;
type ServerComponent = Parameters[0];
// The router's data stream is merged through octane's native
// `StreamOptions.injection` (octane >= 0.1.11) instead of router-core's
// `transformStreamWithRouter` text transform. Octane emits tag-complete
// chunks and owns the document tail, so the byte-level re-parse
// (closing-tag scans, leftover buffers, the held-`
` tail that also
// buffered every post-shell suspense segment until stream end) is
// unnecessary — boundary segments stream out of order for document renders,
// and the transform's 64 KiB tail cap on segment volume disappears.
// Octane's document mode also emits `` and folds the leading
// renderer-owned styles into `
`, replacing the `prependDoctype` and
// `relocateLeadingOctaneStylesToHead` transforms this file previously piped
// the stream through.
//
// The serialization timeout is preserved: it arms when octane reports the
// render finished (`renderComplete`) and fails the stream if serialization
// never completes. The script barrier lifts when octane subscribes — octane
// only subscribes after the shell (which carries the barrier anchor) is on
// the wire, matching the transform's lift-after-marker-flush.
// (`setRenderFinished` lifts it as a backstop regardless, exactly as
// before.)
const SERIALIZATION_TIMEOUT_MS = 60_000;
export async function renderRouterToStream({
request,
router,
responseHeaders,
App,
}: {
request: Request;
router: AnyRouter;
responseHeaders: Headers;
App: RouterApp;
}) {
if (isbot(request.headers.get('User-Agent'))) {
return renderRouterForBot({ request, router, responseHeaders, App });
}
const serverSsr = router.serverSsr;
if (!serverSsr) {
throw new Error('Invariant failed: router.serverSsr is required');
}
const renderController = new AbortController();
const onRequestAbort = () => renderController.abort(request.signal.reason);
if (request.signal.aborted) {
onRequestAbort();
} else {
request.signal.addEventListener('abort', onRequestAbort, { once: true });
serverSsr.onCleanup(() => {
request.signal.removeEventListener('abort', onRequestAbort);
});
}
let serializationTimeout: ReturnType | undefined;
let stopSerializationListener: (() => void) | undefined;
let settleDone!: () => void;
let failDone!: (reason: unknown) => void;
const done = new Promise((resolve, reject) => {
settleDone = resolve;
failDone = reject;
});
if (serverSsr.isSerializationFinished()) {
settleDone();
} else {
stopSerializationListener = serverSsr.onSerializationFinished(() => settleDone());
}
const releaseInjection = () => {
if (serializationTimeout !== undefined) {
clearTimeout(serializationTimeout);
serializationTimeout = undefined;
}
stopSerializationListener?.();
stopSerializationListener = undefined;
};
const injection: StreamInjectionSource = {
take: () => serverSsr.takeBufferedHtml() ?? '',
subscribe(notify) {
serverSsr.liftScriptBarrier();
return serverSsr.onInjectedHtml(notify);
},
done,
renderComplete() {
serverSsr.setRenderFinished();
if (!serverSsr.isSerializationFinished() && serializationTimeout === undefined) {
serializationTimeout = setTimeout(() => {
failDone(new Error('Serialization timeout after app render finished'));
}, SERIALIZATION_TIMEOUT_MS);
}
},
};
try {
const stream = await renderToReadableStream(
App as unknown as ServerComponent,
{ router },
{
signal: renderController.signal,
nonce: router.options.ssr?.nonce,
injection,
onError(error) {
if (!isAbortError(request, error)) {
console.error('Error in renderToReadableStream:', error);
}
},
},
);
// The renderer's stream is the response body verbatim. `allReady` settles
// in every terminal state (close, abort, fatal, consumer cancel) — the
// single place to release the injection wiring and the router's SSR state.
stream.allReady.then(
() => {
releaseInjection();
serverSsr.cleanup();
},
() => {
releaseInjection();
serverSsr.cleanup();
},
);
return createSsrStreamResponse(
router,
new Response(stream as unknown as BodyInit, {
status: router.stores.statusCode.get(),
headers: responseHeaders,
}),
);
} catch (error) {
renderController.abort(error);
releaseInjection();
router.serverSsr?.cleanup();
throw error;
}
}
async function renderRouterForBot({
request,
router,
responseHeaders,
App,
}: {
request: Request;
router: AnyRouter;
responseHeaders: Headers;
App: RouterApp;
}) {
try {
const result = await prerender(
App as unknown as Parameters[0],
{ router },
{
signal: request.signal,
nonce: router.options.ssr?.nonce,
onError(error) {
if (!isAbortError(request, error)) {
console.error('Error in prerender:', error);
}
},
},
);
router.serverSsr!.setRenderFinished();
return new Response(
finalizeBufferedHtml(result.html, result.css, router.serverSsr!.takeBufferedHtml()),
{
status: router.stores.statusCode.get(),
headers: responseHeaders,
},
);
} finally {
router.serverSsr?.cleanup();
}
}
function isAbortError(request: Request, error: unknown) {
return (
(request.signal.aborted && error === request.signal.reason) ||
(error instanceof Error && error.name === 'AbortError')
);
}