UNPKG

@tanstack/router-core

Version:

Modern and scalable routing for React applications

497 lines (496 loc) 17.4 kB
import { decodePath } from "../utils.js"; import { invariant } from "../invariant.js"; import { createLRUCache } from "../lru-cache.js"; import { rootRouteId } from "../root.js"; import { createInlineCssPlaceholderAsset, createInlineCssStyleAsset, getStylesheetHref } from "../manifest.js"; import { GLOBAL_TSR, TSR_SCRIPT_BARRIER_ID } from "./constants.js"; import { makeSsrSerovalPlugin } from "./serializer/transformer.js"; import { defaultSerovalPlugins } from "./serializer/seroval-plugins.js"; import { dehydrateSsrMatchId } from "./ssr-match-id.js"; import tsrScript_default from "./tsrScript.js"; import { crossSerializeStream, getCrossReferenceHeader } from "seroval"; //#region src/ssr/ssr-server.ts const SCOPE_ID = "tsr"; const TSR_PREFIX = GLOBAL_TSR + ".router="; const P_PREFIX = GLOBAL_TSR + ".p(()=>"; const P_SUFFIX = ")"; function dehydrateMatch(match) { const dehydratedMatch = { i: dehydrateSsrMatchId(match.id), u: match.updatedAt, s: match.status }; for (const [key, shorthand] of [ ["__beforeLoadContext", "b"], ["loaderData", "l"], ["error", "e"], ["ssr", "ssr"] ]) if (match[key] !== void 0) dehydratedMatch[shorthand] = match[key]; if (match.globalNotFound) dehydratedMatch.g = true; return dehydratedMatch; } const INITIAL_SCRIPTS = [getCrossReferenceHeader(SCOPE_ID), tsrScript_default]; var ScriptBuffer = class { constructor(injectScript) { this._scriptBarrierLifted = false; this._cleanedUp = false; this._microtaskVersion = 0; this._pendingMicrotaskVersion = 0; this.injectScript = injectScript; this._queue = INITIAL_SCRIPTS.slice(); } enqueue(script) { 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) { if (count <= 0) return void 0; const bufferedScripts = this._queue.splice(0, count); if (bufferedScripts.length === 0) return; if (bufferedScripts.length === 1) return bufferedScripts[0] + ";document.currentScript.remove()"; return bufferedScripts.join(";") + ";document.currentScript.remove()"; } hasPending() { return this._queue.length > 0; } injectBufferedScripts() { if (this._cleanedUp) return; 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 = void 0; } }; const isProd = process.env.NODE_ENV === "production"; const MANIFEST_CACHE_SIZE = 100; const manifestCaches = /* @__PURE__ */ new WeakMap(); function getManifestCache(manifest) { const cache = manifestCaches.get(manifest); if (cache) return cache; const newCache = createLRUCache(MANIFEST_CACHE_SIZE); manifestCaches.set(manifest, newCache); return newCache; } function getInlineCssForPreparedRoutes(manifest, preparedRoutes) { if (preparedRoutes.inlineCss !== void 0) return preparedRoutes.inlineCss; const styles = manifest.inlineCss?.styles; const hrefs = preparedRoutes.inlineCssHrefs; if (!styles || !hrefs?.length) return void 0; let css = ""; for (const href of hrefs) css += styles[href]; preparedRoutes.inlineCss = css; return css; } function getInlineCssAssetForPreparedRoutes(manifest, preparedRoutes) { const css = getInlineCssForPreparedRoutes(manifest, preparedRoutes); return css === void 0 ? void 0 : createInlineCssStyleAsset(css); } function getMatchedRoutesCacheKey(matches) { let cacheKey = ""; for (let i = 0; i < matches.length; i++) cacheKey += (i === 0 ? "" : "\0") + matches[i].routeId; return cacheKey; } function getPreparedMatchedManifestRoutes(manifest, matches, cacheKey) { 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, matches) { const inlineStyles = manifest.inlineCss?.styles; const routes = {}; 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 = []; const seenInlineCssHrefs = /* @__PURE__ */ new Set(); 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, route, inlineCssHrefs, seenInlineCssHrefs) { const css = route.css; if (!css) return route; if (css.length === 0) { const nextRoute = { ...route }; delete nextRoute.css; return nextRoute; } let cssLinks; for (let i = 0; i < css.length; i++) { const link = css[i]; const href = getStylesheetHref(link); if (inlineStyles[href] === void 0) { 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) { return !!route.scripts?.length || !!route.css?.length; } function hasRequestAssets(assets) { return !!assets && (!!assets.preloads?.length || hasRouteAssets(assets)); } function mergeRequestAssetsIntoRootRoute(rootRoute, requestAssets) { 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 } : {} }; } function attachRouterServerSsrUtils({ router, manifest, getRequestAssets }) { 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; let routes = manifest.routes; if (manifest.inlineCss) { const preparedManifest = getPreparedMatchedManifestRoutes(manifest, matches, getMatchedRoutesCacheKey(matches)); 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]; 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 = []; const injectedHtmlListeners = []; const serializationFinishedListeners = []; const cleanupListeners = []; let cleanupStarted = false; let injectedHtmlBuffer = ""; const callListeners = (listeners, errorPrefix) => { const snapshot = listeners.slice(); for (const l of snapshot) try { l(); } catch (err) { console.error(`${errorPrefix}:`, err); } }; const removeListener = (listeners, listener) => { const index = listeners.indexOf(listener); if (index >= 0) listeners.splice(index, 1); }; const scriptBuffer = new ScriptBuffer((script) => { serverSsr.injectScript(script); }); const serverSsr = { injectHtml: (html) => { if (!html || cleanupStarted) return; injectedHtmlBuffer += html; callListeners(injectedHtmlListeners, "SSR injected HTML listener error"); }, injectScript: (script) => { if (!script || cleanupStarted) return; const html = `<script${router.options.ssr?.nonce ? ` nonce='${router.options.ssr.nonce}'` : ""}>${script}<\/script>`; serverSsr.injectHtml(html); }, dehydrate: async (opts) => { 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()) matchesToDehydrate = matchesToDehydrate.slice(0, 1); const matches = matchesToDehydrate.map(dehydrateMatch); let manifestToDehydrate = void 0; 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 }; const requestAssets = opts?.requestAssets; if (hasRequestAssets(requestAssets)) { const existingRoot = manifestToDehydrate.routes[rootRouteId]; manifestToDehydrate.routes = { ...manifestToDehydrate.routes, [rootRouteId]: mergeRequestAssetsIntoRootRoute(existingRoot, requestAssets) }; } } const 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; const plugins = serializationAdapters ? serializationAdapters.map((t) => /* @__PURE__ */ 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()"); scriptBuffer.flush(); signalSerializationComplete(); }; crossSerializeStream(dehydratedRouter, { refs: /* @__PURE__ */ 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) => { console.error("Serialization error:", err); if (err && err.stack) console.error(err.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 void 0; return { tag: "script", attrs: { nonce: router.options.ssr?.nonce, className: "$tsr", id: TSR_SCRIPT_BARRIER_ID }, children: scripts }; }, liftScriptBarrier() { scriptBuffer.liftBarrier(); }, takeBufferedHtml() { if (!injectedHtmlBuffer) return; const buffered = injectedHtmlBuffer; injectedHtmlBuffer = ""; return buffered; }, cleanup() { 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 = void 0; } }; 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. */ function getOrigin(request) { try { return new URL(request.url).origin; } catch {} return "http://localhost"; } function getNormalizedURL(url, base) { 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 }; } //#endregion export { attachRouterServerSsrUtils, getNormalizedURL, getOrigin }; //# sourceMappingURL=ssr-server.js.map