@tanstack/router-core
Version:
Modern and scalable routing for React applications
968 lines (894 loc) • 28.6 kB
text/typescript
import { Readable } from 'node:stream'
import {
DOCUMENT_CLOSE_ANCHOR_INDEX,
DOCUMENT_CLOSE_BYTES,
SCRIPT_CLOSE_ANCHOR_INDEX,
SCRIPT_CLOSE_BYTES,
advanceByteMatcher,
encodeIntoBoundedChunk,
findExactBytes,
getExactBytesPrefixAtEnd,
} from './htmlBoundaryScanner'
import {
HYDRATION_SCRIPT_BOUNDARY_ANCHOR_INDEX,
HYDRATION_SCRIPT_BOUNDARY_BYTES,
HydrationScriptOutputState,
} from './hydrationScripts'
import type { ReadableStream as NodeReadableStream } from 'node:stream/web'
import type { AnyRouter } from '../router'
import type { ByteMatcherState } from './htmlBoundaryScanner'
import type { HydrationScriptOutput } from './hydrationScripts'
export type TransformStreamWithRouterOptions = {
/** Timeout for serialization to complete after app render finishes (default: 60000ms) */
timeoutMs?: number
/** Maximum lifetime of the stream transform. Defaults to twice timeoutMs. */
lifetimeMs?: number
/** Cancels the transform and releases SSR state when the request ends. */
signal?: AbortSignal
/**
* Additional point after which the renderer guarantees that a router script
* can be inserted. This is an adapter contract, not a user streaming policy.
* The router boundary, canonical document close, and EOF are always safe.
*/
rendererSafePoint?: 'script-close' | 'record-end'
/**
* Called exactly once when the stream is torn down due to abort/error/
* cancel/timeout — NOT on natural successful completion. Use this to
* abort a hidden producer upstream of any stream passed to this transform.
* Errors thrown from this callback are swallowed.
*/
onAbort?: (reason?: unknown) => void
}
type AppStreamValue = Uint8Array | string
/** Renderer output: UTF-8 bytes, or string records from a Node pipeable. */
type AppStream =
| ReadableStream<Uint8Array>
| ReadableStream<string>
| ReadableStream<AppStreamValue>
| NodeReadableStream<AppStreamValue>
export function transformPipeableStreamWithRouter(
router: AnyRouter,
routerStream: Readable,
opts?: TransformStreamWithRouterOptions,
) {
return Readable.fromWeb(
transformReadableStreamWithRouter(
router,
Readable.toWeb(routerStream) as AppStream,
opts,
) as NodeReadableStream<Uint8Array>,
)
}
export async function transformHtmlStringWithRouter(
router: AnyRouter,
html: string,
opts?: TransformStreamWithRouterOptions,
) {
const serverSsr = router.serverSsr
if (!serverSsr) {
throw new Error('Invariant failed: router.serverSsr is required')
}
const hydrationScripts = serverSsr.hydrationScripts
if (hydrationScripts.reserveFastPath()) {
try {
opts?.signal?.throwIfAborted()
serverSsr.setRenderFinished()
return '<!DOCTYPE html>' + html
} catch (error) {
try {
opts?.onAbort?.(error)
} catch {
// User cleanup must not block SSR cleanup.
}
throw error
} finally {
serverSsr.cleanup()
}
}
// The merge path chunks string records itself.
const appStream = new ReadableStream<string>({
start(controller) {
controller.enqueue('<!DOCTYPE html>')
controller.enqueue(html)
controller.close()
},
})
const output = transformReadableStreamWithRouter(router, appStream, opts)
return readUtf8Stream(output)
}
async function readUtf8Stream(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
const decoder = new TextDecoder()
let text = ''
try {
for (;;) {
const { done, value } = await reader.read()
if (done) {
break
}
text += decoder.decode(value, { stream: true })
}
return text + decoder.decode()
} finally {
reader.releaseLock()
}
}
const DEFAULT_SERIALIZATION_TIMEOUT_MS = 60_000
const MIN_APPLICATION_STRING_CHUNK_BYTES = 256
const MAX_APPLICATION_STRING_CHUNK_BYTES = 64 * 1024
const ApplicationPhase = {
BeforeBoundary: 0,
Merge: 1,
HeldClose: 2,
PassThrough: 3,
} as const
type ApplicationPhase = (typeof ApplicationPhase)[keyof typeof ApplicationPhase]
type Termination = 'complete' | 'cancel' | 'failure'
type AppStreamReader = ReadableStreamDefaultReader<AppStreamValue>
function releaseReader(reader: AppStreamReader) {
try {
reader.releaseLock()
} catch {
// A nonstandard reader may still reject lock release.
}
}
function cancelReader(reader: AppStreamReader, reason?: unknown) {
const cancelled = reader.cancel(reason).catch(() => {})
// Native readers close synchronously when cancel() starts. Release the lock
// before an arbitrary underlying cancel promise has a chance to park.
releaseReader(reader)
return cancelled
}
function finalizeSsrStream(
kind: Termination,
reason: unknown,
controller: ReadableStreamDefaultController<Uint8Array>,
reader: AppStreamReader,
serverSsr: NonNullable<AnyRouter['serverSsr']>,
onAbort?: (reason?: unknown) => void,
) {
try {
if (kind === 'complete') {
controller.close()
} else if (kind === 'failure') {
controller.error(reason)
}
} catch {
// The stream already reached a terminal state.
}
const aborted = kind !== 'complete'
if (aborted) {
try {
onAbort?.(reason)
} catch {
// User cleanup must not block SSR cleanup.
}
}
const readerDone = aborted
? cancelReader(reader, reason)
: releaseReader(reader)
serverSsr.cleanup()
return readerDone
}
function getLifetimeMs(opts?: TransformStreamWithRouterOptions) {
return (
opts?.lifetimeMs ??
(opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS) * 2
)
}
// External serverSsr.cleanup() severs router ownership. The transform must
// release the renderer, timers, listeners, and buffers immediately instead of
// retaining them until the lifetime backstop fires. AbortError identifies this
// intentional cancellation to downstream consumers.
function createCleanupAbortError() {
const error = new Error('SSR stream transform aborted by router SSR cleanup')
error.name = 'AbortError'
return error
}
function listenForAbort(
signal: AbortSignal | undefined,
onAbort: (reason: unknown) => void,
) {
if (!signal) {
return undefined
}
const listener = () => onAbort(signal.reason)
signal.addEventListener('abort', listener, { once: true })
return () => signal.removeEventListener('abort', listener)
}
/**
* Create a timer that does not keep the Node.js process alive when this
* last-resort stream backstop is the only remaining work.
*
* Node's global `setTimeout()` returns a `Timeout` object with `unref()`.
* Web-standard runtimes return a numeric timer ID instead. Cloudflare Workers
* retain that Web behavior for global timers even when `nodejs_compat` is
* enabled. Accessing an optional property on a numeric ID is safe, so timer
* creation can normalize the Node-only capability without allocating a
* wrapper object. The native handle is returned unchanged for `clearTimeout`.
*/
function setUnrefTimeout(callback: () => void, timeoutMs: number) {
const handle = setTimeout(callback, timeoutMs)
const portableHandle = handle as typeof handle & { unref?: () => void }
portableHandle.unref?.()
return handle
}
/**
* Arm the shared teardown triggers of a transform stream: the lifetime
* backstop timer, the request-abort listener, and the external-cleanup
* listener. Returns a disarm function that `terminate()` calls exactly once;
* teardown ordering must stay identical between the fast and merge paths.
*/
function armStreamLifecycle(
serverSsr: NonNullable<AnyRouter['serverSsr']>,
opts: TransformStreamWithRouterOptions | undefined,
isTerminal: () => boolean,
terminate: (kind: Termination, reason?: unknown) => unknown,
) {
const signal = opts?.signal
let lifetimeTimeoutHandle: ReturnType<typeof setTimeout> | undefined
let stopAbortListener: (() => void) | undefined
const disarm = () => {
stopAbortListener?.()
stopAbortListener = undefined
if (signal && serverSsr.hydrationScripts.requestSignal === signal) {
serverSsr.hydrationScripts.requestSignal = undefined
}
if (lifetimeTimeoutHandle !== undefined) {
clearTimeout(lifetimeTimeoutHandle)
lifetimeTimeoutHandle = undefined
}
}
const lifetimeMs = getLifetimeMs(opts)
lifetimeTimeoutHandle = setUnrefTimeout(() => {
if (isTerminal()) {
return
}
const error = new Error('Stream lifetime exceeded')
console.warn(
`SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`,
)
terminate('failure', error)
}, lifetimeMs)
stopAbortListener = listenForAbort(signal, (reason) => {
terminate('failure', reason)
})
if (signal) {
serverSsr.hydrationScripts.requestSignal = signal
}
// External serverSsr.cleanup() must release the reader, renderer, timer,
// and buffers promptly — a parked pump only wakes through its own
// subscriptions, and a discarded response never pulls at all.
serverSsr.onCleanup(() => {
if (!isTerminal()) {
terminate('failure', createCleanupAbortError())
}
})
return disarm
}
function cleanupFailedStreamCreation(
serverSsr: NonNullable<AnyRouter['serverSsr']>,
onAbort: TransformStreamWithRouterOptions['onAbort'],
error: unknown,
) {
try {
onAbort?.(error)
} catch {
// User cleanup must not block SSR cleanup.
}
serverSsr.cleanup()
}
function encodeStringSource(value: string, offset: number) {
const remaining = value.length - offset
const capacity = Math.min(
MAX_APPLICATION_STRING_CHUNK_BYTES,
Math.max(
MIN_APPLICATION_STRING_CHUNK_BYTES,
Math.min(value.length, remaining * 3),
),
)
const output = new Uint8Array(capacity)
const { read, written } = encodeIntoBoundedChunk(value, offset, output)
return {
bytes: written === output.length ? output : output.subarray(0, written),
read,
}
}
export function transformReadableStreamWithRouter(
router: AnyRouter,
appStream: AppStream,
opts?: TransformStreamWithRouterOptions,
) {
const serverSsr = router.serverSsr
if (!serverSsr) {
throw new Error('Invariant failed: router.serverSsr is required')
}
const hydrationScripts = serverSsr.hydrationScripts
let reader: AppStreamReader
try {
reader = appStream.getReader() as AppStreamReader
} catch (error) {
cleanupFailedStreamCreation(serverSsr, opts?.onAbort, error)
throw error
}
try {
opts?.signal?.throwIfAborted()
if (hydrationScripts.reserveFastPath()) {
// Hydration already drained: pass application output through from the
// first byte. The merge stream handles this with the same pass-through
// it uses after hydration finishes mid-stream.
return makeMergeStream(serverSsr, reader, undefined, opts)
}
const hydrationOutput = hydrationScripts.claimOutput()
if (hydrationOutput.state === HydrationScriptOutputState.Failed) {
throw hydrationOutput.error
}
return makeMergeStream(serverSsr, reader, hydrationOutput, opts)
} catch (error) {
void cancelReader(reader, error)
cleanupFailedStreamCreation(serverSsr, opts?.onAbort, error)
throw error
}
}
// The merge path searches only router- and renderer-owned ASCII delimiters.
// Application bytes otherwise leave through zero-copy subarray views.
// Without a hydration output the stream starts in pass-through.
function makeMergeStream(
serverSsr: NonNullable<AnyRouter['serverSsr']>,
reader: AppStreamReader,
hydrationOutput: HydrationScriptOutput | undefined,
opts?: TransformStreamWithRouterOptions,
) {
const hydrationScripts = serverSsr.hydrationScripts
let controller!: ReadableStreamDefaultController<Uint8Array>
let terminal = false
let appDone = false
let applicationPhase: ApplicationPhase = hydrationOutput
? ApplicationPhase.BeforeBoundary
: ApplicationPhase.PassThrough
let insertionBoundary = false
let stopHydrationOutputListener: (() => void) | undefined
let appReadPending = false
let settledAppRead: ReadableStreamReadValueResult<AppStreamValue> | undefined
let appBytes: Uint8Array | undefined
let appOffset = 0
let documentCloseIndex: number | undefined
let appString: string | undefined
let appStringOffset = 0
const useScriptCloseSafePoints = opts?.rendererSafePoint === 'script-close'
const useRecordEndSafePoints = opts?.rendererSafePoint === 'record-end'
// Both matchers are created on first use; pass-through never needs them.
let barrierMatcher: ByteMatcherState | undefined
let safePointMatcher: ByteMatcherState | undefined
// Split document closes carry at most 13 bytes across chunks. This uses
// findExactBytes + getExactBytesPrefixAtEnd instead of advanceByteMatcher
// because `</body></html>` repeats its first byte (`<` at 0 and 7), which
// violates the matcher's unique-first-byte resync precondition.
let closeCarry: Uint8Array | undefined
let wakeResolve: (() => void) | undefined
// Assigned after the stream exists; `terminate` can run before that when an
// already-cleaned owner fires its cleanup listener synchronously.
let disarmLifecycle = (): void => {}
function waitForWake() {
return new Promise<void>((resolve) => {
wakeResolve = resolve
})
}
function wakePump() {
const resolve = wakeResolve
wakeResolve = undefined
resolve?.()
}
// Returns true when a chunk was enqueued. Every process* function
// propagates this boolean so pump() emits at most one chunk per downstream
// pull — the backpressure contract of this transform.
function enqueueAppBytes(value: Uint8Array) {
if (value.length === 0) {
return false
}
controller.enqueue(value)
return true
}
function finishAppChunk() {
appBytes = undefined
documentCloseIndex = undefined
if (
appString === undefined &&
useRecordEndSafePoints &&
closeCarry === undefined
) {
insertionBoundary = true
}
}
function emitAppRange(
end: number,
safePoint: boolean,
finishCurrentChunk = end === appBytes!.length,
) {
const value = appBytes!
const output =
appOffset === 0 && end === value.length
? value
: value.subarray(appOffset, end)
appOffset = end
if (safePoint) {
insertionBoundary = true
}
if (finishCurrentChunk) {
finishAppChunk()
}
return enqueueAppBytes(output)
}
function loadNextAppStringChunk() {
const value = appString!
const encoded = encodeStringSource(value, appStringOffset)
appStringOffset += encoded.read
appBytes = encoded.bytes
appOffset = 0
if (appStringOffset === value.length) {
appString = undefined
}
}
function processUntilBarrier() {
const value = appBytes!
if (!hydrationScripts.isInitialTaken()) {
// The rendered boundary can only exist after the initial script take,
// so earlier renderer bytes pass through without scanning. This skips
// the barrier scan for the whole pre-<Scripts> document and prevents
// barrier-lookalike bytes in application content from lifting the
// barrier early.
return emitAppRange(value.length, false)
}
const matchEnd = advanceByteMatcher(
(barrierMatcher ??= {
pattern: HYDRATION_SCRIPT_BOUNDARY_BYTES,
anchorIndex: HYDRATION_SCRIPT_BOUNDARY_ANCHOR_INDEX,
matched: 0,
}),
value,
appOffset,
)
if (matchEnd === undefined) {
return emitAppRange(value.length, false)
}
applicationPhase = ApplicationPhase.Merge
hydrationScripts.liftBarrier()
return emitAppRange(matchEnd, true)
}
// Advance past a canonical close that ends `consumed` bytes into the current
// chunk and hold it until the transform completes.
function enterHeldClose(consumed: number, prefix: Uint8Array | undefined) {
appOffset = consumed
applicationPhase = ApplicationPhase.HeldClose
if (safePointMatcher) {
// The removed close breaks byte continuity with later renderer bytes.
safePointMatcher.matched = 0
}
insertionBoundary = true
if (appOffset === appBytes!.length) {
finishAppChunk()
}
return prefix ? enqueueAppBytes(prefix) : false
}
function holdDocumentClose(matchStart: number) {
const value = appBytes!
return enterHeldClose(
matchStart + DOCUMENT_CLOSE_BYTES.length,
matchStart === appOffset
? undefined
: value.subarray(appOffset, matchStart),
)
}
function processUntilSafePoint(endIndex: number) {
const value = appBytes!
const matchEnd = findSafePointEnd(value, appOffset, endIndex)
if (matchEnd === undefined) {
return false
}
return emitAppRange(matchEnd, true)
}
function findSafePointEnd(
value: Uint8Array,
startIndex: number,
endIndex: number,
) {
// Merge-only: pass-through never scans for safe points.
const hydrationState = hydrationOutput!.state
if (
endIndex === startIndex ||
hydrationState === HydrationScriptOutputState.Done
) {
return undefined
}
const scanValue =
endIndex === value.length ? value : value.subarray(0, endIndex)
const matcher = (safePointMatcher ??= {
pattern: SCRIPT_CLOSE_BYTES,
anchorIndex: SCRIPT_CLOSE_ANCHOR_INDEX,
matched: 0,
})
// Active drains before app bytes, Done is excluded above, and Failed
// terminates through the subscriber; only Ready and Waiting reach here.
const waiting = hydrationState === HydrationScriptOutputState.Waiting
const matchEnd = advanceByteMatcher(matcher, scanValue, startIndex, waiting)
if (matchEnd === undefined) {
return undefined
}
if (waiting) {
// Bytes after the last match are processed again on the next pull.
matcher.matched = 0
}
return matchEnd
}
function processCloseCarry() {
const value = appBytes!
const carry = closeCarry!
const headLength = Math.min(
value.length - appOffset,
DOCUMENT_CLOSE_BYTES.length,
)
const combined = new Uint8Array(carry.length + headLength)
combined.set(carry)
combined.set(
value.subarray(appOffset, appOffset + headLength),
carry.length,
)
const matchStart = findExactBytes(
combined,
DOCUMENT_CLOSE_BYTES,
0,
DOCUMENT_CLOSE_ANCHOR_INDEX,
)
const partial =
matchStart < 0
? getExactBytesPrefixAtEnd(combined, DOCUMENT_CLOSE_BYTES)
: undefined
const safeEnd = matchStart >= 0 ? matchStart : (partial ?? combined.length)
if (useScriptCloseSafePoints) {
const safePointEnd = findSafePointEnd(combined, 0, safeEnd)
if (safePointEnd !== undefined) {
appOffset += safePointEnd - carry.length
closeCarry = undefined
insertionBoundary = true
if (appOffset === value.length) {
finishAppChunk()
}
return enqueueAppBytes(combined.subarray(0, safePointEnd))
}
}
if (matchStart >= 0) {
closeCarry = undefined
return enterHeldClose(
appOffset + matchStart + DOCUMENT_CLOSE_BYTES.length - carry.length,
matchStart === 0 ? undefined : combined.subarray(0, matchStart),
)
}
closeCarry = partial === undefined ? undefined : combined.slice(partial)
appOffset += headLength
return enqueueAppBytes(
safeEnd === combined.length ? combined : combined.subarray(0, safeEnd),
)
}
function processUntilDocumentClose() {
const value = appBytes!
if (closeCarry) {
if (processCloseCarry()) {
return true
}
if (applicationPhase === ApplicationPhase.HeldClose) {
return false
}
if (appOffset >= value.length) {
finishAppChunk()
return false
}
}
// Safe points only advance within this chunk, so keep its close or miss.
const matchStart = (documentCloseIndex ??= findExactBytes(
value,
DOCUMENT_CLOSE_BYTES,
appOffset,
DOCUMENT_CLOSE_ANCHOR_INDEX,
))
if (matchStart >= 0) {
if (useScriptCloseSafePoints && processUntilSafePoint(matchStart)) {
return true
}
return holdDocumentClose(matchStart)
}
const partial = getExactBytesPrefixAtEnd(
value,
DOCUMENT_CLOSE_BYTES,
appOffset,
)
const safeEnd = partial ?? value.length
if (useScriptCloseSafePoints && processUntilSafePoint(safeEnd)) {
return true
}
closeCarry = partial === undefined ? undefined : value.slice(partial)
return emitAppRange(safeEnd, false, true)
}
function processAppChunk() {
if (appOffset >= appBytes!.length) {
finishAppChunk()
return false
}
insertionBoundary = false
if (applicationPhase === ApplicationPhase.BeforeBoundary) {
return processUntilBarrier()
}
if (applicationPhase === ApplicationPhase.Merge) {
return processUntilDocumentClose()
}
const value = appBytes!
if (useScriptCloseSafePoints && processUntilSafePoint(value.length)) {
return true
}
return emitAppRange(value.length, false)
}
function terminate(kind: Termination, reason?: unknown) {
if (terminal) {
return
}
terminal = true
stopHydrationOutputListener?.()
stopHydrationOutputListener = undefined
disarmLifecycle()
settledAppRead = undefined
appBytes = undefined
documentCloseIndex = undefined
appString = undefined
closeCarry = undefined
wakePump()
return finalizeSsrStream(
kind,
reason,
controller,
reader,
serverSsr,
opts?.onAbort,
)
}
function startAppRead() {
if (appReadPending || settledAppRead || terminal) {
return
}
appReadPending = true
void reader.read().then(
(result) => {
appReadPending = false
if (!terminal) {
if (result.done) {
acceptAppRead(result)
} else {
settledAppRead = result
}
wakePump()
}
},
(error) => {
appReadPending = false
if (!terminal) {
handlePumpError(error)
}
},
)
}
function acceptAppRead(result: ReadableStreamReadResult<AppStreamValue>) {
if (result.done) {
appDone = true
insertionBoundary = closeCarry === undefined
// The serialization deadline is a transport concern of this merge
// path; the lifecycle signal below stays a plain notification.
hydrationScripts.startSerializationTimeout(
opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS,
)
serverSsr.setRenderFinished()
return
}
const value = result.value
if (typeof value === 'string') {
if (value.length === 0) {
return
}
appString = value
appStringOffset = 0
insertionBoundary = false
loadNextAppStringChunk()
return
}
if (value.byteLength === 0) {
return
}
appBytes = value
appOffset = 0
insertionBoundary = false
}
async function loadNextAppChunk() {
if (appString !== undefined) {
loadNextAppStringChunk()
return
}
if (settledAppRead) {
const settled = settledAppRead
settledAppRead = undefined
acceptAppRead(settled)
return
}
const scriptsCanInterruptRead =
applicationPhase !== ApplicationPhase.BeforeBoundary &&
insertionBoundary &&
hydrationOutput!.state !== HydrationScriptOutputState.Done
if (!scriptsCanInterruptRead && !appReadPending) {
const result = await reader.read()
if (terminal) {
return
}
acceptAppRead(result)
return
}
const wake = waitForWake()
startAppRead()
await wake
}
// Pass-through forwards application records unchanged and only encodes
// string records; it skips the merge bookkeeping entirely.
async function pumpPassThrough() {
try {
for (;;) {
if (appBytes) {
const remainder =
appOffset === 0 ? appBytes : appBytes.subarray(appOffset)
appBytes = undefined
if (enqueueAppBytes(remainder)) {
return
}
continue
}
if (appString !== undefined) {
loadNextAppStringChunk()
continue
}
if (appDone) {
terminate('complete')
return
}
if (appReadPending) {
// A merge-phase prefetch is still in flight; it wakes the pump.
await waitForWake()
continue
}
let result: ReadableStreamReadResult<AppStreamValue> | undefined =
settledAppRead
if (result) {
settledAppRead = undefined
} else {
result = await reader.read()
if (terminal) {
return
}
}
if (result.done || typeof result.value === 'string') {
acceptAppRead(result)
continue
}
if (result.value.byteLength > 0) {
controller.enqueue(result.value)
return
}
}
} catch (error) {
handlePumpError(error)
}
}
async function pump() {
const output = hydrationOutput!
while (!terminal) {
const hydrationState = output.state
if (hydrationState === HydrationScriptOutputState.Active) {
controller.enqueue(output.pullChunk())
return
}
if (
applicationPhase !== ApplicationPhase.BeforeBoundary &&
insertionBoundary &&
hydrationState === HydrationScriptOutputState.Ready
) {
if (!appDone && !appBytes && appString === undefined) {
startAppRead()
}
controller.enqueue(output.pullChunk())
return
}
if (
applicationPhase === ApplicationPhase.Merge &&
hydrationState === HydrationScriptOutputState.Done &&
closeCarry === undefined &&
hydrationScripts.reserveFastPath(output)
) {
applicationPhase = ApplicationPhase.PassThrough
stopHydrationOutputListener?.()
stopHydrationOutputListener = undefined
return pumpPassThrough()
}
if (appBytes) {
if (processAppChunk()) {
return
}
continue
}
if (appDone) {
if (applicationPhase === ApplicationPhase.BeforeBoundary) {
// No <Scripts> boundary was rendered. The document is already
// delivered, so emit the hydration sources after it instead of
// failing the response.
hydrationScripts.skipInitialTake()
applicationPhase = ApplicationPhase.Merge
insertionBoundary = true
continue
}
if (closeCarry) {
// A close prefix that never completed is ordinary content, exactly
// like a document without a canonical close.
const carry = closeCarry
closeCarry = undefined
insertionBoundary = true
if (enqueueAppBytes(carry)) {
return
}
continue
}
if (hydrationState === HydrationScriptOutputState.Waiting) {
await waitForWake()
continue
}
if (applicationPhase === ApplicationPhase.HeldClose) {
controller.enqueue(DOCUMENT_CLOSE_BYTES.slice())
terminate('complete')
return
}
terminate('complete')
return
}
await loadNextAppChunk()
}
}
function handlePumpError(error: unknown) {
if (terminal) {
return
}
console.error('Error processing appStream:', error)
terminate('failure', error)
}
const stream = new ReadableStream<Uint8Array>({
start(c) {
controller = c
},
pull() {
// One async frame per pull: pass-through must stay as cheap as a plain
// read-and-forward loop.
return applicationPhase === ApplicationPhase.PassThrough
? pumpPassThrough()
: pump().catch(handlePumpError)
},
cancel(reason) {
return terminate('cancel', reason)
},
})
if (hydrationOutput) {
stopHydrationOutputListener = hydrationOutput.subscribe(() => {
if (hydrationOutput.state === HydrationScriptOutputState.Failed) {
terminate('failure', hydrationOutput.error)
return
}
wakePump()
})
}
disarmLifecycle = armStreamLifecycle(
serverSsr,
opts,
() => terminal,
terminate,
)
return stream
}