@tanstack/router-core
Version:
Modern and scalable routing for React applications
690 lines (635 loc) • 20.6 kB
text/typescript
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference path="../vite-env.d.ts" />
import { getCrossReferenceHeader } from 'seroval'
import { invariant } from '../invariant'
import minifiedTsrBootStrapScript from './tsrScript?script-string'
import { GLOBAL_TSR } from './constants'
import { encodeIntoBoundedChunk } from './htmlBoundaryScanner'
import type { RouterManagedTag } from '../manifest'
export const SSR_SERIALIZATION_SCOPE_ID = 'tsr'
const HYDRATION_SCRIPT_BOUNDARY_TAIL = ';/*$tsr-stream-boundary*/'
const encoder = new TextEncoder()
const SOURCE_SEPARATOR = ';'
const MAX_INITIAL_SOURCE_CODE_UNITS = 16 * 1024
const MAX_BACKLOG_CODE_UNITS = 16 * 1024 * 1024
const MAX_BACKLOG_SOURCES = 4_096
const MIN_OUTPUT_BYTES = 256
const MAX_DIRECT_CODE_UNITS = 16 * 1024
export const MAX_HYDRATION_OUTPUT_CHUNK_BYTES = 64 * 1024
const MAX_DYNAMIC_RECORD_CODE_UNITS = MAX_HYDRATION_OUTPUT_CHUNK_BYTES
const STREAM_PART_ATTRIBUTE = 'data-tsr-stream-part'
const INITIAL_CLEANUP_SOURCE = `{let s=document.currentScript,p;while((p=s.previousElementSibling)&&p.hasAttribute('${STREAM_PART_ATTRIBUTE}'))p.remove();s.remove()}`
const INITIAL_CLEANUP_SUFFIX = SOURCE_SEPARATOR + INITIAL_CLEANUP_SOURCE
const DYNAMIC_CLOSE_SOURCE = 'document.currentScript.remove()</script>'
const HYDRATION_SCRIPT_BOUNDARY_SOURCE =
`document.currentScript.remove()` + HYDRATION_SCRIPT_BOUNDARY_TAIL
const HYDRATION_SCRIPT_BOUNDARY_SUFFIX =
HYDRATION_SCRIPT_BOUNDARY_TAIL + '</script>'
export const HYDRATION_SCRIPT_BOUNDARY_ANCHOR_INDEX =
HYDRATION_SCRIPT_BOUNDARY_SUFFIX.lastIndexOf('*')
export const HYDRATION_SCRIPT_BOUNDARY_BYTES = encoder.encode(
HYDRATION_SCRIPT_BOUNDARY_SUFFIX,
)
const ROUTER_PREFIX = GLOBAL_TSR + '.router='
const PROMISE_PREFIX = GLOBAL_TSR + '.p(()=>'
const DEFAULT_INITIAL_SOURCES = [
getCrossReferenceHeader(SSR_SERIALIZATION_SCOPE_ID),
minifiedTsrBootStrapScript,
]
export const HydrationScriptOutputState = {
Waiting: 0,
Ready: 1,
Active: 2,
Done: 3,
Failed: 4,
} as const
export type HydrationScriptOutputState =
(typeof HydrationScriptOutputState)[keyof typeof HydrationScriptOutputState]
/** A request-local, single-consumer stream of complete records. */
export type HydrationScriptOutput = {
readonly state: HydrationScriptOutputState
readonly error: unknown
pullChunk: () => Uint8Array
subscribe: (onChange: () => void) => () => void
}
type ConsumerOwnership =
| HydrationScriptOutput
| 'fast-path'
| 'cleaned'
| undefined
/** The one-time initial `<Scripts>` take: hydration tags plus the boundary. */
export type InitialHydrationScriptTags = {
before: Array<RouterManagedTag>
boundary: RouterManagedTag
}
function escapeAttribute(value: string) {
return value.replace(/[&"'<>]/g, (char) => `&#${char.charCodeAt(0)};`)
}
function createInitialTags(
sources: ReadonlyArray<string>,
nonce: string | undefined,
): InitialHydrationScriptTags {
const before: Array<RouterManagedTag> = []
for (const source of sources) {
if (!source) {
continue
}
const previous = before[before.length - 1]
if (
previous?.children &&
previous.children.length + SOURCE_SEPARATOR.length + source.length <=
MAX_INITIAL_SOURCE_CODE_UNITS
) {
previous.children += SOURCE_SEPARATOR + source
} else {
before.push({
tag: 'script',
attrs: { nonce, [STREAM_PART_ATTRIBUTE]: '' },
children: source,
})
}
}
// Cleanup runs before route and asset scripts. If hydration code fails, its
// transport tags can remain because that page cannot hydrate successfully.
const lastHydrationTag = before[before.length - 1]
if (lastHydrationTag) {
const lastSource = lastHydrationTag.children!
if (
lastSource.length + INITIAL_CLEANUP_SUFFIX.length <=
MAX_INITIAL_SOURCE_CODE_UNITS
) {
lastHydrationTag.children = lastSource + INITIAL_CLEANUP_SUFFIX
} else {
// Do not concatenate onto a large source. A renderer could flatten the
// result and temporarily retain a second copy of the hydration payload.
before.push({
tag: 'script',
attrs: { nonce, [STREAM_PART_ATTRIBUTE]: '' },
children: INITIAL_CLEANUP_SOURCE,
})
}
}
return {
before,
boundary: {
tag: 'script',
attrs: { nonce },
children: HYDRATION_SCRIPT_BOUNDARY_SOURCE,
} satisfies RouterManagedTag,
}
}
type HydrationScripts = {
pushSerializedSource: (
data: string,
initial: boolean,
wrap: boolean,
) => boolean
pushSource: (nextSource: string) => boolean
fail: (reason: unknown) => void
finish: () => void
takeInitialHydrationScriptTags: () => InitialHydrationScriptTags | undefined
disableHydration: () => void
isInitialTaken: () => boolean
skipInitialTake: () => void
liftBarrier: () => void
claimOutput: () => HydrationScriptOutput
reserveFastPath: (output?: HydrationScriptOutput) => boolean
startSerializationTimeout: (timeoutMs: number) => void
cleanup: () => void
}
class HydrationScriptsOwner implements HydrationScriptOutput {
private queuedSources: Array<string | undefined> = []
private queuedSourceHead = 0
private initialTaken = false
private barrierLifted = false
private producerDone = false
private consumer: ConsumerOwnership
private listener: (() => void) | undefined
private active: Array<string | undefined> | undefined
private retainedSources = 0
private regularCodeUnits = 0
private hasOversizedSource = false
private segmentIndex = 0
private closingSegmentIndex = 0
private source = ''
private sourceOffset = 0
private outputCapacity = MIN_OUTPUT_BYTES
private outputState: HydrationScriptOutputState =
HydrationScriptOutputState.Waiting
private outputError: unknown
private timeout: ReturnType<typeof setTimeout> | undefined
private opening: string | undefined
constructor(
private readonly nonce: string | undefined,
initialSources?: ReadonlyArray<string>,
) {
// ServerSsr exposes this method as a bare callback, so keep this single
// method bound while all other owner operations live on the prototype.
this.takeInitialHydrationScriptTags =
this.takeInitialHydrationScriptTags.bind(this)
const seedSources = initialSources ?? DEFAULT_INITIAL_SOURCES
for (const seedSource of seedSources) {
if (!this.account(seedSource)) {
break
}
this.queuedSources.push(seedSource)
}
}
get state() {
return this.outputState
}
get error() {
return this.outputError
}
private notify() {
try {
this.listener?.()
} catch (listenerError) {
console.error('Hydration script output listener error:', listenerError)
}
}
private refresh(notifyChange = true) {
const next =
this.outputState === HydrationScriptOutputState.Failed
? HydrationScriptOutputState.Failed
: this.active
? HydrationScriptOutputState.Active
: typeof this.consumer === 'object' &&
this.initialTaken &&
this.barrierLifted &&
!this.queueIsEmpty()
? HydrationScriptOutputState.Ready
: this.producerDone && this.initialTaken && this.queueIsEmpty()
? HydrationScriptOutputState.Done
: HydrationScriptOutputState.Waiting
if (this.outputState !== next) {
this.outputState = next
if (notifyChange) {
this.notify()
}
}
}
private clearTimeoutIfSet() {
if (this.timeout !== undefined) {
clearTimeout(this.timeout)
this.timeout = undefined
}
}
private queueIsEmpty() {
return this.queuedSourceHead === this.queuedSources.length
}
private clearQueue() {
this.queuedSources = []
this.queuedSourceHead = 0
}
private dropBufferedOutput() {
this.clearQueue()
this.active = undefined
this.retainedSources = 0
this.regularCodeUnits = 0
this.hasOversizedSource = false
this.segmentIndex = 0
this.closingSegmentIndex = 0
this.source = ''
this.sourceOffset = 0
this.outputCapacity = MIN_OUTPUT_BYTES
this.opening = undefined
}
fail(reason: unknown) {
if (
this.consumer === 'cleaned' ||
this.outputState === HydrationScriptOutputState.Failed
) {
return
}
this.outputError = reason
this.clearTimeoutIfSet()
this.dropBufferedOutput()
this.outputState = HydrationScriptOutputState.Failed
this.notify()
}
private rejectBacklog(kind: 'source-part' | 'code-unit') {
this.fail(new Error(`SSR hydration backlog exceeded maximum ${kind} count`))
return false
}
private account(nextSource: string) {
if (this.retainedSources === MAX_BACKLOG_SOURCES) {
return this.rejectBacklog('source-part')
}
if (nextSource.length > MAX_BACKLOG_CODE_UNITS) {
if (this.hasOversizedSource) {
return this.rejectBacklog('code-unit')
}
this.hasOversizedSource = true
} else if (
this.regularCodeUnits + nextSource.length >
MAX_BACKLOG_CODE_UNITS
) {
return this.rejectBacklog('code-unit')
} else {
this.regularCodeUnits += nextSource.length
}
this.retainedSources++
return true
}
private releaseSource(part: string) {
this.retainedSources--
if (part.length > MAX_BACKLOG_CODE_UNITS) {
this.hasOversizedSource = false
} else {
this.regularCodeUnits -= part.length
}
}
private releaseAccounting(batch: ReadonlyArray<string | undefined>) {
for (const part of batch) {
if (part !== undefined) {
this.releaseSource(part)
}
}
}
liftBarrier() {
if (this.consumer !== 'cleaned' && !this.barrierLifted) {
this.barrierLifted = true
this.refresh()
}
}
private producerCanWrite() {
return (
this.consumer !== 'cleaned' &&
this.outputState !== HydrationScriptOutputState.Failed &&
!this.producerDone
)
}
pushSource(nextSource: string) {
if (!this.producerCanWrite()) {
return false
}
if (this.account(nextSource)) {
this.queuedSources.push(nextSource)
if (this.initialTaken) {
this.refresh()
}
} else {
return false
}
// A notification can synchronously fail or clean up this owner.
return this.producerCanWrite()
}
private takeQueuedBatch(batchLength: number) {
if (
this.queuedSourceHead === 0 &&
batchLength === this.queuedSources.length
) {
const batch = this.queuedSources
this.clearQueue()
return batch
}
const end = this.queuedSourceHead + batchLength
const batch = this.queuedSources.slice(this.queuedSourceHead, end)
for (let index = this.queuedSourceHead; index < end; index++) {
this.queuedSources[index] = undefined
}
this.queuedSourceHead = end
if (this.queueIsEmpty()) {
this.clearQueue()
} else if (
this.queuedSourceHead >= 1024 &&
this.queuedSourceHead >= this.queuedSources.length - this.queuedSourceHead
) {
this.queuedSources = this.queuedSources.slice(this.queuedSourceHead)
this.queuedSourceHead = 0
}
return batch
}
private release(batch: Array<string | undefined>) {
this.releaseAccounting(batch)
this.active = undefined
this.source = ''
this.sourceOffset = 0
this.refresh(false)
}
private advanceSource() {
const batch = this.active!
if (this.segmentIndex > 0 && this.segmentIndex < this.closingSegmentIndex) {
const partIndex = (this.segmentIndex - 1) >> 1
if (this.segmentIndex % 2 === 1) {
const part = batch[partIndex]
if (part !== undefined) {
this.releaseSource(part)
batch[partIndex] = undefined
}
}
}
this.segmentIndex++
// Segment 0 is the opening tag. Sources and separators alternate until
// the closing segment. The next advance releases the complete record.
if (this.segmentIndex < this.closingSegmentIndex) {
const partIndex = (this.segmentIndex - 1) >> 1
this.source =
this.segmentIndex % 2 === 1 ? batch[partIndex]! : SOURCE_SEPARATOR
} else if (this.segmentIndex === this.closingSegmentIndex) {
this.source = DYNAMIC_CLOSE_SOURCE
} else {
this.release(batch)
}
this.sourceOffset = 0
}
private pullActive() {
const bytes = new Uint8Array(this.outputCapacity)
let offset = 0
while (this.active) {
if (this.sourceOffset === this.source.length) {
this.advanceSource()
} else if (offset === bytes.length) {
break
} else {
const result = encodeIntoBoundedChunk(
this.source,
this.sourceOffset,
bytes,
offset,
)
if (result.read === 0) {
break
}
this.sourceOffset += result.read
offset += result.written
}
}
if (offset === 0) {
throw new Error('SSR router script record produced no output')
}
if (offset === bytes.length) {
return bytes
}
// A subarray view pins the whole output buffer. Copy mostly-empty tail
// chunks (typically the final close-tag remnant) so the large buffer can
// be collected immediately.
return offset * 2 < bytes.length
? bytes.slice(0, offset)
: bytes.subarray(0, offset)
}
private pullReady() {
const scriptOpening = (this.opening ??= this.nonce
? `<script nonce="${escapeAttribute(this.nonce)}">`
: '<script>')
let codeUnits = scriptOpening.length + DYNAMIC_CLOSE_SOURCE.length
let batchLength = 0
for (
let index = this.queuedSourceHead;
index < this.queuedSources.length;
index++
) {
const part = this.queuedSources[index]!
const nextCodeUnits = codeUnits + SOURCE_SEPARATOR.length + part.length
if (batchLength > 0 && nextCodeUnits > MAX_DYNAMIC_RECORD_CODE_UNITS) {
break
}
codeUnits = nextCodeUnits
batchLength++
if (codeUnits > MAX_DYNAMIC_RECORD_CODE_UNITS) {
// A complete JavaScript source is not safely splittable across tags.
break
}
}
const batch = this.takeQueuedBatch(batchLength)
if (codeUnits <= MAX_DIRECT_CODE_UNITS) {
const joined =
batch.length === 1 ? batch[0]! : batch.join(SOURCE_SEPARATOR)
const bytes = encoder.encode(
scriptOpening + joined + SOURCE_SEPARATOR + DYNAMIC_CLOSE_SOURCE,
)
this.release(batch)
return bytes
}
this.active = batch
this.segmentIndex = 0
this.closingSegmentIndex = (batch.length << 1) + 1
this.source = scriptOpening
this.sourceOffset = 0
this.outputCapacity = Math.max(
MIN_OUTPUT_BYTES,
Math.min(MAX_HYDRATION_OUTPUT_CHUNK_BYTES, codeUnits),
)
this.outputState = HydrationScriptOutputState.Active
return this.pullActive()
}
pullChunk() {
if (
this.outputState !== HydrationScriptOutputState.Ready &&
this.outputState !== HydrationScriptOutputState.Active
) {
throw new Error('Hydration script output is not ready')
}
try {
return this.outputState === HydrationScriptOutputState.Ready
? this.pullReady()
: this.pullActive()
} catch (cause) {
this.fail(cause)
throw cause
}
}
subscribe(onChange: () => void) {
if (this.consumer === 'cleaned') {
return () => {}
}
if (this.listener) {
throw new Error('SSR hydration output already has a subscriber')
}
this.listener = onChange
return () => {
if (this.listener === onChange) {
this.listener = undefined
}
}
}
pushSerializedSource(data: string, initial: boolean, wrap: boolean) {
let serialized = initial ? ROUTER_PREFIX + data : data
if (wrap) {
serialized = PROMISE_PREFIX + serialized + ')'
}
return this.pushSource(serialized)
}
finish() {
if (!this.pushSource(GLOBAL_TSR + '.e()')) {
return
}
this.producerDone = true
this.clearTimeoutIfSet()
this.refresh()
}
takeInitialHydrationScriptTags() {
if (
this.consumer === 'cleaned' ||
this.outputState === HydrationScriptOutputState.Failed ||
this.initialTaken
) {
return undefined
}
// No source can drain before the initial take, so the queue has no
// cleared prefix. Once tags are composed, the same empty array can become
// the dynamic queue without allocating a replacement.
const sources = this.queuedSources as Array<string>
const tags = createInitialTags(sources, this.nonce)
this.initialTaken = true
this.releaseAccounting(sources)
sources.length = 0
this.queuedSourceHead = 0
this.refresh()
return tags
}
/**
* Opt this request out of hydration output entirely (for example a
* `hydrate: false` page). Drops the queued bootstrap sources, marks the
* producer done, and makes the fast pass-through path reservable without
* a rendered `<Scripts>` boundary. Must run before the initial take and
* before serialization produces output.
*/
disableHydration() {
if (
this.consumer === 'cleaned' ||
this.outputState === HydrationScriptOutputState.Failed
) {
return
}
if (this.initialTaken || this.consumer !== undefined || this.producerDone) {
if (process.env.NODE_ENV !== 'production') {
throw new Error(
'Invariant failed: hydration output is already committed; ' +
'disableHydration() must run before <Scripts> renders and ' +
'before serialization starts.',
)
}
invariant()
}
this.releaseAccounting(this.queuedSources)
this.clearQueue()
this.initialTaken = true
this.barrierLifted = true
this.producerDone = true
this.refresh()
}
// The rendered boundary can only exist after the initial take. The merger
// uses this to skip barrier scanning for all earlier renderer bytes.
isInitialTaken() {
return this.initialTaken
}
// Application EOF without a rendered <Scripts> boundary. The initial
// sources become late records after the document so the response still
// completes instead of failing after its HTML was already delivered.
skipInitialTake() {
if (this.consumer !== 'cleaned' && !this.initialTaken) {
this.initialTaken = true
this.refresh()
}
}
claimOutput(): HydrationScriptOutput {
if (this.consumer === 'cleaned') {
throw new Error('SSR hydration script output is already cleaned up')
}
if (this.consumer !== undefined) {
throw new Error('SSR hydration script output already has a consumer')
}
this.consumer = this
this.refresh(false)
return this
}
reserveFastPath(output?: HydrationScriptOutput) {
const ownsConsumer = this.consumer === output
if (
this.outputState === HydrationScriptOutputState.Failed ||
!this.producerDone ||
!this.initialTaken ||
!this.queueIsEmpty() ||
this.active ||
!ownsConsumer
) {
return false
}
this.consumer = 'fast-path'
return true
}
// Arms the serialization deadline once the renderer finished. The
// lifecycle signal stays separate from this merge-transport concern.
startSerializationTimeout(timeoutMs: number) {
if (
this.consumer === 'cleaned' ||
this.outputState === HydrationScriptOutputState.Failed ||
this.producerDone ||
this.timeout !== undefined
) {
return
}
this.timeout = setTimeout(() => {
this.timeout = undefined
if (
this.consumer !== 'cleaned' &&
this.outputState !== HydrationScriptOutputState.Failed &&
!this.producerDone
) {
console.error('Serialization timeout after app render finished')
this.fail(new Error('Serialization timeout after app render finished'))
}
}, timeoutMs)
}
cleanup() {
if (this.consumer === 'cleaned') {
return
}
this.consumer = 'cleaned'
this.clearTimeoutIfSet()
this.dropBufferedOutput()
this.listener = undefined
this.outputError = undefined
this.producerDone = true
this.outputState = HydrationScriptOutputState.Done
}
}
/** Create the hydration-script owner for one server request. */
export function createHydrationScripts(
nonce: string | undefined,
initialSources?: ReadonlyArray<string>,
): HydrationScripts {
return new HydrationScriptsOwner(nonce, initialSources)
}