UNPKG

@tanstack/start-server-core

Version:

Modern and scalable routing for React applications

486 lines (424 loc) 12.7 kB
import { resolveManifestAssetLink, rootRouteId } from '@tanstack/router-core' import type { AssetCrossOrigin, Awaitable, Manifest, ManifestAssetLink, RouterManagedTag, } from '@tanstack/router-core' export type { AssetCrossOrigin } export type TransformAssetKind = 'modulepreload' | 'stylesheet' | 'clientEntry' type TransformAssetsShorthandCrossOriginKind = Exclude< TransformAssetKind, 'clientEntry' > export type AssetUrlType = TransformAssetKind export interface TransformAssetsContext { url: string kind: TransformAssetKind } export type TransformAssetResult = | string | { href: string crossOrigin?: AssetCrossOrigin } export type TransformAssetsFn = ( context: TransformAssetsContext, ) => Awaitable<TransformAssetResult> export interface TransformAssetUrlsContext { url: string type: AssetUrlType } export type TransformAssetUrlsFn = ( context: TransformAssetUrlsContext, ) => Awaitable<string> export type CreateTransformAssetUrlsContext = | { /** True when the server is computing the cached manifest during startup warmup. */ warmup: true } | { /** * The current Request. * * Only available during request handling (i.e. when `warmup: false`). */ request: Request /** False when transforming URLs as part of request handling. */ warmup: false } /** * Async factory that runs once per manifest computation and returns the * per-asset transform. */ export type CreateTransformAssetUrlsFn = ( ctx: CreateTransformAssetUrlsContext, ) => Awaitable<TransformAssetUrlsFn> export type CreateTransformAssetsFn = ( ctx: CreateTransformAssetUrlsContext, ) => Awaitable<TransformAssetsFn> type TransformAssetUrlsOptionsBase = { /** * Whether to cache the transformed manifest after the first request. * * When `true` (default), the transform runs once on the first request and * the resulting manifest is reused for all subsequent requests in production. * * Set to `false` for per-request transforms (e.g. geo-routing to different * CDNs based on request headers). * * @default true */ cache?: boolean /** * When `true`, warms up the cached transformed manifest in the background when * the server starts (production only). * * This can reduce latency for the first request when `cache` is `true`. * Has no effect when `cache: false` (per-request transforms) or in dev mode. * * @default false */ warmup?: boolean } export type TransformAssetUrlsOptions = | (TransformAssetUrlsOptionsBase & { /** * The transform to apply to asset URLs. Can be a string prefix or a callback. * * **String** — prepended to every asset URL. * **Callback** — receives `{ url, type }` and returns a new URL. */ transform: string | TransformAssetUrlsFn createTransform?: never }) | (TransformAssetUrlsOptionsBase & { /** * Create a per-asset transform function. * * This factory runs once per manifest computation (per request when * `cache: false`, or once per server when `cache: true`). It can do async * setup work (fetch config, read from a KV, etc.) and return a fast * per-asset transformer. */ createTransform: CreateTransformAssetUrlsFn transform?: never }) export type TransformAssetsOptions = | (TransformAssetUrlsOptionsBase & { transform: string | TransformAssetsFn createTransform?: never }) | (TransformAssetUrlsOptionsBase & { createTransform: CreateTransformAssetsFn transform?: never }) export type TransformAssetUrls = | string | TransformAssetUrlsFn | TransformAssetUrlsOptions /** * Per-kind crossOrigin configuration for the object shorthand. * * Accepts either a single value applied to all asset kinds, or a per-kind * record (matching `HeadContent`'s `assetCrossOrigin` shape): * * ```ts * // All assets get the same value * crossOrigin: 'anonymous' * * // Different values per kind * crossOrigin: { modulepreload: 'anonymous', stylesheet: 'use-credentials' } * ``` */ export type TransformAssetsCrossOriginConfig = | AssetCrossOrigin | Partial<Record<TransformAssetsShorthandCrossOriginKind, AssetCrossOrigin>> /** * Object shorthand for `transformAssets`. Combines a URL prefix with optional * per-asset `crossOrigin` without needing a callback: * * ```ts * transformAssets: { * prefix: 'https://cdn.example.com', * crossOrigin: 'anonymous', * } * ``` */ export interface TransformAssetsObjectShorthand { /** URL prefix prepended to every asset URL. */ prefix: string /** * Optional crossOrigin attribute applied to manifest-managed `<link>` assets. * * Accepts a single value or a per-kind record. */ crossOrigin?: TransformAssetsCrossOriginConfig } export type TransformAssets = | string | TransformAssetsFn | TransformAssetsObjectShorthand | TransformAssetsOptions export type ResolvedTransformAssetsConfig = | { type: 'transform' transformFn: TransformAssetsFn cache: boolean } | { type: 'createTransform' createTransform: CreateTransformAssetsFn cache: boolean } let hasWarnedAboutDeprecatedTransformAssetUrls = false export function warnDeprecatedTransformAssetUrls() { if ( (process.env.NODE_ENV === 'development' || process.env.TSS_DEV_SERVER === 'true') && !hasWarnedAboutDeprecatedTransformAssetUrls ) { hasWarnedAboutDeprecatedTransformAssetUrls = true console.warn( '[TanStack Start] `transformAssetUrls` is deprecated. Use `transformAssets` instead.', ) } } function normalizeTransformAssetResult( result: TransformAssetResult, ): Exclude<TransformAssetResult, string> { if (typeof result === 'string') { return { href: result } } return result } function resolveTransformAssetsCrossOrigin( config: TransformAssetsCrossOriginConfig | undefined, kind: TransformAssetsShorthandCrossOriginKind, ): AssetCrossOrigin | undefined { if (!config) return undefined if (typeof config === 'string') return config return config[kind] } function isObjectShorthand( transform: TransformAssetsObjectShorthand | TransformAssetsOptions, ): transform is TransformAssetsObjectShorthand { return 'prefix' in transform } export function resolveTransformAssetsConfig( transform: TransformAssets, ): ResolvedTransformAssetsConfig { if (typeof transform === 'string') { const prefix = transform return { type: 'transform', transformFn: ({ url }) => ({ href: `${prefix}${url}` }), cache: true, } } if (typeof transform === 'function') { return { type: 'transform', transformFn: transform, cache: true, } } // Object shorthand: { prefix, crossOrigin? } if (isObjectShorthand(transform)) { const { prefix, crossOrigin } = transform return { type: 'transform', transformFn: ({ url, kind }) => { const href = `${prefix}${url}` if (kind === 'clientEntry') { return { href } } const co = resolveTransformAssetsCrossOrigin(crossOrigin, kind) return co ? { href, crossOrigin: co } : { href } }, cache: true, } } if ('createTransform' in transform && transform.createTransform) { return { type: 'createTransform', createTransform: transform.createTransform, cache: transform.cache !== false, } } const transformFn = typeof transform.transform === 'string' ? ((({ url }: TransformAssetsContext) => ({ href: `${transform.transform}${url}`, })) as TransformAssetsFn) : transform.transform return { type: 'transform', transformFn, cache: transform.cache !== false, } } export function adaptTransformAssetUrlsToTransformAssets( transformFn: TransformAssetUrlsFn, ): TransformAssetsFn { return async ({ url, kind }) => ({ href: await transformFn({ url, type: kind }), }) } export function adaptTransformAssetUrlsConfigToTransformAssets( transform: TransformAssetUrls, ): TransformAssets { warnDeprecatedTransformAssetUrls() if (typeof transform === 'string') { return transform } if (typeof transform === 'function') { return adaptTransformAssetUrlsToTransformAssets(transform) } if ('createTransform' in transform && transform.createTransform) { return { createTransform: async (ctx: CreateTransformAssetUrlsContext) => adaptTransformAssetUrlsToTransformAssets( await transform.createTransform(ctx), ), cache: transform.cache, warmup: transform.warmup, } } return { transform: typeof transform.transform === 'string' ? transform.transform : adaptTransformAssetUrlsToTransformAssets(transform.transform), cache: transform.cache, warmup: transform.warmup, } } export interface StartManifestWithClientEntry { manifest: Manifest clientEntry: string /** Script content prepended before the client entry import (dev only) */ injectedHeadScripts?: string } /** * Builds the client entry `<script>` tag from a (possibly transformed) client * entry URL and optional injected head scripts. */ export function buildClientEntryScriptTag( clientEntry: string, injectedHeadScripts?: string, ): RouterManagedTag { const clientEntryLiteral = JSON.stringify(clientEntry) let script = `import(${clientEntryLiteral})` if (injectedHeadScripts) { script = `${injectedHeadScripts};${script}` } return { tag: 'script', attrs: { type: 'module', async: true, }, children: script, } } function assignManifestAssetLink( link: ManifestAssetLink, next: { href: string; crossOrigin?: AssetCrossOrigin }, ): ManifestAssetLink { if (typeof link === 'string') { return next.crossOrigin ? next : next.href } return next.crossOrigin ? next : { href: next.href } } export async function transformManifestAssets( source: StartManifestWithClientEntry, transformFn: TransformAssetsFn, _opts?: { clone?: boolean }, ): Promise<Manifest> { const manifest = structuredClone(source.manifest) for (const route of Object.values(manifest.routes)) { if (route.preloads) { route.preloads = await Promise.all( route.preloads.map(async (link) => { const resolved = resolveManifestAssetLink(link) const result = normalizeTransformAssetResult( await transformFn({ url: resolved.href, kind: 'modulepreload', }), ) return assignManifestAssetLink(link, { href: result.href, crossOrigin: result.crossOrigin, }) }), ) } if (route.assets) { for (const asset of route.assets) { if (asset.tag === 'link' && asset.attrs?.href) { const rel = asset.attrs.rel const relTokens = typeof rel === 'string' ? rel.split(/\s+/) : [] if (!relTokens.includes('stylesheet')) { continue } const result = normalizeTransformAssetResult( await transformFn({ url: asset.attrs.href, kind: 'stylesheet', }), ) asset.attrs.href = result.href if (result.crossOrigin) { asset.attrs.crossOrigin = result.crossOrigin } else { delete asset.attrs.crossOrigin } } } } } const transformedClientEntry = normalizeTransformAssetResult( await transformFn({ url: source.clientEntry, kind: 'clientEntry', }), ) const rootRoute = (manifest.routes[rootRouteId] = manifest.routes[rootRouteId] || {}) rootRoute.assets = rootRoute.assets || [] rootRoute.assets.push( buildClientEntryScriptTag( transformedClientEntry.href, source.injectedHeadScripts, ), ) return manifest } /** * Builds a final Manifest from a StartManifestWithClientEntry without any * URL transforms. Used when no transformAssetUrls option is provided. * * Returns a new manifest object so the cached base manifest is never mutated. */ export function buildManifestWithClientEntry( source: StartManifestWithClientEntry, ): Manifest { const scriptTag = buildClientEntryScriptTag( source.clientEntry, source.injectedHeadScripts, ) const baseRootRoute = source.manifest.routes[rootRouteId] const routes = { ...source.manifest.routes, [rootRouteId]: { ...baseRootRoute, assets: [...(baseRootRoute?.assets || []), scriptTag], }, } return { routes } }