@tanstack/router-core
Version:
Modern and scalable routing for React applications
782 lines (701 loc) • 23.3 kB
text/typescript
import { crossSerializeStream, getCrossReferenceHeader } from 'seroval'
import { invariant } from '../invariant'
import {
createInlineCssPlaceholderAsset,
createInlineCssStyleAsset,
getStylesheetHref,
} from '../manifest'
import { decodePath } from '../utils'
import { createLRUCache } from '../lru-cache'
import { rootRouteId } from '../root'
import minifiedTsrBootStrapScript from './tsrScript?script-string'
import { GLOBAL_TSR, TSR_SCRIPT_BARRIER_ID } from './constants'
import { dehydrateSsrMatchId } from './ssr-match-id'
import { defaultSerovalPlugins } from './serializer/seroval-plugins'
import { makeSsrSerovalPlugin } from './serializer/transformer'
import type { LRUCache } from '../lru-cache'
import type { DehydratedMatch, DehydratedRouter } from './types'
import type { AnySerializationAdapter } from './serializer/transformer'
import type { AnyRouter, ServerSsr } from '../router'
import type { AnyRouteMatch } from '../Matches'
import type {
Manifest,
ManifestRoute,
ManifestRouteAssets,
RouterManagedTag,
ServerManifest,
} from '../manifest'
const SCOPE_ID = 'tsr'
const TSR_PREFIX = GLOBAL_TSR + '.router='
const P_PREFIX = GLOBAL_TSR + '.p(()=>'
const P_SUFFIX = ')'
export function dehydrateMatch(match: AnyRouteMatch): DehydratedMatch {
const dehydratedMatch: DehydratedMatch = {
i: dehydrateSsrMatchId(match.id),
u: match.updatedAt,
s: match.status,
}
const properties = [
['__beforeLoadContext', 'b'],
['loaderData', 'l'],
['error', 'e'],
['ssr', 'ssr'],
] as const
for (const [key, shorthand] of properties) {
if (match[key] !== undefined) {
dehydratedMatch[shorthand] = match[key]
}
}
if (match.globalNotFound) {
dehydratedMatch.g = true
}
return dehydratedMatch
}
const INITIAL_SCRIPTS = [
getCrossReferenceHeader(SCOPE_ID),
minifiedTsrBootStrapScript,
]
class ScriptBuffer {
private injectScript: ((script: string) => void) | undefined
private _queue: Array<string>
private _scriptBarrierLifted = false
private _cleanedUp = false
private _microtaskVersion = 0
private _pendingMicrotaskVersion = 0
constructor(injectScript: (script: string) => void) {
this.injectScript = injectScript
// Copy INITIAL_SCRIPTS to avoid mutating the shared array
this._queue = INITIAL_SCRIPTS.slice()
}
enqueue(script: string) {
if (this._cleanedUp) return
this._queue.push(script)
if (this._scriptBarrierLifted) {
this.scheduleInjectBufferedScripts()
}
}
liftBarrier() {
if (this._scriptBarrierLifted || this._cleanedUp) return
this._scriptBarrierLifted = true
if (this._queue.length > 0) {
this.scheduleInjectBufferedScripts()
}
}
scheduleInjectBufferedScripts() {
if (this._pendingMicrotaskVersion !== 0) return
const pendingVersion = ++this._microtaskVersion
this._pendingMicrotaskVersion = pendingVersion
queueMicrotask(() => {
if (this._pendingMicrotaskVersion !== pendingVersion) return
this._pendingMicrotaskVersion = 0
this.injectBufferedScripts()
})
}
clearPendingMicrotask() {
if (this._pendingMicrotaskVersion === 0) return
this._pendingMicrotaskVersion = 0
this._microtaskVersion++
}
/**
* Flushes any pending scripts synchronously.
* Call this before signaling serialization finished to ensure all scripts are injected.
*
* IMPORTANT: Only injects if the barrier has been lifted. Before the barrier is lifted,
* scripts should remain in the queue so takeBufferedScripts() can retrieve them
*/
flush() {
if (!this._scriptBarrierLifted) return
if (this._cleanedUp) return
this.clearPendingMicrotask()
this.injectBufferedScripts()
}
takeAll() {
return this.takeScripts(this._queue.length)
}
takeScripts(count: number) {
if (count <= 0) return undefined
const bufferedScripts = this._queue.splice(0, count)
if (bufferedScripts.length === 0) {
return undefined
}
// Optimization: if only one script, avoid join
if (bufferedScripts.length === 1) {
return bufferedScripts[0] + ';document.currentScript.remove()'
}
// Append cleanup script and join - avoid push() to not mutate then iterate
return bufferedScripts.join(';') + ';document.currentScript.remove()'
}
hasPending() {
return this._queue.length > 0
}
injectBufferedScripts() {
if (this._cleanedUp) return
// Early return if queue is empty (avoids unnecessary takeAll() call)
if (this._queue.length === 0) return
const scriptsToInject = this.takeAll()
if (scriptsToInject) {
this.injectScript?.(scriptsToInject)
}
}
cleanup() {
this._cleanedUp = true
this.clearPendingMicrotask()
this._queue = []
this.injectScript = undefined
}
}
const isProd = process.env.NODE_ENV === 'production'
type FilteredRoutes = Manifest['routes']
type PreparedMatchedManifestRoutes = {
routes: FilteredRoutes
hasStrippedRoutes: boolean
inlineCssHrefs?: Array<string>
inlineCss?: string
}
type ManifestLRU = LRUCache<string, PreparedMatchedManifestRoutes>
const MANIFEST_CACHE_SIZE = 100
const manifestCaches = new WeakMap<ServerManifest, ManifestLRU>()
function getManifestCache(manifest: ServerManifest): ManifestLRU {
const cache = manifestCaches.get(manifest)
if (cache) return cache
const newCache = createLRUCache<string, PreparedMatchedManifestRoutes>(
MANIFEST_CACHE_SIZE,
)
manifestCaches.set(manifest, newCache)
return newCache
}
function getInlineCssForPreparedRoutes(
manifest: ServerManifest,
preparedRoutes: PreparedMatchedManifestRoutes,
) {
if (preparedRoutes.inlineCss !== undefined) return preparedRoutes.inlineCss
const styles = manifest.inlineCss?.styles
const hrefs = preparedRoutes.inlineCssHrefs
if (!styles || !hrefs?.length) return undefined
let css = ''
for (const href of hrefs) {
css += styles[href]!
}
preparedRoutes.inlineCss = css
return css
}
function getInlineCssAssetForPreparedRoutes(
manifest: ServerManifest,
preparedRoutes: PreparedMatchedManifestRoutes,
) {
const css = getInlineCssForPreparedRoutes(manifest, preparedRoutes)
return css === undefined ? undefined : createInlineCssStyleAsset(css)
}
function getMatchedRoutesCacheKey(matches: Array<AnyRouteMatch>) {
let cacheKey = ''
for (let i = 0; i < matches.length; i++) {
cacheKey += (i === 0 ? '' : '\0') + matches[i]!.routeId
}
return cacheKey
}
function getPreparedMatchedManifestRoutes(
manifest: ServerManifest,
matches: Array<AnyRouteMatch>,
cacheKey: string,
) {
if (isProd) {
const cached = getManifestCache(manifest).get(cacheKey)
if (cached) {
return cached
}
}
const preparedRoutes = prepareMatchedManifestRoutes(manifest, matches)
if (isProd) {
getManifestCache(manifest).set(cacheKey, preparedRoutes)
}
return preparedRoutes
}
function prepareMatchedManifestRoutes(
manifest: ServerManifest,
matches: Array<AnyRouteMatch>,
): PreparedMatchedManifestRoutes {
const inlineStyles = manifest.inlineCss?.styles
const routes: FilteredRoutes = {}
if (!inlineStyles) {
for (const match of matches) {
const route = manifest.routes[match.routeId]
if (route) {
routes[match.routeId] = route
}
}
return { routes, hasStrippedRoutes: false }
}
const inlineCssHrefs: Array<string> = []
const seenInlineCssHrefs = new Set<string>()
let hasStrippedRoutes = false
for (const match of matches) {
const routeId = match.routeId
const route = manifest.routes[routeId]
if (!route) {
continue
}
const nextRoute = stripInlinedStylesheetAssetsFromRoute(
inlineStyles,
route,
inlineCssHrefs,
seenInlineCssHrefs,
)
if (nextRoute !== route) {
hasStrippedRoutes = true
}
routes[routeId] = nextRoute
}
return {
routes,
hasStrippedRoutes,
...(inlineCssHrefs.length ? { inlineCssHrefs } : {}),
}
}
function stripInlinedStylesheetAssetsFromRoute(
inlineStyles: Record<string, string>,
route: ManifestRoute,
inlineCssHrefs: Array<string>,
seenInlineCssHrefs: Set<string>,
): ManifestRoute {
const css = route.css
if (!css) {
return route
}
if (css.length === 0) {
const nextRoute = { ...route }
delete nextRoute.css
return nextRoute
}
let cssLinks: typeof css | undefined
for (let i = 0; i < css.length; i++) {
const link = css[i]!
const href = getStylesheetHref(link)
if (inlineStyles[href] === undefined) {
if (cssLinks) {
cssLinks.push(link)
}
continue
}
if (!seenInlineCssHrefs.has(href)) {
seenInlineCssHrefs.add(href)
inlineCssHrefs.push(href)
}
if (!cssLinks) {
cssLinks = css.slice(0, i)
}
}
if (!cssLinks) {
return route
}
if (cssLinks.length > 0) {
return { ...route, css: cssLinks }
}
const nextRoute = { ...route }
delete nextRoute.css
return nextRoute
}
function hasRouteAssets(route: ManifestRoute) {
return !!route.scripts?.length || !!route.css?.length
}
function hasRequestAssets(assets: ManifestRouteAssets | undefined) {
return !!assets && (!!assets.preloads?.length || hasRouteAssets(assets))
}
function mergeRequestAssetsIntoRootRoute(
rootRoute: ManifestRoute | undefined,
requestAssets: ManifestRouteAssets | undefined,
): ManifestRoute {
const preloads = requestAssets?.preloads?.length
? [...requestAssets.preloads, ...(rootRoute?.preloads ?? [])]
: rootRoute?.preloads
const scripts = requestAssets?.scripts?.length
? [...requestAssets.scripts, ...(rootRoute?.scripts ?? [])]
: rootRoute?.scripts
const cssLinks = requestAssets?.css?.length
? [...requestAssets.css, ...(rootRoute?.css ?? [])]
: rootRoute?.css
return {
...(rootRoute ?? {}),
...(preloads?.length ? { preloads } : {}),
...(scripts?.length ? { scripts } : {}),
...(cssLinks?.length ? { css: cssLinks } : {}),
}
}
export function attachRouterServerSsrUtils({
router,
manifest,
getRequestAssets,
}: {
router: AnyRouter
manifest: ServerManifest | undefined
getRequestAssets?: () => ManifestRouteAssets | undefined
}) {
router.ssr = {
get manifest() {
if (!manifest) return manifest
const requestAssets = getRequestAssets?.()
const matches = router.stores.matches.get()
const hasAssets = hasRequestAssets(requestAssets)
if (!hasAssets && !manifest.inlineCss) {
return manifest
}
let inlineCssAsset: Manifest['inlineStyle'] | undefined
let routes = manifest.routes
if (manifest.inlineCss) {
const cacheKey = getMatchedRoutesCacheKey(matches)
const preparedManifest = getPreparedMatchedManifestRoutes(
manifest,
matches,
cacheKey,
)
inlineCssAsset = getInlineCssAssetForPreparedRoutes(
manifest,
preparedManifest,
)
if (preparedManifest.hasStrippedRoutes) {
routes = { ...manifest.routes, ...preparedManifest.routes }
}
}
if (!hasAssets) {
return {
...(manifest.scriptFormat
? { scriptFormat: manifest.scriptFormat }
: {}),
...(inlineCssAsset ? { inlineStyle: inlineCssAsset } : {}),
routes,
}
}
const rootRoute = routes[rootRouteId]
// Merge request-scoped assets into root route without mutating cached manifest
return {
...(manifest.scriptFormat
? { scriptFormat: manifest.scriptFormat }
: {}),
...(inlineCssAsset ? { inlineStyle: inlineCssAsset } : {}),
routes: {
...routes,
[rootRouteId]: mergeRequestAssetsIntoRootRoute(
rootRoute,
requestAssets,
),
},
}
},
}
let _dehydrated = false
let _serializationFinished = false
let streamFastPathReserved = false
const renderFinishedListeners: Array<() => void> = []
const injectedHtmlListeners: Array<() => void> = []
const serializationFinishedListeners: Array<() => void> = []
const cleanupListeners: Array<() => void> = []
let cleanupStarted = false
let injectedHtmlBuffer = ''
const callListeners = (listeners: Array<() => void>, errorPrefix: string) => {
const snapshot = listeners.slice()
for (const l of snapshot) {
try {
l()
} catch (err) {
console.error(`${errorPrefix}:`, err)
}
}
}
const removeListener = (
listeners: Array<() => void>,
listener: () => void,
) => {
const index = listeners.indexOf(listener)
if (index >= 0) listeners.splice(index, 1)
}
const scriptBuffer = new ScriptBuffer((script) => {
serverSsr.injectScript(script)
})
const serverSsr: ServerSsr = {
injectHtml: (html: string) => {
if (!html || cleanupStarted) return
// Buffer the HTML so it can be retrieved via takeBufferedHtml()
injectedHtmlBuffer += html
callListeners(injectedHtmlListeners, 'SSR injected HTML listener error')
},
injectScript: (script: string) => {
if (!script || cleanupStarted) return
const html = `<script${router.options.ssr?.nonce ? ` nonce='${router.options.ssr.nonce}'` : ''}>${script}</script>`
serverSsr.injectHtml(html)
},
dehydrate: async (opts?: { requestAssets?: ManifestRouteAssets }) => {
if (_dehydrated) {
if (process.env.NODE_ENV !== 'production') {
throw new Error('Invariant failed: router is already dehydrated!')
}
invariant()
}
let matchesToDehydrate = router.stores.matches.get()
if (router.isShell()) {
// In SPA mode we only want to dehydrate the root match
matchesToDehydrate = matchesToDehydrate.slice(0, 1)
}
const matches = matchesToDehydrate.map(dehydrateMatch)
let manifestToDehydrate: Manifest | undefined = undefined
// Only currently matched routes are dehydrated. Other route assets are
// loaded through dynamic imports when those routes become active.
if (manifest) {
const cacheKey = getMatchedRoutesCacheKey(matchesToDehydrate)
const preparedManifest = getPreparedMatchedManifestRoutes(
manifest,
matchesToDehydrate,
cacheKey,
)
manifestToDehydrate = {
...(manifest.scriptFormat
? { scriptFormat: manifest.scriptFormat }
: {}),
...(preparedManifest.inlineCssHrefs
? { inlineStyle: createInlineCssPlaceholderAsset() }
: {}),
routes: preparedManifest.routes,
}
// Merge request-scoped assets into root route (without mutating cached manifest)
const requestAssets = opts?.requestAssets
if (hasRequestAssets(requestAssets)) {
const existingRoot = manifestToDehydrate.routes[rootRouteId]
manifestToDehydrate.routes = {
...manifestToDehydrate.routes,
[rootRouteId]: mergeRequestAssetsIntoRootRoute(
existingRoot,
requestAssets,
),
}
}
}
const dehydratedRouter: DehydratedRouter = {
manifest: manifestToDehydrate,
matches,
}
const lastMatchId = matchesToDehydrate[matchesToDehydrate.length - 1]?.id
if (lastMatchId) {
dehydratedRouter.lastMatchId = dehydrateSsrMatchId(lastMatchId)
}
const dehydratedData = await router.options.dehydrate?.()
if (dehydratedData) {
dehydratedRouter.dehydratedData = dehydratedData
}
_dehydrated = true
const trackPlugins = { didRun: false }
const serializationAdapters = router.options.serializationAdapters as
| Array<AnySerializationAdapter>
| undefined
const plugins = serializationAdapters
? serializationAdapters
.map((t) => makeSsrSerovalPlugin(t, trackPlugins))
.concat(defaultSerovalPlugins)
: defaultSerovalPlugins
let serializationCompleteSignaled = false
const signalSerializationComplete = () => {
if (serializationCompleteSignaled || cleanupStarted) return
serializationCompleteSignaled = true
_serializationFinished = true
const listeners = serializationFinishedListeners.slice()
serializationFinishedListeners.length = 0
for (const l of listeners) {
try {
l()
} catch (err) {
console.error('Serialization listener error:', err)
}
}
}
const finishScriptSerialization = () => {
if (serializationCompleteSignaled || cleanupStarted) return
scriptBuffer.enqueue(GLOBAL_TSR + '.e()')
// Must synchronously notify injected HTML listeners before signaling
// completion; otherwise the held </body> tail could flush ahead of the
// end script.
scriptBuffer.flush()
signalSerializationComplete()
}
crossSerializeStream(dehydratedRouter, {
refs: new Map(),
plugins,
onSerialize: (data, initial) => {
let serialized = initial ? TSR_PREFIX + data : data
if (trackPlugins.didRun) {
serialized = P_PREFIX + serialized + P_SUFFIX
}
scriptBuffer.enqueue(serialized)
},
onError: (err: unknown) => {
console.error('Serialization error:', err)
if (err && (err as any).stack) {
console.error((err as any).stack)
}
finishScriptSerialization()
},
scopeId: SCOPE_ID,
onDone: () => {
finishScriptSerialization()
},
})
},
isDehydrated() {
return _dehydrated
},
isSerializationFinished() {
return _serializationFinished
},
reserveStreamFastPath() {
if (
!cleanupStarted &&
_serializationFinished &&
!streamFastPathReserved &&
renderFinishedListeners.length === 0 &&
!injectedHtmlBuffer &&
!scriptBuffer.hasPending()
) {
streamFastPathReserved = true
return true
}
return false
},
onInjectedHtml: (listener) => {
if (cleanupStarted) return () => {}
injectedHtmlListeners.push(listener)
return () => removeListener(injectedHtmlListeners, listener)
},
onRenderFinished: (listener) => {
if (cleanupStarted || streamFastPathReserved) return
renderFinishedListeners.push(listener)
},
onSerializationFinished: (listener) => {
if (cleanupStarted) return () => {}
if (_serializationFinished && !cleanupStarted) {
try {
listener()
} catch (err) {
console.error('Serialization listener error:', err)
}
return () => {}
}
serializationFinishedListeners.push(listener)
return () => removeListener(serializationFinishedListeners, listener)
},
onCleanup: (listener) => {
if (cleanupStarted) return
cleanupListeners.push(listener)
},
setRenderFinished: () => {
if (cleanupStarted) return
scriptBuffer.liftBarrier()
const listeners = renderFinishedListeners.slice()
renderFinishedListeners.length = 0
for (const l of listeners) {
try {
l()
} catch (err) {
console.error('Error in render finished listener:', err)
}
}
if (_serializationFinished) {
scriptBuffer.flush()
}
},
takeBufferedScripts() {
const scripts = scriptBuffer.takeAll()
if (!scripts) return undefined
const serverBufferedScript: RouterManagedTag = {
tag: 'script',
attrs: {
nonce: router.options.ssr?.nonce,
className: '$tsr',
id: TSR_SCRIPT_BARRIER_ID,
},
children: scripts,
}
return serverBufferedScript
},
liftScriptBarrier() {
scriptBuffer.liftBarrier()
},
takeBufferedHtml() {
if (!injectedHtmlBuffer) {
return undefined
}
const buffered = injectedHtmlBuffer
injectedHtmlBuffer = ''
return buffered
},
cleanup() {
// Guard against multiple/reentrant cleanup calls. A listener could call
// cleanup() again indirectly; snapshot + clear before invoking so each
// listener runs exactly once and reentry is a no-op.
if (cleanupStarted) return
cleanupStarted = true
const listeners = cleanupListeners.slice()
cleanupListeners.length = 0
for (const l of listeners) {
try {
l()
} catch (err) {
console.error('Error in SSR cleanup listener:', err)
}
}
renderFinishedListeners.length = 0
injectedHtmlListeners.length = 0
serializationFinishedListeners.length = 0
injectedHtmlBuffer = ''
scriptBuffer.cleanup()
router.serverSsr = undefined
},
}
router.serverSsr = serverSsr
for (const listener of router.serverSsrLifecycle?.onServerSsrAttach ?? []) {
try {
listener(serverSsr)
} catch (err) {
console.error('SSR attach listener error:', err)
}
}
}
/**
* Get the origin for the request.
*
* SECURITY: We intentionally do NOT trust the Origin header for determining
* the router's origin. The Origin header can be spoofed by attackers, which
* could lead to SSRF-like vulnerabilities where redirects are constructed
* using a malicious origin (CVE-2024-34351).
*
* Instead, we derive the origin from request.url, which is typically set by
* the server infrastructure (not client-controlled headers).
*
* For applications behind proxies that need to trust forwarded headers,
* use the router's `origin` option to explicitly configure a trusted origin.
*/
export function getOrigin(request: Request) {
try {
return new URL(request.url).origin
} catch {}
return 'http://localhost'
}
// server and browser can decode/encode characters differently in paths and search params.
// Server generally strictly follows the WHATWG URL Standard, while browsers may differ for legacy reasons.
// for example, in paths "|" is not encoded on the server but is encoded on chromium (and not on firefox) while "대" is encoded on both sides.
// Another anomaly is that in Node new URLSearchParams and new URL also decode/encode characters differently.
// new URLSearchParams() encodes "|" while new URL() does not, and in this instance
// chromium treats search params differently than paths, i.e. "|" is not encoded in search params.
export function getNormalizedURL(url: string | URL, base?: string | URL) {
// ensure backslashes are encoded correctly in the URL
if (typeof url === 'string') url = url.replace('\\', '%5C')
const rawUrl = new URL(url, base)
const { path: decodedPathname, handledProtocolRelativeURL } = decodePath(
rawUrl.pathname,
)
const searchParams = new URLSearchParams(rawUrl.search)
const normalizedHref =
decodedPathname +
(searchParams.size > 0 ? '?' : '') +
searchParams.toString() +
rawUrl.hash
return {
url: new URL(normalizedHref, rawUrl.origin),
handledProtocolRelativeURL,
}
}