UNPKG

@tanstack/router-core

Version:

Modern and scalable routing for React applications

380 lines (379 loc) 14.2 kB
import { decodePath } from "../utils.js"; import { invariant } from "../invariant.js"; import { createSieveCache } from "../sieve-cache.js"; import { rootRouteId } from "../root.js"; import { dehydrateSsrMatchId } from "./ssr-match-id.js"; import { _getRenderedMatches } from "../load-client.js"; import { createInlineCssPlaceholderAsset, createInlineCssStyleAsset, getStylesheetHref } from "../manifest.js"; import { waitForReason } from "../await-signal.js"; import { createHydrationScripts } from "./hydrationScripts.js"; import { ssrSerovalPlugins } from "./serializer/seroval-plugins.ssr.js"; import { makeSsrSerovalPlugin } from "./serializer/makeSsrSerovalPlugin.js"; import { crossSerializeStream } from "seroval"; //#region src/ssr/ssr-server.ts 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._notFound) dehydratedMatch.g = true; return dehydratedMatch; } function disposeSerializationSafely(dispose) { try { dispose?.(); } catch (err) { console.error("Error disposing SSR serialization:", err); } } function notifyAndClearListeners(listeners, errorMessage, arg) { const pending = listeners.slice(); listeners.length = 0; for (const listener of pending) try { listener(arg); } catch (error) { console.error(errorMessage, error); } } 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 = createSieveCache(MANIFEST_CACHE_SIZE); manifestCaches.set(manifest, newCache); return newCache; } function getInlineCssForPreparedRoutes(manifest, preparedRoutes) { const styles = manifest.inlineCss?.styles; const hrefs = preparedRoutes.inlineCssHrefs; if (!styles || !hrefs?.length) return; let css = ""; for (const href of hrefs) css += styles[href]; 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 } : {} }; } /** * Compose a client-facing manifest from prepared routes, an optional inline * style, and optional request-scoped assets merged into the root route. * Shared by the `router.ssr.manifest` getter and `dehydrate()` so the two * compositions cannot drift. */ function composeManifest(scriptFormat, inlineStyle, routes, requestAssets) { const base = { ...scriptFormat ? { scriptFormat } : {}, ...inlineStyle ? { inlineStyle } : {}, routes }; if (!hasRequestAssets(requestAssets)) return base; return { ...base, routes: { ...routes, [rootRouteId]: mergeRequestAssetsIntoRootRoute(routes[rootRouteId], requestAssets) } }; } function attachRouterServerSsrUtils({ router, manifest, getRequestAssets }) { let memoizedPreparedManifest; router.ssr = { get manifest() { if (!manifest) return manifest; const requestAssets = getRequestAssets?.(); const hasAssets = hasRequestAssets(requestAssets); if (!hasAssets && !manifest.inlineCss) return manifest; let inlineCssAsset; let routes = manifest.routes; if (manifest.inlineCss) { const matches = _getRenderedMatches(router.stores.matches.get()); const cacheKey = getMatchedRoutesCacheKey(matches); if (memoizedPreparedManifest?.cacheKey === cacheKey) { inlineCssAsset = memoizedPreparedManifest.inlineCssAsset; routes = memoizedPreparedManifest.routes; } else { const preparedManifest = getPreparedMatchedManifestRoutes(manifest, matches, cacheKey); inlineCssAsset = getInlineCssAssetForPreparedRoutes(manifest, preparedManifest); if (preparedManifest.hasStrippedRoutes) routes = { ...manifest.routes, ...preparedManifest.routes }; memoizedPreparedManifest = { cacheKey, inlineCssAsset, routes }; } } return composeManifest(manifest.scriptFormat, inlineCssAsset, routes, hasAssets ? requestAssets : void 0); } }; let dehydrationPhase = "idle"; let renderFinished = false; const renderFinishedListeners = []; const cleanupListeners = []; let cleanupStarted = false; let settled = false; let disposeSerialization; const hydrationScripts = createHydrationScripts(router.options.ssr?.nonce); const serverSsr = { hydrationScripts, dehydrate: async (opts) => { if (dehydrationPhase !== "idle") { if (process.env.NODE_ENV !== "production") throw new Error(dehydrationPhase === "disabled" ? "Invariant failed: hydration is disabled for this request!" : "Invariant failed: router is already dehydrated!"); invariant(); } opts?.signal?.throwIfAborted(); dehydrationPhase = "started"; let matchesToDehydrate = _getRenderedMatches(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 = composeManifest(manifest.scriptFormat, preparedManifest.inlineCssHrefs ? createInlineCssPlaceholderAsset() : void 0, preparedManifest.routes, opts?.requestAssets); } const dehydratedRouter = { manifest: manifestToDehydrate, matches }; const dehydrate = router.options.dehydrate; const dehydratedData = dehydrate ? opts?.signal ? await waitForReason(dehydrate.call(router.options), opts.signal) : await dehydrate.call(router.options) : void 0; opts?.signal?.throwIfAborted(); if (cleanupStarted) return; if (dehydratedData !== void 0) dehydratedRouter.dehydratedData = dehydratedData; const trackPlugins = { didRun: false }; const serializationAdapters = router.options.serializationAdapters; const plugins = serializationAdapters ? [...serializationAdapters.map((adapter) => /* @__PURE__ */ makeSsrSerovalPlugin(adapter, trackPlugins)), ...ssrSerovalPlugins] : ssrSerovalPlugins; let serializationCompleteSignaled = false; let initialSerialized = false; const completeScriptSerialization = (result) => { if (serializationCompleteSignaled || cleanupStarted) return; serializationCompleteSignaled = true; const dispose = disposeSerialization; disposeSerialization = void 0; if (result === true) { settled = true; hydrationScripts.finish(); } else if (result) hydrationScripts.fail(result.error); if (dispose) queueMicrotask(() => disposeSerializationSafely(dispose)); }; let synchronousFailure; const dispose = crossSerializeStream(dehydratedRouter, { refs: /* @__PURE__ */ new Map(), plugins, onSerialize: (data, initial) => { if (serializationCompleteSignaled || cleanupStarted) return; initialSerialized ||= initial; if (!hydrationScripts.pushSerializedSource(data, initial, trackPlugins.didRun)) completeScriptSerialization(false); }, onError: (err) => { if (serializationCompleteSignaled || cleanupStarted) return; console.error("Serialization error:", err); synchronousFailure = { error: err }; completeScriptSerialization({ error: err }); }, scopeId: "tsr", onDone: () => { if (initialSerialized) completeScriptSerialization(true); } }); if (cleanupStarted || serializationCompleteSignaled) disposeSerializationSafely(dispose); else disposeSerialization = dispose; if (synchronousFailure) throw synchronousFailure.error; }, onRenderFinished: (listener) => { if (cleanupStarted) return; if (renderFinished) { try { listener(); } catch (error) { console.error("Error in render finished listener:", error); } return; } renderFinishedListeners.push(listener); }, onCleanup: (listener) => { if (cleanupStarted) { try { listener(settled); } catch (error) { console.error("Error in SSR cleanup listener:", error); } return; } cleanupListeners.push(listener); }, setRenderFinished: () => { if (cleanupStarted || renderFinished) return; renderFinished = true; hydrationScripts.liftBarrier(); notifyAndClearListeners(renderFinishedListeners, "Error in render finished listener:", void 0); }, disableHydration: () => { if (cleanupStarted || dehydrationPhase === "disabled") return; if (dehydrationPhase !== "idle") { if (process.env.NODE_ENV !== "production") throw new Error("Invariant failed: cannot disable hydration after dehydrate()!"); invariant(); } hydrationScripts.disableHydration(); dehydrationPhase = "disabled"; }, takeInitialHydrationScriptTags: hydrationScripts.takeInitialHydrationScriptTags, cleanup() { if (cleanupStarted) return; cleanupStarted = true; hydrationScripts.cleanup(); const dispose = disposeSerialization; disposeSerialization = void 0; disposeSerializationSafely(dispose); notifyAndClearListeners(cleanupListeners, "Error in SSR cleanup listener:", settled); renderFinishedListeners.length = 0; router.ssr = void 0; 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 handledProtocolRelativeURL = rawUrl.pathname.startsWith("//"); const decodedPathname = decodePath(handledProtocolRelativeURL ? rawUrl.pathname.replace(/^\/+/, "/") : 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