@timber-js/app 0.2.0-alpha.187 → 0.2.0-alpha.188
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/segment-keys-BawYuNFO.js.map +1 -1
- package/dist/client/internal.js +12 -3
- package/dist/client/internal.js.map +1 -1
- package/dist/client/segment-cache.d.ts +6 -0
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/routing/segment-keys.d.ts +22 -0
- package/dist/routing/segment-keys.d.ts.map +1 -1
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/chain-url-parts.d.ts +2 -3
- package/dist/server/chain-url-parts.d.ts.map +1 -1
- package/dist/server/internal.js +1 -7
- package/dist/server/internal.js.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/skippable-prefix.d.ts +3 -0
- package/dist/server/skippable-prefix.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +18 -19
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +9 -16
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts +0 -7
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/shared/segment-info.d.ts +7 -0
- package/dist/shared/segment-info.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/segment-cache.ts +18 -5
- package/src/routing/segment-keys.ts +37 -0
- package/src/server/access-gate.tsx +1 -28
- package/src/server/chain-url-parts.ts +2 -3
- package/src/server/route-element-builder.ts +3 -25
- package/src/server/rsc-entry/helpers.ts +1 -0
- package/src/server/skippable-prefix.ts +21 -39
- package/src/server/slot-resolver.ts +59 -187
- package/src/server/state-tree-diff.ts +11 -62
- package/src/server/tree-builder.ts +0 -10
- package/src/shared/segment-info.ts +7 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"segment-keys-BawYuNFO.js","names":[],"sources":["../../src/server/metadata-routes.ts","../../src/server/canonicalize.ts","../../src/routing/segment-keys.ts"],"sourcesContent":["/**\n * Metadata route classification for timber.js.\n *\n * Metadata routes are file-based endpoints that generate well-known URLs for\n * crawlers and browsers (sitemap.xml, robots.txt, OG images, etc.).\n *\n * These routes run through proxy.ts but NOT through middleware.ts or access.ts —\n * they are public endpoints by nature.\n *\n * See design/16-metadata.md §\"Metadata Routes\"\n */\n\nimport { randomUUID } from 'node:crypto';\nimport type { HeadElement } from './metadata.js';\nimport type { Metadata } from './types.js';\nimport type { ManifestSegmentNode } from './route-matcher.js';\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\n/** Classification of a metadata route file. */\nexport interface MetadataRouteInfo {\n /** The metadata route type. */\n type: MetadataRouteType;\n /** The content type to serve this route with. */\n contentType: string;\n /** Whether this route can appear in nested segments (not just app root). */\n nestable: boolean;\n}\n\nexport type MetadataRouteType =\n | 'sitemap'\n | 'robots'\n | 'manifest'\n | 'favicon'\n | 'icon'\n | 'opengraph-image'\n | 'apple-icon';\n\n// ─── Convention Table ────────────────────────────────────────────────────────\n\n/**\n * All recognized metadata route file conventions.\n *\n * Each entry maps a base file name (without extension) to its route info.\n * The extensions determine whether the file is static or dynamic.\n *\n * Static extensions: .xml, .txt, .json, .png, .jpg, .ico, .svg\n * Dynamic extensions: .ts, .tsx\n */\nexport const METADATA_ROUTE_CONVENTIONS: Record<\n string,\n {\n type: MetadataRouteType;\n contentType: string;\n nestable: boolean;\n staticExtensions: string[];\n dynamicExtensions: string[];\n /**\n * The URL path basename this file serves at (relative to segment).\n * For image routes, the full serve path includes an extension via\n * `resolveServePathForFile()`.\n */\n servePath: string;\n /**\n * When set, image routes append `.{serveExtension}` to the serve path.\n * Dynamic handlers (`.ts`/`.tsx`) use this as the default. Static files\n * use their own extension instead. Non-image routes leave this undefined.\n */\n serveExtension?: string;\n }\n> = {\n 'sitemap': {\n type: 'sitemap',\n contentType: 'application/xml',\n nestable: true,\n staticExtensions: ['xml'],\n dynamicExtensions: ['ts'],\n servePath: 'sitemap.xml',\n },\n 'robots': {\n type: 'robots',\n contentType: 'text/plain',\n nestable: false,\n staticExtensions: ['txt'],\n dynamicExtensions: ['ts'],\n servePath: 'robots.txt',\n },\n 'manifest': {\n type: 'manifest',\n contentType: 'application/manifest+json',\n nestable: false,\n staticExtensions: ['json'],\n dynamicExtensions: ['ts'],\n servePath: 'manifest.webmanifest',\n },\n 'favicon': {\n type: 'favicon',\n contentType: 'image/x-icon',\n nestable: false,\n staticExtensions: ['ico'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'favicon.ico',\n },\n 'icon': {\n type: 'icon',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png', 'jpg', 'svg'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'icon',\n serveExtension: 'png',\n },\n 'opengraph-image': {\n type: 'opengraph-image',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png', 'jpg'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'opengraph-image',\n serveExtension: 'png',\n },\n\n 'apple-icon': {\n type: 'apple-icon',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'apple-icon',\n serveExtension: 'png',\n },\n};\n\n// ─── MIME Type Resolution ─────────────────────────────────────────────────────\n\n/**\n * Map of file extensions to MIME types for static metadata route files.\n * Used to resolve the generic `image/*` content type for static image files.\n */\nconst EXTENSION_MIME_TYPES: Record<string, string> = {\n xml: 'application/xml',\n txt: 'text/plain',\n json: 'application/json',\n ico: 'image/x-icon',\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n svg: 'image/svg+xml',\n webp: 'image/webp',\n};\n\n/**\n * Resolve the concrete MIME type for a static metadata route file.\n *\n * For generic content types like `image/*`, this resolves to the actual\n * MIME type based on the file extension (e.g. `image/png` for `.png`).\n *\n * @param conventionContentType - The content type from the convention table (may be generic like `image/*`)\n * @param extension - The file extension without leading dot (e.g. \"png\", \"xml\")\n * @returns The resolved MIME type\n */\nexport function resolveStaticContentType(conventionContentType: string, extension: string): string {\n if (conventionContentType.includes('*')) {\n return EXTENSION_MIME_TYPES[extension] ?? 'application/octet-stream';\n }\n return conventionContentType;\n}\n\n/**\n * Check if a file extension represents a static (non-code) metadata route file.\n *\n * @param baseName - The base file name without extension (e.g. \"sitemap\", \"icon\")\n * @param extension - The file extension without leading dot (e.g. \"xml\", \"png\", \"ts\")\n * @returns true if this is a static file, false if dynamic or unrecognized\n */\nexport function isStaticMetadataExtension(baseName: string, extension: string): boolean {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return false;\n return convention.staticExtensions.includes(extension);\n}\n\n/**\n * Check if a file extension represents a dynamic (code) metadata route file.\n *\n * @param baseName - The base file name without extension (e.g. \"sitemap\", \"icon\")\n * @param extension - The file extension without leading dot (e.g. \"ts\", \"tsx\")\n * @returns true if this is a dynamic file, false if static or unrecognized\n */\nexport function isDynamicMetadataExtension(baseName: string, extension: string): boolean {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return false;\n return convention.dynamicExtensions.includes(extension);\n}\n\n// ─── Classification ──────────────────────────────────────────────────────────\n\n/**\n * Classify a file name as a metadata route, or return null if it's not one.\n *\n * @param fileName - The full file name including extension (e.g. \"sitemap.xml\", \"icon.tsx\")\n * @returns Classification info, or null if not a metadata route\n */\nexport function classifyMetadataRoute(fileName: string): MetadataRouteInfo | null {\n const dotIndex = fileName.lastIndexOf('.');\n if (dotIndex === -1) return null;\n\n const baseName = fileName.slice(0, dotIndex);\n const ext = fileName.slice(dotIndex + 1);\n\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return null;\n\n const isStatic = convention.staticExtensions.includes(ext);\n const isDynamic = convention.dynamicExtensions.includes(ext);\n\n if (!isStatic && !isDynamic) return null;\n\n return {\n type: convention.type,\n contentType: convention.contentType,\n nestable: convention.nestable,\n };\n}\n\n/**\n * Resolve the serve path for a metadata route file.\n *\n * For image routes (icon, opengraph-image, apple-icon), the serve path includes\n * a file extension so CDNs cache correctly:\n * - Dynamic handlers (.ts/.tsx) use the convention's `serveExtension` (default: .png)\n * - Static files use their own extension (e.g., icon.svg → icon.svg)\n *\n * Non-image routes return the convention's `servePath` as-is (already includes\n * extension: sitemap.xml, robots.txt, etc.).\n */\nexport function resolveServePathForFile(baseName: string, filePath: string): string {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return baseName;\n\n if (!convention.serveExtension) return convention.servePath;\n\n const ext = filePath.slice(filePath.lastIndexOf('.') + 1);\n if (convention.staticExtensions.includes(ext)) {\n return `${convention.servePath}.${ext}`;\n }\n return `${convention.servePath}.${convention.serveExtension}`;\n}\n\n/**\n * Get the default serve path for a metadata route type (using default extension\n * for image routes). Used for auto-link generation when only the type is known.\n */\nexport function getMetadataRouteServePath(type: MetadataRouteType): string {\n for (const convention of Object.values(METADATA_ROUTE_CONVENTIONS)) {\n if (convention.type === type) {\n if (convention.serveExtension) {\n return `${convention.servePath}.${convention.serveExtension}`;\n }\n return convention.servePath;\n }\n }\n throw new Error(`[timber] Unknown metadata route type: ${type}`);\n}\n\n/**\n * All possible serve path segments for metadata routes (includes extension\n * variants for image routes).\n */\nconst METADATA_SERVE_PATHS = new Set<string>();\nfor (const convention of Object.values(METADATA_ROUTE_CONVENTIONS)) {\n if (convention.serveExtension) {\n for (const ext of [...convention.staticExtensions, convention.serveExtension]) {\n METADATA_SERVE_PATHS.add(`${convention.servePath}.${ext}`);\n }\n } else {\n METADATA_SERVE_PATHS.add(convention.servePath);\n }\n}\n\nexport function isMetadataRouteServePath(pathname: string): boolean {\n let lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1);\n const qIdx = lastSegment.indexOf('?');\n let query = '';\n if (qIdx !== -1) {\n query = lastSegment.slice(qIdx + 1);\n lastSegment = lastSegment.slice(0, qIdx);\n }\n if (!METADATA_SERVE_PATHS.has(lastSegment)) return false;\n // Vite module requests (e.g., /src/icon.svg?import) use special query params.\n // These are source assets, not metadata routes.\n if (/(?:^|&)(?:import|url|raw|worker|inline)(?:&|$)/.test(query)) return false;\n return true;\n}\n\n/** A <link> auto-link tag. */\nexport interface AutoLinkLink {\n tag: 'link';\n rel: string;\n href: string;\n type?: string;\n}\n\n/** A <meta> auto-link tag. */\nexport interface AutoLinkMeta {\n tag: 'meta';\n property?: string;\n name?: string;\n content: string;\n}\n\nexport type AutoLinkTag = AutoLinkLink | AutoLinkMeta;\n\n/**\n * Get the auto-link tags to inject into <head> for metadata route files\n * discovered in a segment.\n *\n * Returns link tags for icon/apple-icon/manifest, and meta tags for\n * opengraph-image (emits both og:image and twitter:image). Returns null\n * for types that don't auto-link (favicon, sitemap, robots).\n *\n * @param type - The metadata route type\n * @param href - The resolved URL path to the metadata route\n * @returns Tag descriptor(s) for the <head>, or null if no auto-link\n */\nexport function getMetadataRouteAutoLink(type: MetadataRouteType, href: string): AutoLinkTag[] {\n switch (type) {\n case 'icon':\n return [{ tag: 'link', rel: 'icon', href }];\n case 'apple-icon':\n return [{ tag: 'link', rel: 'apple-touch-icon', href }];\n case 'manifest':\n return [{ tag: 'link', rel: 'manifest', href }];\n case 'opengraph-image':\n return [\n { tag: 'meta', property: 'og:image', content: href },\n { tag: 'meta', name: 'twitter:image', content: href },\n ];\n default:\n return [];\n }\n}\n\n// ─── Auto-Linking ──────────────────────────────────────────────────────────\n\n// In dev mode, use a per-startup nonce for metadata route cache busting\n// instead of per-file content hashes (avoids rehashing on every request).\nlet _devNonce: string | undefined;\nfunction getDevNonce(): string {\n _devNonce ??= randomUUID().slice(0, 8);\n return _devNonce;\n}\n\n/**\n * Collect auto-linked head elements from metadata route files in the segment chain.\n *\n * Walks each segment's metadataRoutes, resolves serve paths and URLs, and\n * emits HeadElement descriptors for <link> and <meta> tags that React Float\n * hoists into <head>.\n *\n * See design/16-metadata.md §\"Auto-Linking\"\n */\nexport function collectMetadataRouteHeadElements(\n segments: ManifestSegmentNode[],\n firstDeniedIndex: number,\n resolvedMetadata: Metadata,\n requestUrl: URL,\n metadataRouteHashes?: Record<string, string>\n): HeadElement[] {\n const elements: HeadElement[] = [];\n const hasUserOgImage = Boolean(resolvedMetadata.openGraph?.images);\n const hasUserTwitterImage = Boolean(resolvedMetadata.twitter?.images);\n const requestPathname = requestUrl.pathname;\n // In dev mode, use the request origin so OG URLs resolve to localhost.\n // In production, use metadataBase (the canonical domain).\n const ogBase =\n process.env.NODE_ENV !== 'production'\n ? new URL(requestUrl.origin)\n : resolvedMetadata.metadataBase;\n\n for (let si = 0; si < segments.length; si++) {\n const segment = segments[si];\n if (!segment.metadataRoutes) continue;\n if (si >= firstDeniedIndex) continue;\n for (const baseName of Object.keys(segment.metadataRoutes)) {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) continue;\n if (!convention.nestable && segment.urlPath !== '/') continue;\n if (convention.type === 'opengraph-image' && hasUserOgImage) continue;\n const resolvedPrefix = convention.nestable\n ? requestPathname === '/'\n ? ''\n : requestPathname\n : '';\n const metaFile = segment.metadataRoutes[baseName];\n const fileServePath = metaFile?.filePath\n ? resolveServePathForFile(baseName, metaFile.filePath)\n : convention.serveExtension\n ? `${convention.servePath}.${convention.serveExtension}`\n : convention.servePath;\n let href = `${resolvedPrefix}/${fileServePath}`;\n if (convention.type === 'opengraph-image') {\n const fileHash = metaFile?.filePath ? metadataRouteHashes?.[metaFile.filePath] : undefined;\n const cacheBust = fileHash ?? getDevNonce();\n href = `${href}?${cacheBust}`;\n }\n if (ogBase && convention.type === 'opengraph-image') {\n href = new URL(href, ogBase).toString();\n }\n for (const autoLink of getMetadataRouteAutoLink(convention.type, href)) {\n if (\n hasUserTwitterImage &&\n autoLink.tag === 'meta' &&\n 'name' in autoLink &&\n autoLink.name === 'twitter:image'\n )\n continue;\n if (autoLink.tag === 'link') {\n const attrs: Record<string, string> = { rel: autoLink.rel, href: autoLink.href };\n if (autoLink.type) attrs.type = autoLink.type;\n elements.push({ tag: 'link', attrs });\n } else {\n const attrs: Record<string, string> = { content: autoLink.content };\n if (autoLink.property) attrs.property = autoLink.property;\n if (autoLink.name) attrs.name = autoLink.name;\n elements.push({ tag: 'meta', attrs });\n }\n }\n }\n }\n return elements;\n}\n","/**\n * URL canonicalization — runs once at the request boundary.\n *\n * Every layer (proxy.ts, middleware.ts, access.ts, components) sees the same\n * canonical path. No re-decoding occurs at any later stage.\n *\n * See design/07-routing.md §\"URL Canonicalization & Security\"\n */\n\n/** Result of canonicalization — either a clean path or a rejection. */\nexport type CanonicalizeResult = { ok: true; pathname: string } | { ok: false; status: 400 };\n\n/**\n * Encoded separators that produce a 400 rejection.\n * %2f (/) and %5c (\\) cause path-confusion attacks.\n *\n * Shared between the runtime canonicalizer and the build-time route scanner\n * to ensure both enforce identical security rules. See design/13-security.md.\n */\nexport const ENCODED_SEPARATOR_RE = /%2f|%5c/i;\n\n/** Null byte — rejected. Shared with the route scanner. */\nexport const NULL_BYTE_RE = /%00/i;\n\n/**\n * Canonicalize a URL pathname.\n *\n * 1. Reject encoded separators (%2f, %5c) and null bytes (%00)\n * 2. Single percent-decode\n * 3. Collapse // → /\n * 4. Resolve .. segments (reject if escaping root)\n * 5. Strip trailing slash (except root \"/\")\n *\n * @param rawPathname - The raw pathname from the request URL (percent-encoded)\n * @param stripTrailingSlash - Whether to strip trailing slashes. Default: true.\n */\nexport function canonicalize(rawPathname: string, stripTrailingSlash = true): CanonicalizeResult {\n // Step 1: Reject dangerous encoded sequences BEFORE decoding.\n // This must happen on the raw input so %252f doesn't bypass after a single decode.\n if (ENCODED_SEPARATOR_RE.test(rawPathname)) {\n return { ok: false, status: 400 };\n }\n if (NULL_BYTE_RE.test(rawPathname)) {\n return { ok: false, status: 400 };\n }\n\n // Step 2: Single percent-decode.\n // Double-encoded input (%2561 → %61) stays as %61 — not decoded again.\n let decoded: string;\n try {\n decoded = decodeURIComponent(rawPathname);\n } catch {\n // Malformed percent-encoding → 400\n return { ok: false, status: 400 };\n }\n\n // Reject null bytes that appeared after decoding (from valid %00-like sequences\n // that weren't caught above — belt and suspenders).\n if (decoded.includes('\\0')) {\n return { ok: false, status: 400 };\n }\n\n // Backslash is NOT a path separator — keep as literal character.\n // But reject if it would create // after normalization (e.g., /\\evil.com).\n // We do NOT convert \\ to / — it stays as a literal.\n\n // Step 3: Collapse consecutive slashes.\n let pathname = decoded.replace(/\\/\\/+/g, '/');\n\n // Step 4: Resolve .. and . segments.\n const segments = pathname.split('/');\n const resolved: string[] = [];\n for (const seg of segments) {\n if (seg === '..') {\n if (resolved.length <= 1) {\n // Trying to escape root — 400\n return { ok: false, status: 400 };\n }\n resolved.pop();\n } else if (seg !== '.') {\n resolved.push(seg);\n }\n }\n\n pathname = resolved.join('/') || '/';\n\n // Step 5: Strip trailing slash (except root \"/\").\n if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith('/')) {\n pathname = pathname.slice(0, -1);\n }\n\n return { ok: true, pathname };\n}\n","/**\n * Segment key computation — the stable identity of a segment in the route tree.\n *\n * A segment key is the string that names a segment across the RSC/SSR/client\n * boundary: it appears in the X-Timber-State-Tree header, the X-Timber-Segments\n * header, SegmentOutlet props, the client segment cache, and (since TIM-1279)\n * interception scoping.\n *\n * This module lives in `routing/` rather than `server/` because the keys are a\n * property of the route tree itself — both the build-time scanner tree and the\n * runtime manifest tree produce identical keys for the same directory, which is\n * what lets a build-time value (an interception scope) be compared against a\n * request-time matched chain.\n *\n * See design/19-client-navigation.md §\"X-Timber-State-Tree Header\"\n */\n\n/**\n * Segment node shape expected by computeSegmentKeys.\n *\n * Structurally satisfied by both `SegmentNode` (build time) and\n * `ManifestSegmentNode` (request time) — keys depend only on the\n * URL path and the segment classification, never on file payloads.\n */\nexport interface SegmentKeyInput {\n urlPath: string;\n segmentName?: string;\n segmentType?: string;\n}\n\n/**\n * Compute state-tree keys for a segment chain.\n *\n * Non-group segments use their urlPath as-is. Route groups accumulate\n * ancestor group names to produce globally unique keys:\n * app/(a)/(shared)/dashboard → keys: ['/', '/(a)', '/(a)/(shared)', '/dashboard']\n *\n * **An intercepting segment switches the chain to name accumulation for good.**\n * A children-path interception (TIM-1280) puts an intercepting node and its\n * descendants into the *main* rendered chain, and their `urlPath`s are not\n * URLs: interception adds no URL depth, so `app/(browse)/(...)[artistSlug]/[year]`\n * carries `/` and `/[year]`. Keyed by urlPath the intercepting node would\n * collide with its own owner, and `[year]` with any top-level `[year]` route —\n * so a client that had one mounted would reuse the wrong cached layout. The\n * switch is sticky rather than per-node because the *descendants* are where\n * the false URLs are; the intercepting node alone is not the problem.\n *\n * This is the single source of truth for segment keys — used by the\n * element builder (skip decisions, SegmentOutlet props), segment info\n * (X-Timber-Segments header), the client cache/state tree, and\n * interception scoping (`routing/interception.ts`).\n */\nexport function computeSegmentKeys(segments: SegmentKeyInput[]): string[] {\n const keys: string[] = [];\n let prevKey = '';\n let insideIntercepting = false;\n\n for (const segment of segments) {\n if (segment.segmentType === 'intercepting') insideIntercepting = true;\n if (segment.segmentType === 'group' || insideIntercepting) {\n const base = prevKey === '/' ? '' : prevKey;\n const key = `${base}/${segment.segmentName}`;\n keys.push(key);\n prevKey = key;\n } else {\n keys.push(segment.urlPath);\n prevKey = segment.urlPath;\n }\n }\n\n return keys;\n}\n\n/**\n * Compute tree paths for a segment chain — the directory path of each segment\n * with slots elided, e.g. `app/(browse)/feed` → `/(browse)/feed`.\n *\n * This is `computeSegmentKeys` with the group branch applied to *every*\n * segment rather than only to groups. That one difference is the whole point:\n * `computeSegmentKeys` resets to `urlPath` at each URL-visible segment, so\n * `(browse)/feed` and `(landing)/feed` both key as `/feed`. Two directories\n * that render different layouts must not share an identity.\n *\n * Used for interception scoping (`routing/interception.ts`), where the\n * question is \"does this route pass through *that* directory?\" — not \"does it\n * render this URL?\". Not interchangeable with `computeSegmentKeys`: the state\n * tree is keyed by URL on purpose, because the client caches by URL.\n */\nexport function computeSegmentTreePaths(segments: SegmentKeyInput[]): string[] {\n const paths: string[] = [];\n let prev = '';\n\n for (const segment of segments) {\n const name = segment.segmentName ?? '';\n if (!name) {\n // The app root — no directory name of its own.\n paths.push('/');\n prev = '/';\n continue;\n }\n const base = prev === '/' ? '' : prev;\n const path = `${base}/${name}`;\n paths.push(path);\n prev = path;\n }\n\n return paths;\n}\n\n/**\n * The directory names a tree path is built from — `/` is none, `/feed/(a)` is\n * `['feed', '(a)']`.\n *\n * The inverse of the walk above, and it lives beside it so the format is\n * stated once: `findChainByTreePath` (`server/children-interception.ts`) walks\n * these names back down the tree to the directory the path addresses, and\n * `treePathDepth` counts them. Both are readings of the same string, and a\n * reader that disagreed with the producer about what separates two names\n * resolves an interception to the wrong node or to none.\n */\nexport function treePathNames(treePath: string): string[] {\n return treePath === '/' ? [] : treePath.slice(1).split('/');\n}\n\n/**\n * How many directories a tree path names — `/` is 0, `/feed` is 1,\n * `/feed/(a)` is 2.\n *\n * Two tree paths on a single rendered chain are strictly nested, so on that\n * chain depth orders them totally — which is what makes it usable as \"the\n * deeper of these two directories\" wherever a route passes through both\n * (`routing/interception.ts`).\n */\nexport function treePathDepth(treePath: string): number {\n return treePathNames(treePath).length;\n}\n\n/**\n * Compute a unique key for a parallel route slot.\n * Format: `{parentSegmentId}/@{slotName}`, e.g. `/@sidebar` or `/dashboard/@modal`.\n */\nexport function computeSlotKey(parentSegmentId: string, slotName: string): string {\n const name = slotName.startsWith('@') ? slotName : `@${slotName}`;\n const prefix = parentSegmentId === '/' ? '' : parentSegmentId;\n return `${prefix}/${name}`;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"segment-keys-BawYuNFO.js","names":[],"sources":["../../src/server/metadata-routes.ts","../../src/server/canonicalize.ts","../../src/routing/segment-keys.ts"],"sourcesContent":["/**\n * Metadata route classification for timber.js.\n *\n * Metadata routes are file-based endpoints that generate well-known URLs for\n * crawlers and browsers (sitemap.xml, robots.txt, OG images, etc.).\n *\n * These routes run through proxy.ts but NOT through middleware.ts or access.ts —\n * they are public endpoints by nature.\n *\n * See design/16-metadata.md §\"Metadata Routes\"\n */\n\nimport { randomUUID } from 'node:crypto';\nimport type { HeadElement } from './metadata.js';\nimport type { Metadata } from './types.js';\nimport type { ManifestSegmentNode } from './route-matcher.js';\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\n/** Classification of a metadata route file. */\nexport interface MetadataRouteInfo {\n /** The metadata route type. */\n type: MetadataRouteType;\n /** The content type to serve this route with. */\n contentType: string;\n /** Whether this route can appear in nested segments (not just app root). */\n nestable: boolean;\n}\n\nexport type MetadataRouteType =\n | 'sitemap'\n | 'robots'\n | 'manifest'\n | 'favicon'\n | 'icon'\n | 'opengraph-image'\n | 'apple-icon';\n\n// ─── Convention Table ────────────────────────────────────────────────────────\n\n/**\n * All recognized metadata route file conventions.\n *\n * Each entry maps a base file name (without extension) to its route info.\n * The extensions determine whether the file is static or dynamic.\n *\n * Static extensions: .xml, .txt, .json, .png, .jpg, .ico, .svg\n * Dynamic extensions: .ts, .tsx\n */\nexport const METADATA_ROUTE_CONVENTIONS: Record<\n string,\n {\n type: MetadataRouteType;\n contentType: string;\n nestable: boolean;\n staticExtensions: string[];\n dynamicExtensions: string[];\n /**\n * The URL path basename this file serves at (relative to segment).\n * For image routes, the full serve path includes an extension via\n * `resolveServePathForFile()`.\n */\n servePath: string;\n /**\n * When set, image routes append `.{serveExtension}` to the serve path.\n * Dynamic handlers (`.ts`/`.tsx`) use this as the default. Static files\n * use their own extension instead. Non-image routes leave this undefined.\n */\n serveExtension?: string;\n }\n> = {\n 'sitemap': {\n type: 'sitemap',\n contentType: 'application/xml',\n nestable: true,\n staticExtensions: ['xml'],\n dynamicExtensions: ['ts'],\n servePath: 'sitemap.xml',\n },\n 'robots': {\n type: 'robots',\n contentType: 'text/plain',\n nestable: false,\n staticExtensions: ['txt'],\n dynamicExtensions: ['ts'],\n servePath: 'robots.txt',\n },\n 'manifest': {\n type: 'manifest',\n contentType: 'application/manifest+json',\n nestable: false,\n staticExtensions: ['json'],\n dynamicExtensions: ['ts'],\n servePath: 'manifest.webmanifest',\n },\n 'favicon': {\n type: 'favicon',\n contentType: 'image/x-icon',\n nestable: false,\n staticExtensions: ['ico'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'favicon.ico',\n },\n 'icon': {\n type: 'icon',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png', 'jpg', 'svg'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'icon',\n serveExtension: 'png',\n },\n 'opengraph-image': {\n type: 'opengraph-image',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png', 'jpg'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'opengraph-image',\n serveExtension: 'png',\n },\n\n 'apple-icon': {\n type: 'apple-icon',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'apple-icon',\n serveExtension: 'png',\n },\n};\n\n// ─── MIME Type Resolution ─────────────────────────────────────────────────────\n\n/**\n * Map of file extensions to MIME types for static metadata route files.\n * Used to resolve the generic `image/*` content type for static image files.\n */\nconst EXTENSION_MIME_TYPES: Record<string, string> = {\n xml: 'application/xml',\n txt: 'text/plain',\n json: 'application/json',\n ico: 'image/x-icon',\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n svg: 'image/svg+xml',\n webp: 'image/webp',\n};\n\n/**\n * Resolve the concrete MIME type for a static metadata route file.\n *\n * For generic content types like `image/*`, this resolves to the actual\n * MIME type based on the file extension (e.g. `image/png` for `.png`).\n *\n * @param conventionContentType - The content type from the convention table (may be generic like `image/*`)\n * @param extension - The file extension without leading dot (e.g. \"png\", \"xml\")\n * @returns The resolved MIME type\n */\nexport function resolveStaticContentType(conventionContentType: string, extension: string): string {\n if (conventionContentType.includes('*')) {\n return EXTENSION_MIME_TYPES[extension] ?? 'application/octet-stream';\n }\n return conventionContentType;\n}\n\n/**\n * Check if a file extension represents a static (non-code) metadata route file.\n *\n * @param baseName - The base file name without extension (e.g. \"sitemap\", \"icon\")\n * @param extension - The file extension without leading dot (e.g. \"xml\", \"png\", \"ts\")\n * @returns true if this is a static file, false if dynamic or unrecognized\n */\nexport function isStaticMetadataExtension(baseName: string, extension: string): boolean {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return false;\n return convention.staticExtensions.includes(extension);\n}\n\n/**\n * Check if a file extension represents a dynamic (code) metadata route file.\n *\n * @param baseName - The base file name without extension (e.g. \"sitemap\", \"icon\")\n * @param extension - The file extension without leading dot (e.g. \"ts\", \"tsx\")\n * @returns true if this is a dynamic file, false if static or unrecognized\n */\nexport function isDynamicMetadataExtension(baseName: string, extension: string): boolean {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return false;\n return convention.dynamicExtensions.includes(extension);\n}\n\n// ─── Classification ──────────────────────────────────────────────────────────\n\n/**\n * Classify a file name as a metadata route, or return null if it's not one.\n *\n * @param fileName - The full file name including extension (e.g. \"sitemap.xml\", \"icon.tsx\")\n * @returns Classification info, or null if not a metadata route\n */\nexport function classifyMetadataRoute(fileName: string): MetadataRouteInfo | null {\n const dotIndex = fileName.lastIndexOf('.');\n if (dotIndex === -1) return null;\n\n const baseName = fileName.slice(0, dotIndex);\n const ext = fileName.slice(dotIndex + 1);\n\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return null;\n\n const isStatic = convention.staticExtensions.includes(ext);\n const isDynamic = convention.dynamicExtensions.includes(ext);\n\n if (!isStatic && !isDynamic) return null;\n\n return {\n type: convention.type,\n contentType: convention.contentType,\n nestable: convention.nestable,\n };\n}\n\n/**\n * Resolve the serve path for a metadata route file.\n *\n * For image routes (icon, opengraph-image, apple-icon), the serve path includes\n * a file extension so CDNs cache correctly:\n * - Dynamic handlers (.ts/.tsx) use the convention's `serveExtension` (default: .png)\n * - Static files use their own extension (e.g., icon.svg → icon.svg)\n *\n * Non-image routes return the convention's `servePath` as-is (already includes\n * extension: sitemap.xml, robots.txt, etc.).\n */\nexport function resolveServePathForFile(baseName: string, filePath: string): string {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return baseName;\n\n if (!convention.serveExtension) return convention.servePath;\n\n const ext = filePath.slice(filePath.lastIndexOf('.') + 1);\n if (convention.staticExtensions.includes(ext)) {\n return `${convention.servePath}.${ext}`;\n }\n return `${convention.servePath}.${convention.serveExtension}`;\n}\n\n/**\n * Get the default serve path for a metadata route type (using default extension\n * for image routes). Used for auto-link generation when only the type is known.\n */\nexport function getMetadataRouteServePath(type: MetadataRouteType): string {\n for (const convention of Object.values(METADATA_ROUTE_CONVENTIONS)) {\n if (convention.type === type) {\n if (convention.serveExtension) {\n return `${convention.servePath}.${convention.serveExtension}`;\n }\n return convention.servePath;\n }\n }\n throw new Error(`[timber] Unknown metadata route type: ${type}`);\n}\n\n/**\n * All possible serve path segments for metadata routes (includes extension\n * variants for image routes).\n */\nconst METADATA_SERVE_PATHS = new Set<string>();\nfor (const convention of Object.values(METADATA_ROUTE_CONVENTIONS)) {\n if (convention.serveExtension) {\n for (const ext of [...convention.staticExtensions, convention.serveExtension]) {\n METADATA_SERVE_PATHS.add(`${convention.servePath}.${ext}`);\n }\n } else {\n METADATA_SERVE_PATHS.add(convention.servePath);\n }\n}\n\nexport function isMetadataRouteServePath(pathname: string): boolean {\n let lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1);\n const qIdx = lastSegment.indexOf('?');\n let query = '';\n if (qIdx !== -1) {\n query = lastSegment.slice(qIdx + 1);\n lastSegment = lastSegment.slice(0, qIdx);\n }\n if (!METADATA_SERVE_PATHS.has(lastSegment)) return false;\n // Vite module requests (e.g., /src/icon.svg?import) use special query params.\n // These are source assets, not metadata routes.\n if (/(?:^|&)(?:import|url|raw|worker|inline)(?:&|$)/.test(query)) return false;\n return true;\n}\n\n/** A <link> auto-link tag. */\nexport interface AutoLinkLink {\n tag: 'link';\n rel: string;\n href: string;\n type?: string;\n}\n\n/** A <meta> auto-link tag. */\nexport interface AutoLinkMeta {\n tag: 'meta';\n property?: string;\n name?: string;\n content: string;\n}\n\nexport type AutoLinkTag = AutoLinkLink | AutoLinkMeta;\n\n/**\n * Get the auto-link tags to inject into <head> for metadata route files\n * discovered in a segment.\n *\n * Returns link tags for icon/apple-icon/manifest, and meta tags for\n * opengraph-image (emits both og:image and twitter:image). Returns null\n * for types that don't auto-link (favicon, sitemap, robots).\n *\n * @param type - The metadata route type\n * @param href - The resolved URL path to the metadata route\n * @returns Tag descriptor(s) for the <head>, or null if no auto-link\n */\nexport function getMetadataRouteAutoLink(type: MetadataRouteType, href: string): AutoLinkTag[] {\n switch (type) {\n case 'icon':\n return [{ tag: 'link', rel: 'icon', href }];\n case 'apple-icon':\n return [{ tag: 'link', rel: 'apple-touch-icon', href }];\n case 'manifest':\n return [{ tag: 'link', rel: 'manifest', href }];\n case 'opengraph-image':\n return [\n { tag: 'meta', property: 'og:image', content: href },\n { tag: 'meta', name: 'twitter:image', content: href },\n ];\n default:\n return [];\n }\n}\n\n// ─── Auto-Linking ──────────────────────────────────────────────────────────\n\n// In dev mode, use a per-startup nonce for metadata route cache busting\n// instead of per-file content hashes (avoids rehashing on every request).\nlet _devNonce: string | undefined;\nfunction getDevNonce(): string {\n _devNonce ??= randomUUID().slice(0, 8);\n return _devNonce;\n}\n\n/**\n * Collect auto-linked head elements from metadata route files in the segment chain.\n *\n * Walks each segment's metadataRoutes, resolves serve paths and URLs, and\n * emits HeadElement descriptors for <link> and <meta> tags that React Float\n * hoists into <head>.\n *\n * See design/16-metadata.md §\"Auto-Linking\"\n */\nexport function collectMetadataRouteHeadElements(\n segments: ManifestSegmentNode[],\n firstDeniedIndex: number,\n resolvedMetadata: Metadata,\n requestUrl: URL,\n metadataRouteHashes?: Record<string, string>\n): HeadElement[] {\n const elements: HeadElement[] = [];\n const hasUserOgImage = Boolean(resolvedMetadata.openGraph?.images);\n const hasUserTwitterImage = Boolean(resolvedMetadata.twitter?.images);\n const requestPathname = requestUrl.pathname;\n // In dev mode, use the request origin so OG URLs resolve to localhost.\n // In production, use metadataBase (the canonical domain).\n const ogBase =\n process.env.NODE_ENV !== 'production'\n ? new URL(requestUrl.origin)\n : resolvedMetadata.metadataBase;\n\n for (let si = 0; si < segments.length; si++) {\n const segment = segments[si];\n if (!segment.metadataRoutes) continue;\n if (si >= firstDeniedIndex) continue;\n for (const baseName of Object.keys(segment.metadataRoutes)) {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) continue;\n if (!convention.nestable && segment.urlPath !== '/') continue;\n if (convention.type === 'opengraph-image' && hasUserOgImage) continue;\n const resolvedPrefix = convention.nestable\n ? requestPathname === '/'\n ? ''\n : requestPathname\n : '';\n const metaFile = segment.metadataRoutes[baseName];\n const fileServePath = metaFile?.filePath\n ? resolveServePathForFile(baseName, metaFile.filePath)\n : convention.serveExtension\n ? `${convention.servePath}.${convention.serveExtension}`\n : convention.servePath;\n let href = `${resolvedPrefix}/${fileServePath}`;\n if (convention.type === 'opengraph-image') {\n const fileHash = metaFile?.filePath ? metadataRouteHashes?.[metaFile.filePath] : undefined;\n const cacheBust = fileHash ?? getDevNonce();\n href = `${href}?${cacheBust}`;\n }\n if (ogBase && convention.type === 'opengraph-image') {\n href = new URL(href, ogBase).toString();\n }\n for (const autoLink of getMetadataRouteAutoLink(convention.type, href)) {\n if (\n hasUserTwitterImage &&\n autoLink.tag === 'meta' &&\n 'name' in autoLink &&\n autoLink.name === 'twitter:image'\n )\n continue;\n if (autoLink.tag === 'link') {\n const attrs: Record<string, string> = { rel: autoLink.rel, href: autoLink.href };\n if (autoLink.type) attrs.type = autoLink.type;\n elements.push({ tag: 'link', attrs });\n } else {\n const attrs: Record<string, string> = { content: autoLink.content };\n if (autoLink.property) attrs.property = autoLink.property;\n if (autoLink.name) attrs.name = autoLink.name;\n elements.push({ tag: 'meta', attrs });\n }\n }\n }\n }\n return elements;\n}\n","/**\n * URL canonicalization — runs once at the request boundary.\n *\n * Every layer (proxy.ts, middleware.ts, access.ts, components) sees the same\n * canonical path. No re-decoding occurs at any later stage.\n *\n * See design/07-routing.md §\"URL Canonicalization & Security\"\n */\n\n/** Result of canonicalization — either a clean path or a rejection. */\nexport type CanonicalizeResult = { ok: true; pathname: string } | { ok: false; status: 400 };\n\n/**\n * Encoded separators that produce a 400 rejection.\n * %2f (/) and %5c (\\) cause path-confusion attacks.\n *\n * Shared between the runtime canonicalizer and the build-time route scanner\n * to ensure both enforce identical security rules. See design/13-security.md.\n */\nexport const ENCODED_SEPARATOR_RE = /%2f|%5c/i;\n\n/** Null byte — rejected. Shared with the route scanner. */\nexport const NULL_BYTE_RE = /%00/i;\n\n/**\n * Canonicalize a URL pathname.\n *\n * 1. Reject encoded separators (%2f, %5c) and null bytes (%00)\n * 2. Single percent-decode\n * 3. Collapse // → /\n * 4. Resolve .. segments (reject if escaping root)\n * 5. Strip trailing slash (except root \"/\")\n *\n * @param rawPathname - The raw pathname from the request URL (percent-encoded)\n * @param stripTrailingSlash - Whether to strip trailing slashes. Default: true.\n */\nexport function canonicalize(rawPathname: string, stripTrailingSlash = true): CanonicalizeResult {\n // Step 1: Reject dangerous encoded sequences BEFORE decoding.\n // This must happen on the raw input so %252f doesn't bypass after a single decode.\n if (ENCODED_SEPARATOR_RE.test(rawPathname)) {\n return { ok: false, status: 400 };\n }\n if (NULL_BYTE_RE.test(rawPathname)) {\n return { ok: false, status: 400 };\n }\n\n // Step 2: Single percent-decode.\n // Double-encoded input (%2561 → %61) stays as %61 — not decoded again.\n let decoded: string;\n try {\n decoded = decodeURIComponent(rawPathname);\n } catch {\n // Malformed percent-encoding → 400\n return { ok: false, status: 400 };\n }\n\n // Reject null bytes that appeared after decoding (from valid %00-like sequences\n // that weren't caught above — belt and suspenders).\n if (decoded.includes('\\0')) {\n return { ok: false, status: 400 };\n }\n\n // Backslash is NOT a path separator — keep as literal character.\n // But reject if it would create // after normalization (e.g., /\\evil.com).\n // We do NOT convert \\ to / — it stays as a literal.\n\n // Step 3: Collapse consecutive slashes.\n let pathname = decoded.replace(/\\/\\/+/g, '/');\n\n // Step 4: Resolve .. and . segments.\n const segments = pathname.split('/');\n const resolved: string[] = [];\n for (const seg of segments) {\n if (seg === '..') {\n if (resolved.length <= 1) {\n // Trying to escape root — 400\n return { ok: false, status: 400 };\n }\n resolved.pop();\n } else if (seg !== '.') {\n resolved.push(seg);\n }\n }\n\n pathname = resolved.join('/') || '/';\n\n // Step 5: Strip trailing slash (except root \"/\").\n if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith('/')) {\n pathname = pathname.slice(0, -1);\n }\n\n return { ok: true, pathname };\n}\n","/**\n * Segment key computation — the stable identity of a segment in the route tree.\n *\n * A segment key is the string that names a segment across the RSC/SSR/client\n * boundary: it appears in the X-Timber-State-Tree header, the X-Timber-Segments\n * header, SegmentOutlet props, the client segment cache, and (since TIM-1279)\n * interception scoping.\n *\n * This module lives in `routing/` rather than `server/` because the keys are a\n * property of the route tree itself — both the build-time scanner tree and the\n * runtime manifest tree produce identical keys for the same directory, which is\n * what lets a build-time value (an interception scope) be compared against a\n * request-time matched chain.\n *\n * See design/19-client-navigation.md §\"X-Timber-State-Tree Header\"\n */\n\n/**\n * Segment node shape expected by computeSegmentKeys.\n *\n * Structurally satisfied by both `SegmentNode` (build time) and\n * `ManifestSegmentNode` (request time) — keys depend only on the\n * URL path and the segment classification, never on file payloads.\n */\nexport interface SegmentKeyInput {\n urlPath: string;\n segmentName?: string;\n segmentType?: string;\n}\n\n/**\n * Compute state-tree keys for a segment chain.\n *\n * Non-group segments use their urlPath as-is. Route groups accumulate\n * ancestor group names to produce globally unique keys:\n * app/(a)/(shared)/dashboard → keys: ['/', '/(a)', '/(a)/(shared)', '/dashboard']\n *\n * **An intercepting segment switches the chain to name accumulation for good.**\n * A children-path interception (TIM-1280) puts an intercepting node and its\n * descendants into the *main* rendered chain, and their `urlPath`s are not\n * URLs: interception adds no URL depth, so `app/(browse)/(...)[artistSlug]/[year]`\n * carries `/` and `/[year]`. Keyed by urlPath the intercepting node would\n * collide with its own owner, and `[year]` with any top-level `[year]` route —\n * so a client that had one mounted would reuse the wrong cached layout. The\n * switch is sticky rather than per-node because the *descendants* are where\n * the false URLs are; the intercepting node alone is not the problem.\n *\n * This is the single source of truth for segment keys — used by the\n * element builder (skip decisions, SegmentOutlet props), segment info\n * (X-Timber-Segments header), the client cache/state tree, and\n * interception scoping (`routing/interception.ts`).\n */\nexport function computeSegmentKeys(segments: SegmentKeyInput[]): string[] {\n const keys: string[] = [];\n let prevKey = '';\n let insideIntercepting = false;\n\n for (const segment of segments) {\n if (segment.segmentType === 'intercepting') insideIntercepting = true;\n if (segment.segmentType === 'group' || insideIntercepting) {\n const base = prevKey === '/' ? '' : prevKey;\n const key = `${base}/${segment.segmentName}`;\n keys.push(key);\n prevKey = key;\n } else {\n keys.push(segment.urlPath);\n prevKey = segment.urlPath;\n }\n }\n\n return keys;\n}\n\n/**\n * Compute tree paths for a segment chain — the directory path of each segment\n * with slots elided, e.g. `app/(browse)/feed` → `/(browse)/feed`.\n *\n * This is `computeSegmentKeys` with the group branch applied to *every*\n * segment rather than only to groups. That one difference is the whole point:\n * `computeSegmentKeys` resets to `urlPath` at each URL-visible segment, so\n * `(browse)/feed` and `(landing)/feed` both key as `/feed`. Two directories\n * that render different layouts must not share an identity.\n *\n * Used for interception scoping (`routing/interception.ts`), where the\n * question is \"does this route pass through *that* directory?\" — not \"does it\n * render this URL?\". Not interchangeable with `computeSegmentKeys`: the state\n * tree is keyed by URL on purpose, because the client caches by URL.\n */\nexport function computeSegmentTreePaths(segments: SegmentKeyInput[]): string[] {\n const paths: string[] = [];\n let prev = '';\n\n for (const segment of segments) {\n const name = segment.segmentName ?? '';\n if (!name) {\n // The app root — no directory name of its own.\n paths.push('/');\n prev = '/';\n continue;\n }\n const base = prev === '/' ? '' : prev;\n const path = `${base}/${name}`;\n paths.push(path);\n prev = path;\n }\n\n return paths;\n}\n\n/**\n * The directory names a tree path is built from — `/` is none, `/feed/(a)` is\n * `['feed', '(a)']`.\n *\n * The inverse of the walk above, and it lives beside it so the format is\n * stated once: `findChainByTreePath` (`server/children-interception.ts`) walks\n * these names back down the tree to the directory the path addresses, and\n * `treePathDepth` counts them. Both are readings of the same string, and a\n * reader that disagreed with the producer about what separates two names\n * resolves an interception to the wrong node or to none.\n */\nexport function treePathNames(treePath: string): string[] {\n return treePath === '/' ? [] : treePath.slice(1).split('/');\n}\n\n/**\n * How many directories a tree path names — `/` is 0, `/feed` is 1,\n * `/feed/(a)` is 2.\n *\n * Two tree paths on a single rendered chain are strictly nested, so on that\n * chain depth orders them totally — which is what makes it usable as \"the\n * deeper of these two directories\" wherever a route passes through both\n * (`routing/interception.ts`).\n */\nexport function treePathDepth(treePath: string): number {\n return treePathNames(treePath).length;\n}\n\n/**\n * Compute a unique key for a parallel route slot.\n * Format: `{parentSegmentId}/@{slotName}`, e.g. `/@sidebar` or `/dashboard/@modal`.\n */\nexport function computeSlotKey(parentSegmentId: string, slotName: string): string {\n const name = slotName.startsWith('@') ? slotName : `@${slotName}`;\n const prefix = parentSegmentId === '/' ? '' : parentSegmentId;\n return `${prefix}/${name}`;\n}\n\n/**\n * Compute a content key for a parallel route slot.\n *\n * The content key encodes everything that determines the slot's rendered\n * output for a given navigation: the slot key (which includes the owning\n * segment's identity and the slot name), the URL parts consumed by the\n * owning segment (which determine parent params the slot page might\n * read), the matched page file (or a sentinel for `default.tsx`), and\n * the slot's own extracted params.\n *\n * The slot key prevents cross-group collisions: two route groups at the\n * same URL level each owning a `@sidebar` slot produce different keys\n * even when both fall back to `default.tsx`.\n *\n * The key is opaque to the client — it stores whatever the server sent\n * and advertises it back. The server computes the destination key and\n * checks membership. This eliminates the need for the server to\n * reconstruct the client's state from a departing URL.\n *\n * See design/07-routing.md §\"Segment Tree Diffing on Navigation\"\n */\nexport function computeSlotContentKey(\n slotKey: string,\n ownerParts: string[],\n entryFile: string | null,\n slotParams: Record<string, string | string[]>\n): string {\n const owner = ownerParts.join('/');\n const entry = entryFile ?? '\\x01';\n const paramKeys = Object.keys(slotParams).sort();\n const paramParts = paramKeys.map((k) => {\n const v = slotParams[k];\n return Array.isArray(v) ? `${k}=${v.join('\\x02')}` : `${k}=${v}`;\n });\n return [slotKey, owner, entry, ...paramParts].join('\\0');\n}\n"],"mappings":"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"}
|
package/dist/client/internal.js
CHANGED
|
@@ -142,10 +142,18 @@ function collectTreePaths(node, out) {
|
|
|
142
142
|
if (node.treePath) out.push(node.treePath);
|
|
143
143
|
for (const child of node.children.values()) collectTreePaths(child, out);
|
|
144
144
|
}
|
|
145
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* Recursively collect content keys from cacheable slots.
|
|
147
|
+
*
|
|
148
|
+
* The server advertises a content key per slot on every render (TIM-1370),
|
|
149
|
+
* encoding the owner's URL parts, the slot name, the matched entry file,
|
|
150
|
+
* and the slot's params. The client stores this key and sends it back on
|
|
151
|
+
* the next navigation so the server can decide skips by key membership —
|
|
152
|
+
* no departing URL reconstruction needed.
|
|
153
|
+
*/
|
|
146
154
|
function collectSyncSlots(node, out) {
|
|
147
155
|
if (node.slots) {
|
|
148
|
-
for (const slot of node.slots.values()) if (!slot.isRequestDependent && !slot.denied) out.push(slot.
|
|
156
|
+
for (const slot of node.slots.values()) if (!slot.isRequestDependent && !slot.denied && slot.contentKey) out.push(slot.contentKey);
|
|
149
157
|
}
|
|
150
158
|
for (const child of node.children.values()) collectSyncSlots(child, out);
|
|
151
159
|
}
|
|
@@ -193,7 +201,8 @@ function buildSegmentTree(segments) {
|
|
|
193
201
|
payload: null,
|
|
194
202
|
isRequestDependent: slotInfo.isRequestDependent,
|
|
195
203
|
children: /* @__PURE__ */ new Map(),
|
|
196
|
-
denied: slotInfo.denied
|
|
204
|
+
denied: slotInfo.denied,
|
|
205
|
+
contentKey: slotInfo.contentKey
|
|
197
206
|
};
|
|
198
207
|
if (!parentNode.slots) parentNode.slots = /* @__PURE__ */ new Map();
|
|
199
208
|
parentNode.slots.set(slotId, slotNode);
|