@tanstack/router-core
Version:
Modern and scalable routing for React applications
494 lines (425 loc) • 14.2 kB
text/typescript
import { ReadableStream } from 'node:stream/web'
import { Readable } from 'node:stream'
import { TSR_SCRIPT_BARRIER_ID } from './constants'
import type { AnyRouter } from '../router'
export function transformReadableStreamWithRouter(
router: AnyRouter,
routerStream: ReadableStream,
) {
return transformStreamWithRouter(router, routerStream)
}
export function transformPipeableStreamWithRouter(
router: AnyRouter,
routerStream: Readable,
) {
return Readable.fromWeb(
transformStreamWithRouter(router, Readable.toWeb(routerStream)),
)
}
// Use string constants for simple indexOf matching
const BODY_END_TAG = '</body>'
const HTML_END_TAG = '</html>'
// Minimum length of a valid closing tag: </a> = 4 characters
const MIN_CLOSING_TAG_LENGTH = 4
// Default timeout values (in milliseconds)
const DEFAULT_SERIALIZATION_TIMEOUT_MS = 60000
const DEFAULT_LIFETIME_TIMEOUT_MS = 60000
// Module-level encoder (stateless, safe to reuse)
const textEncoder = new TextEncoder()
/**
* Finds the position just after the last valid HTML closing tag in the string.
*
* Valid closing tags match the pattern: </[a-zA-Z][\w:.-]*>
* Examples: </div>, </my-component>, </slot:name.nested>
*
* @returns Position after the last closing tag, or -1 if none found
*/
function findLastClosingTagEnd(str: string): number {
const len = str.length
if (len < MIN_CLOSING_TAG_LENGTH) return -1
let i = len - 1
while (i >= MIN_CLOSING_TAG_LENGTH - 1) {
// Look for > (charCode 62)
if (str.charCodeAt(i) === 62) {
// Look backwards for valid tag name characters
let j = i - 1
// Skip through valid tag name characters
while (j >= 1) {
const code = str.charCodeAt(j)
// Check if it's a valid tag name char: [a-zA-Z0-9_:.-]
if (
(code >= 97 && code <= 122) || // a-z
(code >= 65 && code <= 90) || // A-Z
(code >= 48 && code <= 57) || // 0-9
code === 95 || // _
code === 58 || // :
code === 46 || // .
code === 45 // -
) {
j--
} else {
break
}
}
// Check if the first char after </ is a valid start char (letter only)
const tagNameStart = j + 1
if (tagNameStart < i) {
const startCode = str.charCodeAt(tagNameStart)
// Tag name must start with a letter (a-z or A-Z)
if (
(startCode >= 97 && startCode <= 122) ||
(startCode >= 65 && startCode <= 90)
) {
// Check for </ (charCodes: < = 60, / = 47)
if (
j >= 1 &&
str.charCodeAt(j) === 47 &&
str.charCodeAt(j - 1) === 60
) {
return i + 1 // Return position after the closing >
}
}
}
}
i--
}
return -1
}
export function transformStreamWithRouter(
router: AnyRouter,
appStream: ReadableStream,
opts?: {
/** Timeout for serialization to complete after app render finishes (default: 60000ms) */
timeoutMs?: number
/** Maximum lifetime of the stream transform (default: 60000ms). Safety net for cleanup. */
lifetimeMs?: number
},
) {
// Check upfront if serialization already finished synchronously
// This is the fast path for routes with no deferred data
const serializationAlreadyFinished =
router.serverSsr?.isSerializationFinished() ?? false
// Take any HTML that was buffered before we started listening
const initialBufferedHtml = router.serverSsr?.takeBufferedHtml()
// True passthrough: if serialization already finished and nothing buffered,
// we can avoid any decoding/scanning while still honoring cleanup + setRenderFinished.
if (serializationAlreadyFinished && !initialBufferedHtml) {
let cleanedUp = false
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
let isStreamClosed = false
let lifetimeTimeoutHandle: ReturnType<typeof setTimeout> | undefined
const cleanup = () => {
if (cleanedUp) return
cleanedUp = true
if (lifetimeTimeoutHandle !== undefined) {
clearTimeout(lifetimeTimeoutHandle)
lifetimeTimeoutHandle = undefined
}
router.serverSsr?.cleanup()
}
const safeClose = () => {
if (isStreamClosed) return
isStreamClosed = true
try {
controller?.close()
} catch {
// ignore
}
}
const safeError = (error: unknown) => {
if (isStreamClosed) return
isStreamClosed = true
try {
controller?.error(error)
} catch {
// ignore
}
}
const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS
lifetimeTimeoutHandle = setTimeout(() => {
if (!cleanedUp && !isStreamClosed) {
console.warn(
`SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`,
)
safeError(new Error('Stream lifetime exceeded'))
cleanup()
}
}, lifetimeMs)
const stream = new ReadableStream<Uint8Array>({
start(c) {
controller = c
},
cancel() {
isStreamClosed = true
cleanup()
},
})
;(async () => {
const reader = appStream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (cleanedUp || isStreamClosed) return
controller?.enqueue(value as unknown as Uint8Array)
}
if (cleanedUp || isStreamClosed) return
router.serverSsr?.setRenderFinished()
safeClose()
cleanup()
} catch (error) {
if (cleanedUp) return
console.error('Error reading appStream:', error)
router.serverSsr?.setRenderFinished()
safeError(error)
cleanup()
} finally {
reader.releaseLock()
}
})().catch((error) => {
if (cleanedUp) return
console.error('Error in stream transform:', error)
safeError(error)
cleanup()
})
return stream
}
let stopListeningToInjectedHtml: (() => void) | undefined
let stopListeningToSerializationFinished: (() => void) | undefined
let serializationTimeoutHandle: ReturnType<typeof setTimeout> | undefined
let lifetimeTimeoutHandle: ReturnType<typeof setTimeout> | undefined
let cleanedUp = false
let controller: ReadableStreamDefaultController<any>
let isStreamClosed = false
const textDecoder = new TextDecoder()
// concat'd router HTML; avoids array joins on each flush
let pendingRouterHtml = initialBufferedHtml ?? ''
// between-chunk text buffer; keep bounded to avoid unbounded memory
let leftover = ''
// captured closing tags from </body> onward
let pendingClosingTags = ''
// conservative cap: enough to hold any partial closing tag + a bit
const MAX_LEFTOVER_CHARS = 2048
let isAppRendering = true
let streamBarrierLifted = false
let serializationFinished = serializationAlreadyFinished
function safeEnqueue(chunk: string | Uint8Array) {
if (isStreamClosed) return
if (typeof chunk === 'string') {
controller.enqueue(textEncoder.encode(chunk))
} else {
controller.enqueue(chunk)
}
}
function safeClose() {
if (isStreamClosed) return
isStreamClosed = true
try {
controller.close()
} catch {
// ignore
}
}
function safeError(error: unknown) {
if (isStreamClosed) return
isStreamClosed = true
try {
controller.error(error)
} catch {
// ignore
}
}
/**
* Cleanup with guards; must be idempotent.
*/
function cleanup() {
if (cleanedUp) return
cleanedUp = true
try {
stopListeningToInjectedHtml?.()
stopListeningToSerializationFinished?.()
} catch {
// ignore
}
stopListeningToInjectedHtml = undefined
stopListeningToSerializationFinished = undefined
if (serializationTimeoutHandle !== undefined) {
clearTimeout(serializationTimeoutHandle)
serializationTimeoutHandle = undefined
}
if (lifetimeTimeoutHandle !== undefined) {
clearTimeout(lifetimeTimeoutHandle)
lifetimeTimeoutHandle = undefined
}
pendingRouterHtml = ''
leftover = ''
pendingClosingTags = ''
router.serverSsr?.cleanup()
}
const stream = new ReadableStream({
start(c) {
controller = c
},
cancel() {
isStreamClosed = true
cleanup()
},
})
function flushPendingRouterHtml() {
if (!pendingRouterHtml) return
safeEnqueue(pendingRouterHtml)
pendingRouterHtml = ''
}
function appendRouterHtml(html: string) {
if (!html) return
pendingRouterHtml += html
}
/**
* Finish only when app done and serialization complete.
*/
function tryFinish() {
if (isAppRendering || !serializationFinished) return
if (cleanedUp || isStreamClosed) return
if (serializationTimeoutHandle !== undefined) {
clearTimeout(serializationTimeoutHandle)
serializationTimeoutHandle = undefined
}
// Flush any remaining bytes in the TextDecoder
const decoderRemainder = textDecoder.decode()
if (leftover) safeEnqueue(leftover)
if (decoderRemainder) safeEnqueue(decoderRemainder)
flushPendingRouterHtml()
if (pendingClosingTags) safeEnqueue(pendingClosingTags)
safeClose()
cleanup()
}
// Safety net: cleanup even if consumer never reads
const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS
lifetimeTimeoutHandle = setTimeout(() => {
if (!cleanedUp && !isStreamClosed) {
console.warn(
`SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`,
)
safeError(new Error('Stream lifetime exceeded'))
cleanup()
}
}, lifetimeMs)
if (!serializationAlreadyFinished) {
stopListeningToInjectedHtml = router.subscribe('onInjectedHtml', () => {
if (cleanedUp || isStreamClosed) return
const html = router.serverSsr?.takeBufferedHtml()
if (!html) return
// If we've already captured </body> (pendingClosingTags), we must keep appending
// so injection stays before the stored closing tags.
if (isAppRendering || leftover || pendingClosingTags) {
appendRouterHtml(html)
} else {
safeEnqueue(html)
}
})
stopListeningToSerializationFinished = router.subscribe(
'onSerializationFinished',
() => {
serializationFinished = true
tryFinish()
},
)
}
// Transform the appStream
;(async () => {
const reader = appStream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (cleanedUp || isStreamClosed) return
const text =
value instanceof Uint8Array
? textDecoder.decode(value, { stream: true })
: String(value)
// Fast path: most chunks have no pending left-over.
const chunkString = leftover ? leftover + text : text
if (!streamBarrierLifted) {
if (chunkString.includes(TSR_SCRIPT_BARRIER_ID)) {
streamBarrierLifted = true
router.serverSsr?.liftScriptBarrier()
}
}
// If we already saw </body>, everything else is part of tail; buffer it.
if (pendingClosingTags) {
pendingClosingTags += chunkString
leftover = ''
continue
}
const bodyEndIndex = chunkString.indexOf(BODY_END_TAG)
const htmlEndIndex = chunkString.indexOf(HTML_END_TAG)
if (
bodyEndIndex !== -1 &&
htmlEndIndex !== -1 &&
bodyEndIndex < htmlEndIndex
) {
pendingClosingTags = chunkString.slice(bodyEndIndex)
safeEnqueue(chunkString.slice(0, bodyEndIndex))
flushPendingRouterHtml()
leftover = ''
continue
}
const lastClosingTagEnd = findLastClosingTagEnd(chunkString)
if (lastClosingTagEnd > 0) {
safeEnqueue(chunkString.slice(0, lastClosingTagEnd))
flushPendingRouterHtml()
leftover = chunkString.slice(lastClosingTagEnd)
if (leftover.length > MAX_LEFTOVER_CHARS) {
// Ensure bounded memory even if a consumer streams long text sequences
// without any closing tags. This may reduce injection granularity but is correct.
safeEnqueue(leftover.slice(0, leftover.length - MAX_LEFTOVER_CHARS))
leftover = leftover.slice(-MAX_LEFTOVER_CHARS)
}
} else {
// No closing tag found; keep small tail to handle split closing tags,
// but stream older bytes to prevent unbounded buffering.
const combined = chunkString
if (combined.length > MAX_LEFTOVER_CHARS) {
const flushUpto = combined.length - MAX_LEFTOVER_CHARS
safeEnqueue(combined.slice(0, flushUpto))
leftover = combined.slice(flushUpto)
} else {
leftover = combined
}
}
}
if (cleanedUp || isStreamClosed) return
isAppRendering = false
router.serverSsr?.setRenderFinished()
if (serializationFinished) {
tryFinish()
} else {
const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS
serializationTimeoutHandle = setTimeout(() => {
if (!cleanedUp && !isStreamClosed) {
console.error('Serialization timeout after app render finished')
safeError(
new Error('Serialization timeout after app render finished'),
)
cleanup()
}
}, timeoutMs)
}
} catch (error) {
if (cleanedUp) return
console.error('Error reading appStream:', error)
isAppRendering = false
router.serverSsr?.setRenderFinished()
safeError(error)
cleanup()
} finally {
reader.releaseLock()
}
})().catch((error) => {
if (cleanedUp) return
console.error('Error in stream transform:', error)
safeError(error)
cleanup()
})
return stream
}