@wular/pnext 0.0.1
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/README.md +153 -0
- package/bin/pnext +67 -0
- package/config/lint/base.js +48 -0
- package/config/ts/base.json +26 -0
- package/config/ts/react.json +13 -0
- package/package.json +70 -0
- package/reference/compat.md +63 -0
- package/reference/config.md +120 -0
- package/reference/css.md +58 -0
- package/reference/dev.md +69 -0
- package/reference/env.md +40 -0
- package/reference/metadata.md +86 -0
- package/reference/navigation.md +149 -0
- package/reference/overview.md +35 -0
- package/reference/performance.md +97 -0
- package/reference/rendering.md +127 -0
- package/reference/routing.md +167 -0
- package/reference/typegen.md +64 -0
- package/src/api/cache.ts +80 -0
- package/src/api/client-cache.ts +9 -0
- package/src/api/client-navigation.ts +279 -0
- package/src/api/dynamic.tsx +102 -0
- package/src/api/link.tsx +119 -0
- package/src/api/navigation.ts +198 -0
- package/src/api/router/events.ts +53 -0
- package/src/api/router/history.ts +70 -0
- package/src/api/router/hub.ts +194 -0
- package/src/api/router/policies.ts +107 -0
- package/src/api/router/runtime.ts +5238 -0
- package/src/api/router/types.ts +299 -0
- package/src/api/router.ts +167 -0
- package/src/api/server.ts +323 -0
- package/src/api/suspense.ts +16 -0
- package/src/cache/context.ts +61 -0
- package/src/cli/adapters/vercel-warm.ts +375 -0
- package/src/cli/adapters/vercel.ts +1310 -0
- package/src/cli/analyze-print.ts +181 -0
- package/src/cli/analyze.ts +328 -0
- package/src/cli/boot-trace.ts +29 -0
- package/src/cli/build.ts +3114 -0
- package/src/cli/dev.ts +276 -0
- package/src/cli/index.ts +196 -0
- package/src/cli/named-bin.ts +119 -0
- package/src/cli/serve-ui.ts +160 -0
- package/src/cli/start.ts +1425 -0
- package/src/client/build.ts +2136 -0
- package/src/client/chunk-fold.ts +526 -0
- package/src/client/entry.ts +1525 -0
- package/src/client/paths.ts +22 -0
- package/src/client/prebuilt.ts +621 -0
- package/src/client/profile.ts +75 -0
- package/src/client/react-compiler.ts +94 -0
- package/src/client/reference-stub.ts +145 -0
- package/src/client/reference.ts +47 -0
- package/src/compat/actions/action-client.ts +531 -0
- package/src/compat/actions/action-dispatch.ts +676 -0
- package/src/compat/actions/action-router.ts +40 -0
- package/src/compat/actions/action-shared.ts +95 -0
- package/src/compat/actions/client-plugin.ts +135 -0
- package/src/compat/actions/client-stub.ts +65 -0
- package/src/compat/actions/config.ts +164 -0
- package/src/compat/actions/detect.ts +208 -0
- package/src/compat/actions/discovery.ts +244 -0
- package/src/compat/actions/early-submit.ts +40 -0
- package/src/compat/actions/endpoint.ts +602 -0
- package/src/compat/actions/flight.ts +52 -0
- package/src/compat/actions/form-state.ts +73 -0
- package/src/compat/actions/hoist.ts +485 -0
- package/src/compat/actions/ids.ts +39 -0
- package/src/compat/actions/index.ts +41 -0
- package/src/compat/actions/instances.ts +144 -0
- package/src/compat/actions/origin.ts +109 -0
- package/src/compat/actions/protocol.ts +125 -0
- package/src/compat/actions/registry.ts +74 -0
- package/src/compat/actions/rewrite.ts +282 -0
- package/src/compat/actions/serve.ts +414 -0
- package/src/compat/actions/server-tag.ts +21 -0
- package/src/compat/actions/unrecognized-error.ts +30 -0
- package/src/compat/adapter/build-complete.ts +257 -0
- package/src/compat/bundler/bun-externals.ts +53 -0
- package/src/compat/bundler/cjs-exports.ts +542 -0
- package/src/compat/bundler/config.ts +363 -0
- package/src/compat/bundler/externals.ts +34 -0
- package/src/compat/bundler/import-meta-url.ts +60 -0
- package/src/compat/bundler/modularize-imports.ts +119 -0
- package/src/compat/bundler/new-url-asset.ts +87 -0
- package/src/compat/bundler/optimize-package-imports.ts +273 -0
- package/src/compat/bundler/polyfill.ts +88 -0
- package/src/compat/bundler/react-compiler.ts +61 -0
- package/src/compat/bundler/react-profiler.tsx +25 -0
- package/src/compat/bundler/relay-transform.ts +116 -0
- package/src/compat/bundler/require-context.ts +281 -0
- package/src/compat/bundler/resolve-extensions.ts +75 -0
- package/src/compat/bundler/source-cache.ts +61 -0
- package/src/compat/bundler/static-imports.ts +25 -0
- package/src/compat/bundler/symlink-imports.ts +119 -0
- package/src/compat/bundler/tsconfig-paths.ts +50 -0
- package/src/compat/bundler/wasm.ts +153 -0
- package/src/compat/bundler/webpack-loaders.ts +685 -0
- package/src/compat/bundler/worker.ts +278 -0
- package/src/compat/cache/build-flags.ts +81 -0
- package/src/compat/cache/build-prerender-errors.ts +163 -0
- package/src/compat/cache/custom-handler.ts +159 -0
- package/src/compat/cache/fetch-patch.ts +745 -0
- package/src/compat/cache/handler.ts +100 -0
- package/src/compat/cache/modern-handler.ts +275 -0
- package/src/compat/cache/resume-data-cache.ts +143 -0
- package/src/compat/cache/revalidate.ts +759 -0
- package/src/compat/cache/runtime-error.ts +124 -0
- package/src/compat/cache/use-cache-transform.ts +961 -0
- package/src/compat/cache/use-cache.ts +1695 -0
- package/src/compat/cache-control.ts +269 -0
- package/src/compat/client/base-path.ts +64 -0
- package/src/compat/client/css-order.ts +36 -0
- package/src/compat/client/errors/control-flow.ts +92 -0
- package/src/compat/client/errors/error-boundary.ts +222 -0
- package/src/compat/client/errors/global-error.ts +238 -0
- package/src/compat/client/errors/install.ts +217 -0
- package/src/compat/client/errors/lazy.ts +53 -0
- package/src/compat/client/errors/primitive-throw.ts +126 -0
- package/src/compat/client/errors/soft-refresh.ts +14 -0
- package/src/compat/client/link-status.ts +86 -0
- package/src/compat/client/nav-compat-runtime.ts +57 -0
- package/src/compat/client/nav-compat.ts +42 -0
- package/src/compat/client/navigation-scroll.ts +154 -0
- package/src/compat/client/optimistic-routing.ts +206 -0
- package/src/compat/client/prefetch-cache.ts +111 -0
- package/src/compat/client/route-announcer.ts +72 -0
- package/src/compat/client/segment-cache-policy.ts +159 -0
- package/src/compat/client/segment-cache.ts +1077 -0
- package/src/compat/client/segment-prefetch.ts +375 -0
- package/src/compat/client/trailing-slash.ts +24 -0
- package/src/compat/css/chunking.ts +254 -0
- package/src/compat/css/inline-css.ts +73 -0
- package/src/compat/css/lightningcss.ts +90 -0
- package/src/compat/css/modules.ts +373 -0
- package/src/compat/css/nonce.ts +30 -0
- package/src/compat/css/sass-plugin.ts +65 -0
- package/src/compat/css/sass.ts +392 -0
- package/src/compat/css/styled-jsx-runtime.ts +80 -0
- package/src/compat/css/styled-jsx.ts +49 -0
- package/src/compat/edge-runtime.ts +71 -0
- package/src/compat/export/client.ts +112 -0
- package/src/compat/export/index.ts +272 -0
- package/src/compat/export/standalone.ts +207 -0
- package/src/compat/image-optimizer/cache.ts +119 -0
- package/src/compat/image-optimizer/detect.ts +143 -0
- package/src/compat/image-optimizer/index.ts +601 -0
- package/src/compat/image-optimizer/source.ts +243 -0
- package/src/compat/index.ts +458 -0
- package/src/compat/lifecycle/after-scope.ts +86 -0
- package/src/compat/lifecycle/after.ts +173 -0
- package/src/compat/lifecycle/error-funnel.ts +306 -0
- package/src/compat/lifecycle/error-serialize.ts +87 -0
- package/src/compat/lifecycle/error-ui.ts +167 -0
- package/src/compat/lifecycle/instrumentation-client.ts +138 -0
- package/src/compat/lifecycle/instrumentation.ts +277 -0
- package/src/compat/lifecycle/node-console.ts +19 -0
- package/src/compat/lifecycle/testmode.ts +263 -0
- package/src/compat/mdx/compile.ts +219 -0
- package/src/compat/mdx/next-mdx-stub.ts +46 -0
- package/src/compat/mdx/plugin.ts +37 -0
- package/src/compat/metadata-route-artifacts.ts +458 -0
- package/src/compat/metadata.ts +295 -0
- package/src/compat/middleware/manifest.ts +210 -0
- package/src/compat/misc/action-return.ts +173 -0
- package/src/compat/next/cache.ts +211 -0
- package/src/compat/next/canonical-url.ts +35 -0
- package/src/compat/next/client-cache.ts +57 -0
- package/src/compat/next/client-navigation.ts +313 -0
- package/src/compat/next/client-only.ts +3 -0
- package/src/compat/next/client-script.tsx +215 -0
- package/src/compat/next/client-server.ts +39 -0
- package/src/compat/next/config-loader.ts +569 -0
- package/src/compat/next/config.ts +29 -0
- package/src/compat/next/constants.cjs +6 -0
- package/src/compat/next/constants.ts +6 -0
- package/src/compat/next/custom-server.ts +236 -0
- package/src/compat/next/dist/client/components/app-router-headers.ts +32 -0
- package/src/compat/next/dist/server/app-render/work-unit-async-storage.external.cjs +38 -0
- package/src/compat/next/dist/server/web/spec-extension/revalidate.ts +1 -0
- package/src/compat/next/dist/server/web/spec-extension/unstable-cache.ts +1 -0
- package/src/compat/next/dist/server/web/spec-extension/unstable-no-store.ts +1 -0
- package/src/compat/next/dynamic.tsx +46 -0
- package/src/compat/next/error.tsx +148 -0
- package/src/compat/next/font/cache.ts +171 -0
- package/src/compat/next/font/google.ts +2 -0
- package/src/compat/next/font/index.ts +8 -0
- package/src/compat/next/font/local.ts +5 -0
- package/src/compat/next/font/runtime-client.ts +71 -0
- package/src/compat/next/font/runtime.ts +974 -0
- package/src/compat/next/font/shared.ts +281 -0
- package/src/compat/next/form.tsx +156 -0
- package/src/compat/next/head.tsx +10 -0
- package/src/compat/next/headers.ts +247 -0
- package/src/compat/next/image/config.ts +196 -0
- package/src/compat/next/image/optimizer.ts +96 -0
- package/src/compat/next/image/patterns.ts +103 -0
- package/src/compat/next/image/shared.ts +141 -0
- package/src/compat/next/image/static-metadata.ts +283 -0
- package/src/compat/next/image/validate.ts +269 -0
- package/src/compat/next/image-client.tsx +215 -0
- package/src/compat/next/image-props.ts +575 -0
- package/src/compat/next/image-usage.ts +102 -0
- package/src/compat/next/image.tsx +56 -0
- package/src/compat/next/index.ts +1 -0
- package/src/compat/next/legacy-image.tsx +97 -0
- package/src/compat/next/link-usage.ts +29 -0
- package/src/compat/next/link-validation-transform.ts +200 -0
- package/src/compat/next/link.tsx +466 -0
- package/src/compat/next/navigation.cjs +21 -0
- package/src/compat/next/navigation.ts +188 -0
- package/src/compat/next/offline.ts +51 -0
- package/src/compat/next/og.ts +324 -0
- package/src/compat/next/optimistic-route-state.ts +188 -0
- package/src/compat/next/preferred-region.ts +39 -0
- package/src/compat/next/redirects.ts +131 -0
- package/src/compat/next/resource-hints.ts +136 -0
- package/src/compat/next/rewrites.ts +350 -0
- package/src/compat/next/root-params.ts +142 -0
- package/src/compat/next/router.cjs +49 -0
- package/src/compat/next/router.ts +143 -0
- package/src/compat/next/script.tsx +355 -0
- package/src/compat/next/server-only.ts +3 -0
- package/src/compat/next/server.ts +28 -0
- package/src/compat/next/svgr.ts +58 -0
- package/src/compat/next/telemetry.ts +77 -0
- package/src/compat/next/user-agent.ts +100 -0
- package/src/compat/next/web-vitals.ts +56 -0
- package/src/compat/otel/api.ts +95 -0
- package/src/compat/otel/client-trace-metadata.ts +71 -0
- package/src/compat/otel/fetch-span.ts +77 -0
- package/src/compat/otel/tracer.ts +944 -0
- package/src/compat/pages/client-plugin.ts +108 -0
- package/src/compat/pages/index.ts +527 -0
- package/src/compat/pages/router-state.ts +94 -0
- package/src/compat/ppr/io.ts +38 -0
- package/src/compat/ppr/missing-root-params.ts +105 -0
- package/src/compat/ppr/root-params-scan.ts +164 -0
- package/src/compat/ppr/root-params-transform.ts +75 -0
- package/src/compat/ppr/root-params.ts +129 -0
- package/src/compat/ppr/segment-config-incompat.ts +34 -0
- package/src/compat/protocol.ts +202 -0
- package/src/compat/react/client.ts +59 -0
- package/src/compat/react/compiler-runtime.ts +60 -0
- package/src/compat/react/dom-client.ts +115 -0
- package/src/compat/react/dom-react-server.ts +20 -0
- package/src/compat/react/dom-server.ts +40 -0
- package/src/compat/react/dom.ts +154 -0
- package/src/compat/react/preact.ts +522 -0
- package/src/compat/react/react-server.ts +84 -0
- package/src/compat/react/router-shim.ts +26 -0
- package/src/compat/react/server-component-use.ts +48 -0
- package/src/compat/react/server-inserted-html.ts +87 -0
- package/src/compat/react/server.ts +156 -0
- package/src/compat/react/view-transition.ts +60 -0
- package/src/compat/register/actions.ts +875 -0
- package/src/compat/register/boot.ts +141 -0
- package/src/compat/register/build-tier.ts +11 -0
- package/src/compat/register/build.ts +182 -0
- package/src/compat/register/bundler.ts +587 -0
- package/src/compat/register/cache.ts +85 -0
- package/src/compat/register/client-errors.ts +18 -0
- package/src/compat/register/config.ts +17 -0
- package/src/compat/register/css-extras.ts +120 -0
- package/src/compat/register/edge-runtime.ts +6 -0
- package/src/compat/register/errors.ts +46 -0
- package/src/compat/register/export.ts +23 -0
- package/src/compat/register/font.ts +36 -0
- package/src/compat/register/hooks.ts +34 -0
- package/src/compat/register/image.ts +133 -0
- package/src/compat/register/index.ts +111 -0
- package/src/compat/register/instrumentation-client.ts +35 -0
- package/src/compat/register/lifecycle.ts +86 -0
- package/src/compat/register/mdx.ts +48 -0
- package/src/compat/register/middleware.ts +36 -0
- package/src/compat/register/misc.ts +44 -0
- package/src/compat/register/otel.ts +288 -0
- package/src/compat/register/pages-api.ts +473 -0
- package/src/compat/register/ppr.ts +56 -0
- package/src/compat/register/protocol.ts +57 -0
- package/src/compat/register/proxy.ts +127 -0
- package/src/compat/register/render.ts +268 -0
- package/src/compat/register/routing.ts +410 -0
- package/src/compat/register/segment.ts +1903 -0
- package/src/compat/register/static-image.ts +21 -0
- package/src/compat/register/typed-routes.ts +35 -0
- package/src/compat/register/usecache.ts +131 -0
- package/src/compat/register/validation.ts +56 -0
- package/src/compat/segment/loading-boundary.ts +113 -0
- package/src/compat/segment/page-slot.ts +200 -0
- package/src/compat/segment/tree.ts +481 -0
- package/src/compat/segment/vary-key.ts +102 -0
- package/src/compat/segment/vary-params.ts +551 -0
- package/src/compat/static-params.ts +33 -0
- package/src/compat/tsconfig-defaults.ts +301 -0
- package/src/compat/typecheck/index.ts +1481 -0
- package/src/compat/typecheck/worker.ts +26 -0
- package/src/compat/typed-routes/index.ts +92 -0
- package/src/compat/typed-routes/manifest.ts +356 -0
- package/src/compat/typed-routes/typegen.ts +566 -0
- package/src/compat/validation/errors.ts +159 -0
- package/src/compat/validation/index.ts +1770 -0
- package/src/compat/validation/prerender-diagnostics.ts +1508 -0
- package/src/compat-bootstrap.ts +67 -0
- package/src/config.ts +218 -0
- package/src/css/build.ts +697 -0
- package/src/css/index.ts +2 -0
- package/src/css/postcss.ts +236 -0
- package/src/css/worker.ts +34 -0
- package/src/dev/client-actions.ts +35 -0
- package/src/dev/client-chunk-store.ts +92 -0
- package/src/dev/client-key-cache.ts +178 -0
- package/src/dev/global-css-cache.ts +212 -0
- package/src/dev/imports.ts +2430 -0
- package/src/dev/module-cache.ts +721 -0
- package/src/dev/module-generations.ts +38 -0
- package/src/dev/module-transform.ts +188 -0
- package/src/dev/node-module-bundle-cache.ts +63 -0
- package/src/dev/restart-cache.ts +10 -0
- package/src/dev/route-bundle-key-cache.ts +154 -0
- package/src/dev/route-facts-cache.ts +223 -0
- package/src/dev/server.ts +1710 -0
- package/src/dynamic/source.ts +307 -0
- package/src/dynamic/tree-shake.ts +262 -0
- package/src/env.ts +92 -0
- package/src/extensions.ts +1898 -0
- package/src/index.ts +34 -0
- package/src/internal.ts +43 -0
- package/src/islands/boundary-error.ts +8 -0
- package/src/islands/static-children.ts +37 -0
- package/src/islands/static-slots.ts +106 -0
- package/src/ppr-postpone.ts +24 -0
- package/src/ppr.ts +784 -0
- package/src/proxy.ts +752 -0
- package/src/render/hooks.ts +384 -0
- package/src/render/index.ts +1 -0
- package/src/render/island-context.ts +47 -0
- package/src/render/metadata.ts +857 -0
- package/src/render/renderer.ts +7391 -0
- package/src/render/resource-hints.ts +44 -0
- package/src/render/slots.tsx +679 -0
- package/src/request/context.ts +396 -0
- package/src/resolve/engine.ts +219 -0
- package/src/resolve/imports.ts +1104 -0
- package/src/resolve/scan-facts.ts +474 -0
- package/src/resolve/source-text.ts +86 -0
- package/src/routing/forwarded.ts +41 -0
- package/src/routing/handler.ts +271 -0
- package/src/routing/href.ts +203 -0
- package/src/routing/metadata.ts +1018 -0
- package/src/routing/request-runtime.ts +43 -0
- package/src/routing/routes.ts +2560 -0
- package/src/routing/slots.ts +432 -0
- package/src/runtime/server.ts +3453 -0
- package/src/runtime/vendor.ts +1160 -0
- package/src/style-modules.d.ts +9 -0
- package/src/typegen.ts +151 -0
- package/src/types.ts +725 -0
- package/src/utils/ansi.ts +9 -0
- package/src/utils/content-type.ts +31 -0
- package/src/utils/decode.ts +7 -0
- package/src/utils/dev-profile.ts +31 -0
- package/src/utils/error-log.ts +29 -0
- package/src/utils/fs-cache.ts +31 -0
- package/src/utils/fs.ts +119 -0
- package/src/utils/html.ts +46 -0
- package/src/utils/serialize.ts +378 -0
- package/src/utils/source.ts +35 -0
- package/src/utils/verbose.ts +39 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
// Per-segment client cache (COMPAT client policy).
|
|
2
|
+
//
|
|
3
|
+
// The router's own caches are whole-URL, which cannot express Next's central segment-cache property:
|
|
4
|
+
// a segment that provably never READ a param is shareable across every value of that param, so
|
|
5
|
+
// `/vary/a` and `/vary/b` hit ONE entry.
|
|
6
|
+
//
|
|
7
|
+
// This module adds that layer. The server publishes, per segment response, `vary` (the param names
|
|
8
|
+
// the render actually read; `'?'` = it read searchParams, so the whole query keys it), `route` (the
|
|
9
|
+
// matched route pattern in colon form) and `params` (the concrete params of the URL that produced
|
|
10
|
+
// the response). Entries here are keyed by (segmentPath, route, the VARY SUBSET of params,
|
|
11
|
+
// query-if-varied), so an empty vary set collapses every param value onto one entry.
|
|
12
|
+
//
|
|
13
|
+
// Reuse policy: a navigation NEVER short-circuits on a cache hit unless the entry is COMPLETE (a
|
|
14
|
+
// whole document, not a truncated shell) AND STATIC (its bytes are a pure function of the URL) AND
|
|
15
|
+
// still FRESH. Everything else paints what it has and still issues the dynamic-stage request.
|
|
16
|
+
// Runtime prefetches are the one request-sampled exception: a COMPLETE one commits network-free, an
|
|
17
|
+
// incomplete one is shell-only.
|
|
18
|
+
//
|
|
19
|
+
// Cross-URL sharing is deliberately conservative: only a STATIC entry may serve a URL other than the
|
|
20
|
+
// one it was fetched for, the target must match the entry's learned route pattern, and a pathname
|
|
21
|
+
// already known to resolve to a DIFFERENT route is never predicted.
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
normalizeSegmentSearch,
|
|
25
|
+
segmentVaryCacheKey,
|
|
26
|
+
SEARCH_PARAMS_SENTINEL,
|
|
27
|
+
VARY_KEY_SEPARATOR,
|
|
28
|
+
} from '../segment/vary-key';
|
|
29
|
+
import {
|
|
30
|
+
composeSegmentFrames,
|
|
31
|
+
pageSlotRange,
|
|
32
|
+
stripPageSlotContent,
|
|
33
|
+
stripStreamedContinuation,
|
|
34
|
+
} from '../segment/page-slot';
|
|
35
|
+
|
|
36
|
+
/** LRU bound, matching the router's own segment caches. */
|
|
37
|
+
const SEGMENT_ENTRY_LIMIT = 64;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* w9-segment-split: the two derived frames of a body response. The LAYOUT frame
|
|
41
|
+
* is the response document with the page's markup cut out; the PAGE frame is the
|
|
42
|
+
* document as received (only its page-slot content is ever read back out). They
|
|
43
|
+
* are filed under their OWN vary sets, so a navigation whose layout varied and
|
|
44
|
+
* whose page did not re-fetches `/_layout` alone and composes it with the
|
|
45
|
+
* cached page frame.
|
|
46
|
+
*/
|
|
47
|
+
export const LAYOUT_FRAME_PATH = '/_layout';
|
|
48
|
+
export const PAGE_FRAME_PATH = '/_page';
|
|
49
|
+
|
|
50
|
+
/** The body-segment request key the router sends (`/` is the legacy alias). */
|
|
51
|
+
export const BODY_SEGMENT_PATHS = ['/_index', '/', PAGE_FRAME_PATH] as const;
|
|
52
|
+
|
|
53
|
+
export interface SegmentPayloadMeta {
|
|
54
|
+
/** Segment vary names (may include the `?` sentinel), or null when unknown. */
|
|
55
|
+
vary: string[] | null;
|
|
56
|
+
/**
|
|
57
|
+
* w9-segment-split: the LAYOUT frame's own vary set, when the server split
|
|
58
|
+
* the response. Null/absent means the response was not split and only the
|
|
59
|
+
* whole-document entry is filed.
|
|
60
|
+
*/
|
|
61
|
+
layoutVary?: string[] | null;
|
|
62
|
+
/** w9-segment-split: the PAGE frame's own vary set, when the server split. */
|
|
63
|
+
pageVary?: string[] | null;
|
|
64
|
+
/** Matched route pattern in colon form, or null. */
|
|
65
|
+
route: string | null;
|
|
66
|
+
/** Concrete params of the URL that produced the payload. */
|
|
67
|
+
params: Record<string, string | string[]>;
|
|
68
|
+
/**
|
|
69
|
+
* The route's shared APP SHELL, when the per-URL prerender carried one. Filed
|
|
70
|
+
* as its own param-independent entry so a navigation to another param of the
|
|
71
|
+
* route paints instantly.
|
|
72
|
+
*/
|
|
73
|
+
shell?: { html: string; route: string };
|
|
74
|
+
/**
|
|
75
|
+
* Literal siblings of each DYNAMIC level of the route, keyed by segment index
|
|
76
|
+
* (the server's `staticChildrenBySegment`). A pathname naming one of them is
|
|
77
|
+
* served by its own static route, so this payload must never key onto it.
|
|
78
|
+
*/
|
|
79
|
+
staticSiblings?: Record<string, string[]>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SegmentRecordInput extends SegmentPayloadMeta {
|
|
83
|
+
/** Segment request path (`/_index`, `/_head`, …). */
|
|
84
|
+
segmentPath: string;
|
|
85
|
+
/** Requested pathname (percent-encoding preserved). */
|
|
86
|
+
pathname: string;
|
|
87
|
+
/** Requested query string (leading `?` optional). */
|
|
88
|
+
search: string;
|
|
89
|
+
/** The payload the router would commit for this segment. */
|
|
90
|
+
html: string;
|
|
91
|
+
/** Reuse window in milliseconds. */
|
|
92
|
+
staleTimeMs: number;
|
|
93
|
+
/** True when the payload is a whole document, not a truncated shell. */
|
|
94
|
+
complete: boolean;
|
|
95
|
+
/** True when the bytes are a pure function of the URL (a prerender). */
|
|
96
|
+
static: boolean;
|
|
97
|
+
/** True when the payload came from a runtime (request-sampled) prefetch. */
|
|
98
|
+
runtime: boolean;
|
|
99
|
+
/** True when the route's catch-all is optional (`[[...slug]]`). */
|
|
100
|
+
catchAllOptional?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface SegmentEntry {
|
|
104
|
+
key: string;
|
|
105
|
+
html: string;
|
|
106
|
+
/** The URL this payload was fetched for, canonicalized (`_rsc` stripped). */
|
|
107
|
+
url: string;
|
|
108
|
+
time: number;
|
|
109
|
+
staleTimeMs: number;
|
|
110
|
+
complete: boolean;
|
|
111
|
+
static: boolean;
|
|
112
|
+
runtime: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface SegmentHit {
|
|
116
|
+
html: string;
|
|
117
|
+
/** True when this hit may commit a navigation with no network request. */
|
|
118
|
+
networkFree: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* True when a PREFETCH of this URL has nothing left to fetch: the entry is fresh and its own vary set
|
|
121
|
+
* justifies serving this URL. Weaker than `networkFree` - a navigation may still need the dynamic
|
|
122
|
+
* remainder.
|
|
123
|
+
*/
|
|
124
|
+
prefetchSatisfied: boolean;
|
|
125
|
+
runtime: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface LearnedRoute {
|
|
129
|
+
route: string;
|
|
130
|
+
vary: string[];
|
|
131
|
+
catchAllOptional: boolean;
|
|
132
|
+
/** Literal siblings of the route's dynamic levels, by segment index. */
|
|
133
|
+
staticSiblings?: Record<string, string[]>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const entries = new Map<string, SegmentEntry>();
|
|
137
|
+
/**
|
|
138
|
+
* Learned route vary sets, per segment path, keyed by route + vary set.
|
|
139
|
+
*
|
|
140
|
+
* One route can publish SEVERAL vary sets for the same segment: the per-URL
|
|
141
|
+
* prefetch of `/static-posts/1` reports `['id']` (it rendered the param), while
|
|
142
|
+
* the App-Shell prefetch of the same route reports `[]` (params hung). Both are
|
|
143
|
+
* live candidates for a lookup, so they are kept side by side rather than
|
|
144
|
+
* overwriting each other; `candidateKeys` tries the most specific first.
|
|
145
|
+
* Insertion order = learn order.
|
|
146
|
+
*/
|
|
147
|
+
const learnedRoutes = new Map<string, Map<string, LearnedRoute>>();
|
|
148
|
+
/** pathname -> the route pattern the SERVER resolved it to (sibling guard). */
|
|
149
|
+
const resolvedRoutes = new Map<string, string>();
|
|
150
|
+
|
|
151
|
+
/** Wall clock, overridable in tests (the e2e suites mock `Date`). */
|
|
152
|
+
function now(): number {
|
|
153
|
+
return Date.now();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Recording.
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
export function recordSegment(input: SegmentRecordInput): void {
|
|
161
|
+
recordOneSegment(input);
|
|
162
|
+
// w9-segment-split: a body response the server split also files its two
|
|
163
|
+
// frames, each under its OWN vary set. Additive — the whole-document entry
|
|
164
|
+
// above is untouched, so nothing that hits today starts missing.
|
|
165
|
+
if (!BODY_SEGMENT_PATHS.includes(input.segmentPath as (typeof BODY_SEGMENT_PATHS)[number])) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (input.segmentPath === PAGE_FRAME_PATH) return;
|
|
169
|
+
// The response's layout frame, filed under this URL's EXACT key. Independent of `layoutVary` - the
|
|
170
|
+
// server publishes that set only when it can prove what the layout read, but a frame keyed on the
|
|
171
|
+
// URL it was fetched for needs no such proof. BOTH layout-frame records are skipped when the payload
|
|
172
|
+
// carries no page slot: `stripPageSlotContent` would hand back the whole document, and a "layout
|
|
173
|
+
// frame" with no slot to splice into makes `needsPageFrameOnly` promise a composition that always
|
|
174
|
+
// fails - two serial round trips for one navigation.
|
|
175
|
+
const framable = pageSlotRange(input.html) !== null;
|
|
176
|
+
if (framable) {
|
|
177
|
+
recordOneSegment({
|
|
178
|
+
...input,
|
|
179
|
+
segmentPath: LAYOUT_FRAME_PATH,
|
|
180
|
+
html: stripPageSlotContent(input.html),
|
|
181
|
+
// Exact-URL only: no route, no vary set, so this frame is never shared
|
|
182
|
+
// across params the way a `layoutVary` frame is.
|
|
183
|
+
route: null,
|
|
184
|
+
params: {},
|
|
185
|
+
vary: null,
|
|
186
|
+
complete: false,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (framable && input.layoutVary) {
|
|
190
|
+
recordOneSegment({
|
|
191
|
+
...input,
|
|
192
|
+
segmentPath: LAYOUT_FRAME_PATH,
|
|
193
|
+
html: stripPageSlotContent(input.html),
|
|
194
|
+
vary: input.layoutVary,
|
|
195
|
+
// A layout frame is never the whole page: it may paint, but a navigation
|
|
196
|
+
// still needs the page below it.
|
|
197
|
+
complete: false,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (input.pageVary) {
|
|
201
|
+
recordOneSegment({ ...input, segmentPath: PAGE_FRAME_PATH, vary: input.pageVary });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A payload is REWRITE-PRODUCED when the route the server resolved does not describe the URL it was
|
|
207
|
+
* served at - either structurally, or in its concrete param values.
|
|
208
|
+
*
|
|
209
|
+
* Such a payload must never teach the route trie: a rewrite maps ONE url somewhere, and a sibling URL
|
|
210
|
+
* of the same shape may rewrite somewhere else entirely, or not at all. Learning from it would hand
|
|
211
|
+
* every URL of the pattern a "free" shared shell it is not entitled to, so the router would paint a
|
|
212
|
+
* loading boundary for a URL it has never seen.
|
|
213
|
+
*
|
|
214
|
+
* Params the payload leaves ABSENT are not a divergence: an App Shell renders with its params hanging,
|
|
215
|
+
* and that shell IS shared across params by design.
|
|
216
|
+
*/
|
|
217
|
+
function isRewriteResponse(input: SegmentRecordInput): boolean {
|
|
218
|
+
if (!input.route) return false;
|
|
219
|
+
// The catch-all tail is matched in its OPTIONAL form regardless of what the payload declared: a
|
|
220
|
+
// `[[...slug]]` index response does not always carry `catchAllOptional` on the wire, and reading
|
|
221
|
+
// its absence as "the URL is a segment short" would mis-file every optional-catchall index as a
|
|
222
|
+
// rewrite. The guard's bias is to accuse only what it can prove.
|
|
223
|
+
const derived = matchRoutePattern(input.route, input.pathname, true);
|
|
224
|
+
if (!derived) return true;
|
|
225
|
+
return Object.entries(input.params).some(([name, value]) => {
|
|
226
|
+
const own = derived[name];
|
|
227
|
+
if (own === undefined) return false;
|
|
228
|
+
return Array.isArray(value) || Array.isArray(own)
|
|
229
|
+
? !Array.isArray(value) ||
|
|
230
|
+
!Array.isArray(own) ||
|
|
231
|
+
value.length !== own.length ||
|
|
232
|
+
value.some((part, index) => part !== own[index])
|
|
233
|
+
: value !== own;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function recordOneSegment(rawInput: SegmentRecordInput): void {
|
|
238
|
+
// A rewrite payload is filed EXACT-URL only (no route, no vary set): it stays
|
|
239
|
+
// available for the URL it was fetched for and is invisible to every other.
|
|
240
|
+
const input: SegmentRecordInput = isRewriteResponse(rawInput)
|
|
241
|
+
? { ...rawInput, route: null, vary: null }
|
|
242
|
+
: rawInput;
|
|
243
|
+
if (rawInput.route) {
|
|
244
|
+
// LRU-bound like `entries`: one record per distinct pathname otherwise
|
|
245
|
+
// grows without limit (memory-pressure leak-slope test).
|
|
246
|
+
resolvedRoutes.delete(input.pathname);
|
|
247
|
+
// The resolved-route record stays TRUE even for a rewrite: the server
|
|
248
|
+
// really did answer this pathname with that route, and the sibling guard
|
|
249
|
+
// in `candidateKeys` reads it to keep other routes off this URL.
|
|
250
|
+
resolvedRoutes.set(rawInput.pathname, rawInput.route);
|
|
251
|
+
while (resolvedRoutes.size > SEGMENT_ENTRY_LIMIT) {
|
|
252
|
+
const oldest = resolvedRoutes.keys().next().value;
|
|
253
|
+
if (oldest === undefined) break;
|
|
254
|
+
resolvedRoutes.delete(oldest);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const key = entryKey(input.segmentPath, input.route, input.params, input.search, input.vary, {
|
|
258
|
+
pathname: input.pathname,
|
|
259
|
+
});
|
|
260
|
+
if (input.route && input.vary !== null) {
|
|
261
|
+
let byRoute = learnedRoutes.get(input.segmentPath);
|
|
262
|
+
if (!byRoute) {
|
|
263
|
+
byRoute = new Map<string, LearnedRoute>();
|
|
264
|
+
learnedRoutes.set(input.segmentPath, byRoute);
|
|
265
|
+
}
|
|
266
|
+
const learnedKey = `${input.route}${VARY_KEY_SEPARATOR}${[...input.vary].sort().join(',')}`;
|
|
267
|
+
// The sibling set is a property of the ROUTE, not of one response: a payload
|
|
268
|
+
// that omits it (a framed segment truncated above the state script) must not
|
|
269
|
+
// un-learn what an earlier one published.
|
|
270
|
+
const staticSiblings = input.staticSiblings ?? byRoute.get(learnedKey)?.staticSiblings;
|
|
271
|
+
// Re-inserting moves the route to the end: most recently learned wins on
|
|
272
|
+
// lookup, mirroring the loading-shell prediction order.
|
|
273
|
+
byRoute.delete(learnedKey);
|
|
274
|
+
byRoute.set(learnedKey, {
|
|
275
|
+
route: input.route,
|
|
276
|
+
vary: input.vary,
|
|
277
|
+
catchAllOptional: input.catchAllOptional === true,
|
|
278
|
+
...(staticSiblings ? { staticSiblings } : {}),
|
|
279
|
+
});
|
|
280
|
+
// LRU-bound like `entries`/`resolvedRoutes`: a route publishing a new vary
|
|
281
|
+
// set per prefetch (dynamic params in the vary set) would otherwise grow this
|
|
282
|
+
// map without limit (memory-pressure leak-slope test).
|
|
283
|
+
trimLearnedRoutes(byRoute);
|
|
284
|
+
}
|
|
285
|
+
entries.set(key, {
|
|
286
|
+
key,
|
|
287
|
+
html: input.html,
|
|
288
|
+
url: input.pathname + normalizeSegmentSearch(input.search),
|
|
289
|
+
time: now(),
|
|
290
|
+
staleTimeMs: input.staleTimeMs,
|
|
291
|
+
complete: input.complete,
|
|
292
|
+
static: input.static,
|
|
293
|
+
runtime: input.runtime,
|
|
294
|
+
});
|
|
295
|
+
touch(key);
|
|
296
|
+
trim();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Lookup.
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The cached segment usable for `pathname`+`search`, or null.
|
|
305
|
+
*
|
|
306
|
+
* `networkFree` is the spec's commit rule: only a complete + static + fresh
|
|
307
|
+
* entry (or a complete runtime prefetch) lets a navigation skip the dynamic
|
|
308
|
+
* stage. Everything else is returned for painting only.
|
|
309
|
+
*/
|
|
310
|
+
export function takeSegment(options: {
|
|
311
|
+
pathname: string;
|
|
312
|
+
search: string;
|
|
313
|
+
segmentPath: string;
|
|
314
|
+
}): SegmentHit | null {
|
|
315
|
+
const requestedUrl = options.pathname + normalizeSegmentSearch(options.search);
|
|
316
|
+
const usable: {
|
|
317
|
+
candidate: { key: string; learnedKey?: string; params?: Record<string, string | string[]> };
|
|
318
|
+
entry: SegmentEntry;
|
|
319
|
+
}[] = [];
|
|
320
|
+
for (const candidate of candidateKeys(options.segmentPath, options.pathname, options.search)) {
|
|
321
|
+
const entry = entries.get(candidate.key);
|
|
322
|
+
if (!entry) continue;
|
|
323
|
+
if (now() - entry.time >= entry.staleTimeMs) {
|
|
324
|
+
entries.delete(candidate.key);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
// A shared (different-URL) hit is sound for STATIC content - and for a request-sampled payload
|
|
328
|
+
// the SERVER's own vary set authorised: a non-exact candidate key exists only because a learned
|
|
329
|
+
// route published a vary set, and that set is the server's assertion of what the payload depends
|
|
330
|
+
// on. Everything else stays exact-URL: a request-sampled payload belongs to the URL it sampled.
|
|
331
|
+
const shared = entry.url !== requestedUrl;
|
|
332
|
+
const varyAuthorised = candidate.learnedKey !== undefined;
|
|
333
|
+
if (shared && !entry.static && !(entry.runtime && varyAuthorised)) continue;
|
|
334
|
+
usable.push({ candidate, entry });
|
|
335
|
+
}
|
|
336
|
+
// Candidate ORDER is specificity (exact URL first), but an INCOMPLETE first
|
|
337
|
+
// match yields to a RUNTIME prefetch of this exact URL: an `allow-runtime`
|
|
338
|
+
// route's request-sampled payload is the only one carrying the content behind
|
|
339
|
+
// its Suspense boundaries, while a document-derived entry for the same URL
|
|
340
|
+
// holds the fallbacks. A complete first match already covers everything a
|
|
341
|
+
// runtime sample could, so it keeps the established first-match order.
|
|
342
|
+
const first = usable[0];
|
|
343
|
+
const chosen =
|
|
344
|
+
first && !first.entry.complete
|
|
345
|
+
? (usable.find(({ entry }) => entry.runtime && entry.url === requestedUrl) ?? first)
|
|
346
|
+
: first;
|
|
347
|
+
if (chosen) {
|
|
348
|
+
const { candidate, entry } = chosen;
|
|
349
|
+
const key = candidate.key;
|
|
350
|
+
const shared = entry.url !== requestedUrl;
|
|
351
|
+
touch(key);
|
|
352
|
+
// A learned route that is still serving lookups is live: re-insert it so the
|
|
353
|
+
// LRU bound evicts the route patterns nobody navigates to.
|
|
354
|
+
if (candidate.learnedKey) touchLearnedRoute(options.segmentPath, candidate.learnedKey);
|
|
355
|
+
return {
|
|
356
|
+
// A SHARED payload was rendered for a DIFFERENT URL of the same route, so the concrete params
|
|
357
|
+
// baked into its three param carriers belong to that URL. Re-stamp them for the URL being served -
|
|
358
|
+
// the client-side mirror of the server's `withRequestRouteParams`, and the same soundness rule:
|
|
359
|
+
// the vary set authorises sharing the CONTENT, never the param values.
|
|
360
|
+
html: shared && candidate.params ? restampSharedParams(entry.html, candidate.params) : entry.html,
|
|
361
|
+
// A COMPLETE + STATIC entry is network-free even when SHARED: a static prerender whose vary set
|
|
362
|
+
// does not contain the differing param is by construction byte-identical for this URL, so there
|
|
363
|
+
// is nothing left to fetch. Everything else stays exact-URL only: a shared SHELL (an App Shell
|
|
364
|
+
// whose params hung) is missing the per-param content, and a shared request-sampled payload
|
|
365
|
+
// belongs to the URL it sampled. Both may paint, but the navigation must still issue its request.
|
|
366
|
+
networkFree: entry.complete && (entry.static || (!shared && entry.runtime)),
|
|
367
|
+
// A PREFETCH asks a weaker question than a commit: is there anything left to fetch FOR THIS URL?
|
|
368
|
+
// An exact-URL hit always answers no. A SHARED hit answers no when the sharing was authorised -
|
|
369
|
+
// either the payload is static, or a published vary set let `candidateKeys` produce the shared
|
|
370
|
+
// match. The navigation that follows still fetches the dynamic remainder.
|
|
371
|
+
prefetchSatisfied:
|
|
372
|
+
!shared || (optimisticRoutingEnabled() && (entry.static || entry.runtime)),
|
|
373
|
+
runtime: entry.runtime,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* `experimental.optimisticRouting` (default ON; register-render stamps the
|
|
381
|
+
* document only for an app that turned it OFF).
|
|
382
|
+
*
|
|
383
|
+
* With it off, a prefetch of a URL that was never fetched still goes to the
|
|
384
|
+
* wire even when a sibling entry's vary set covers it: optimistic reuse of
|
|
385
|
+
* another URL's entry to answer a prefetch IS the feature the flag names, and
|
|
386
|
+
* the suites that pin the pre-flag behavior block that first request
|
|
387
|
+
* (`segment-cache/search-params` shared-loading-state). The reuse a NAVIGATION
|
|
388
|
+
* makes (`networkFree`) is a separate, older contract and stays on.
|
|
389
|
+
*/
|
|
390
|
+
function optimisticRoutingEnabled(): boolean {
|
|
391
|
+
return (
|
|
392
|
+
(globalThis as { __PNEXT_NO_OPTIMISTIC_ROUTING__?: boolean })
|
|
393
|
+
.__PNEXT_NO_OPTIMISTIC_ROUTING__ !== true
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* True when a PREFETCH of this segment has nothing left to fetch because a fresh entry the server's
|
|
399
|
+
* own vary set covers is already cached.
|
|
400
|
+
*
|
|
401
|
+
* Deliberately separate from `takeSegment`: this answers only "skip the wire", never "what may this
|
|
402
|
+
* navigation paint or commit". A PPR shell is postponed and carries no `x-nextjs-prerender`, so it is
|
|
403
|
+
* not static and `takeSegment` will not share it - but when the server published a vary set that does
|
|
404
|
+
* NOT name the differing param, that set is its assertion that the shell's bytes are identical for
|
|
405
|
+
* this URL.
|
|
406
|
+
*
|
|
407
|
+
* A request-sampled (`runtime`) payload is excluded: its bytes belong to the URL it was sampled for,
|
|
408
|
+
* and the App-Shell scheduler relies on re-fetching them.
|
|
409
|
+
*/
|
|
410
|
+
export function segmentPrefetchCovered(options: {
|
|
411
|
+
pathname: string;
|
|
412
|
+
search: string;
|
|
413
|
+
segmentPath: string;
|
|
414
|
+
}): { html: string } | null {
|
|
415
|
+
const requestedUrl = options.pathname + normalizeSegmentSearch(options.search);
|
|
416
|
+
for (const candidate of candidateKeys(options.segmentPath, options.pathname, options.search)) {
|
|
417
|
+
const entry = entries.get(candidate.key);
|
|
418
|
+
if (!entry) continue;
|
|
419
|
+
if (now() - entry.time >= entry.staleTimeMs) continue;
|
|
420
|
+
if (entry.runtime) continue;
|
|
421
|
+
// An exact-URL hit is `takeSegment`'s business (it already dedupes there);
|
|
422
|
+
// this predicate exists for the SHARED case the vary set authorised.
|
|
423
|
+
if (entry.url === requestedUrl || candidate.learnedKey === undefined) continue;
|
|
424
|
+
return {
|
|
425
|
+
html: candidate.params ? restampSharedParams(entry.html, candidate.params) : entry.html,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
// w9-segment-split: composed lookups.
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Whether a navigation may commit a COMPOSED (layout frame + page frame)
|
|
437
|
+
* document. Off by default: the split frames are recorded unconditionally (they
|
|
438
|
+
* cost one extra entry and can only ever add hits), but composing them changes
|
|
439
|
+
* what a navigation PAINTS, and that is the half of the split that has not been
|
|
440
|
+
* validated end to end. `setSegmentSplitCommit(true)` opts in.
|
|
441
|
+
*/
|
|
442
|
+
let segmentSplitCommit = false;
|
|
443
|
+
|
|
444
|
+
export function setSegmentSplitCommit(enabled: boolean): void {
|
|
445
|
+
segmentSplitCommit = enabled;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function segmentSplitCommitEnabled(): boolean {
|
|
449
|
+
return segmentSplitCommit;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* A document composed from the cached LAYOUT and PAGE frames of this URL, or null when either frame
|
|
454
|
+
* misses (or composition is off / the frames have no page slot to splice). This is the layout/page
|
|
455
|
+
* split's payoff: a sibling URL can miss the whole-document entry (the layout read the differing
|
|
456
|
+
* param) but hit the page frame, so only the layout has to come off the network.
|
|
457
|
+
*/
|
|
458
|
+
export function takeComposedSegment(options: {
|
|
459
|
+
pathname: string;
|
|
460
|
+
search: string;
|
|
461
|
+
}): SegmentHit | null {
|
|
462
|
+
if (!segmentSplitCommit) return null;
|
|
463
|
+
const layout = takeSegment({ ...options, segmentPath: LAYOUT_FRAME_PATH });
|
|
464
|
+
if (!layout) return null;
|
|
465
|
+
const page = takeSegment({ ...options, segmentPath: PAGE_FRAME_PATH });
|
|
466
|
+
if (!page) return null;
|
|
467
|
+
const html = composeSegmentFrames(layout.html, page.html);
|
|
468
|
+
if (html === null) return null;
|
|
469
|
+
return {
|
|
470
|
+
html,
|
|
471
|
+
// A composed document is only as committable as its weaker half.
|
|
472
|
+
networkFree: layout.networkFree && page.networkFree,
|
|
473
|
+
prefetchSatisfied: layout.prefetchSatisfied && page.prefetchSatisfied,
|
|
474
|
+
runtime: layout.runtime || page.runtime,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
// w9-segment-split (G2): per-segment NAVIGATION — fetch `/_page`, keep the
|
|
480
|
+
// layout.
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* The layout frame of a whole DOCUMENT the router committed (the hard-loaded
|
|
485
|
+
* page, a navigation response). Kept apart from `entries`:
|
|
486
|
+
*
|
|
487
|
+
* - a document carries no vary metadata, so the frame is only ever valid for
|
|
488
|
+
* the exact URL it was rendered for, and
|
|
489
|
+
* - it is only ever consumed by a SAME-URL navigation (Next's "refresh the
|
|
490
|
+
* page segments, keep the layouts" semantics). A later navigation BACK to
|
|
491
|
+
* this URL must not paint a layout this old, so `cachedLayoutFrame` takes it
|
|
492
|
+
* only when the caller says the destination is the current location.
|
|
493
|
+
*/
|
|
494
|
+
interface DocumentLayoutFrame {
|
|
495
|
+
html: string;
|
|
496
|
+
time: number;
|
|
497
|
+
staleTimeMs: number;
|
|
498
|
+
}
|
|
499
|
+
const documentLayoutFrames = new Map<string, DocumentLayoutFrame>();
|
|
500
|
+
/** Small on purpose: only the live URL's frame is ever read back. */
|
|
501
|
+
const DOCUMENT_LAYOUT_LIMIT = 8;
|
|
502
|
+
|
|
503
|
+
export function recordDocumentLayoutFrame(input: {
|
|
504
|
+
pathname: string;
|
|
505
|
+
search: string;
|
|
506
|
+
html: string;
|
|
507
|
+
staleTimeMs: number;
|
|
508
|
+
}): void {
|
|
509
|
+
// No page slot: `stripPageSlotContent` would return the whole document, and a
|
|
510
|
+
// composed document would then carry the page twice.
|
|
511
|
+
if (pageSlotRange(input.html) === null) return;
|
|
512
|
+
const key = input.pathname + normalizeSegmentSearch(input.search);
|
|
513
|
+
documentLayoutFrames.delete(key);
|
|
514
|
+
documentLayoutFrames.set(key, {
|
|
515
|
+
html: stripPageSlotContent(input.html),
|
|
516
|
+
time: now(),
|
|
517
|
+
staleTimeMs: input.staleTimeMs,
|
|
518
|
+
});
|
|
519
|
+
while (documentLayoutFrames.size > DOCUMENT_LAYOUT_LIMIT) {
|
|
520
|
+
const oldest = documentLayoutFrames.keys().next().value;
|
|
521
|
+
if (oldest === undefined) break;
|
|
522
|
+
documentLayoutFrames.delete(oldest);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* The LAYOUT frame a navigation to this URL may keep: a cached `/_layout` segment (fetched, or derived
|
|
528
|
+
* from a prefetch of this URL), or - for a navigation to the URL already on screen - the live
|
|
529
|
+
* document's own frame.
|
|
530
|
+
*/
|
|
531
|
+
function cachedLayoutFrame(options: {
|
|
532
|
+
pathname: string;
|
|
533
|
+
search: string;
|
|
534
|
+
sameUrl: boolean;
|
|
535
|
+
}): string | null {
|
|
536
|
+
const hit = takeSegment({
|
|
537
|
+
pathname: options.pathname,
|
|
538
|
+
search: options.search,
|
|
539
|
+
segmentPath: LAYOUT_FRAME_PATH,
|
|
540
|
+
});
|
|
541
|
+
// The continuation chunks are cut at CONSUMPTION time, so every source of a
|
|
542
|
+
// layout frame — the server's `/_layout` response, a frame derived from a
|
|
543
|
+
// prefetch, the live document's — is covered by one rule.
|
|
544
|
+
if (hit) return stripStreamedContinuation(hit.html);
|
|
545
|
+
if (!options.sameUrl) return null;
|
|
546
|
+
const frame = documentLayoutFrames.get(options.pathname + normalizeSegmentSearch(options.search));
|
|
547
|
+
if (!frame) return null;
|
|
548
|
+
if (now() - frame.time >= frame.staleTimeMs) return null;
|
|
549
|
+
return stripStreamedContinuation(frame.html);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* True when a navigation to this URL may fetch the PAGE frame alone: its layout chain is already
|
|
554
|
+
* cached (or already on screen), so `/_page` carries everything the commit still needs. This is the
|
|
555
|
+
* gate the router asks BEFORE it goes to the wire - the commit itself goes through
|
|
556
|
+
* `composeCachedLayout`, and any failure there falls back to the whole-document fetch.
|
|
557
|
+
*/
|
|
558
|
+
export function needsPageFrameOnly(options: {
|
|
559
|
+
pathname: string;
|
|
560
|
+
search: string;
|
|
561
|
+
sameUrl: boolean;
|
|
562
|
+
}): boolean {
|
|
563
|
+
return cachedLayoutFrame(options) !== null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Splice a `/_page` response into this URL's cached layout frame, yielding the document the
|
|
568
|
+
* whole-document response would have been. Null when the frame is gone (it can expire between the
|
|
569
|
+
* gate and the response) or the two cannot be proven to line up.
|
|
570
|
+
*/
|
|
571
|
+
export function composeCachedLayout(options: {
|
|
572
|
+
pathname: string;
|
|
573
|
+
search: string;
|
|
574
|
+
sameUrl: boolean;
|
|
575
|
+
pageHtml: string;
|
|
576
|
+
}): string | null {
|
|
577
|
+
const layout = cachedLayoutFrame(options);
|
|
578
|
+
return layout === null ? null : composeSegmentFrames(layout, options.pageHtml);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Compose a just-fetched `/_layout` response with this URL's cached PAGE frame into the whole
|
|
583
|
+
* document the `/_index` request would have returned. The layout-only fetch is only worth issuing if
|
|
584
|
+
* the navigation that follows can still paint a complete page, so the two frames are joined HERE, at
|
|
585
|
+
* record time, and filed as an ordinary whole-document entry. Null when the page frame is gone or the
|
|
586
|
+
* two cannot be proven to line up.
|
|
587
|
+
*/
|
|
588
|
+
export function composeFetchedLayoutFrame(options: {
|
|
589
|
+
pathname: string;
|
|
590
|
+
search: string;
|
|
591
|
+
layoutHtml: string;
|
|
592
|
+
}): string | null {
|
|
593
|
+
const page = takeSegment({
|
|
594
|
+
pathname: options.pathname,
|
|
595
|
+
search: options.search,
|
|
596
|
+
segmentPath: PAGE_FRAME_PATH,
|
|
597
|
+
});
|
|
598
|
+
if (!page) return null;
|
|
599
|
+
return composeSegmentFrames(stripStreamedContinuation(options.layoutHtml), page.html);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* True when the PAGE frame for this URL is cached but its LAYOUT frame is not: the navigation should
|
|
604
|
+
* fetch `/_layout` alone rather than the whole route.
|
|
605
|
+
*/
|
|
606
|
+
/**
|
|
607
|
+
* True when a RUNTIME-prefetched PAGE frame already covers this URL. Only the page opts into
|
|
608
|
+
* `prefetch = 'allow-runtime'`; the layouts above it have no static data to prefetch (their
|
|
609
|
+
* params-dependent content sits behind a Suspense boundary and resolves in the dynamic stage), so once
|
|
610
|
+
* the page frame's vary set covers the URL the prefetch has nothing left to fetch.
|
|
611
|
+
*/
|
|
612
|
+
export function runtimePageFrameSatisfied(options: {
|
|
613
|
+
pathname: string;
|
|
614
|
+
search: string;
|
|
615
|
+
}): boolean {
|
|
616
|
+
const requestedUrl = options.pathname + normalizeSegmentSearch(options.search);
|
|
617
|
+
for (const candidate of candidateKeys(PAGE_FRAME_PATH, options.pathname, options.search)) {
|
|
618
|
+
const entry = entries.get(candidate.key);
|
|
619
|
+
if (!entry?.runtime) continue;
|
|
620
|
+
if (now() - entry.time >= entry.staleTimeMs) continue;
|
|
621
|
+
// SHARED coverage only: a URL the client fetched for itself keeps its own
|
|
622
|
+
// prefetch (a stale/incomplete entry of that URL must still refetch); this
|
|
623
|
+
// rule exists for the sibling param the vary set already covers.
|
|
624
|
+
if (entry.url === requestedUrl || candidate.learnedKey === undefined) continue;
|
|
625
|
+
return true;
|
|
626
|
+
}
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export function needsLayoutFrameOnly(options: {
|
|
631
|
+
pathname: string;
|
|
632
|
+
search: string;
|
|
633
|
+
}): boolean {
|
|
634
|
+
if (takeSegment({ ...options, segmentPath: LAYOUT_FRAME_PATH })) return false;
|
|
635
|
+
return takeSegment({ ...options, segmentPath: PAGE_FRAME_PATH }) !== null;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Every cache key that could serve this URL, most specific first: the EXACT-URL key (what a response
|
|
640
|
+
* with no vary metadata is filed under), then the learned route/vary keys, widest vary set first, so
|
|
641
|
+
* a per-URL entry always beats the route's shared App Shell for the URL it was fetched for.
|
|
642
|
+
*
|
|
643
|
+
* When the server has told us which route serves this exact pathname, only that route's keys are
|
|
644
|
+
* considered - a static sibling of a dynamic segment is never predicted onto the dynamic pattern.
|
|
645
|
+
*/
|
|
646
|
+
function candidateKeys(
|
|
647
|
+
segmentPath: string,
|
|
648
|
+
pathname: string,
|
|
649
|
+
search: string,
|
|
650
|
+
): { key: string; learnedKey?: string; params?: Record<string, string | string[]> }[] {
|
|
651
|
+
const keys: { key: string; learnedKey?: string; params?: Record<string, string | string[]> }[] = [
|
|
652
|
+
{ key: entryKey(segmentPath, null, {}, search, null, { pathname }) },
|
|
653
|
+
];
|
|
654
|
+
const byRoute = learnedRoutes.get(segmentPath);
|
|
655
|
+
if (!byRoute) return keys;
|
|
656
|
+
const ownRoute = resolvedRoutes.get(pathname);
|
|
657
|
+
const learned = [...byRoute.entries()]
|
|
658
|
+
.reverse()
|
|
659
|
+
.filter(([, candidate]) => ownRoute === undefined || candidate.route === ownRoute)
|
|
660
|
+
// Stable sort: within one specificity the most recently learned wins.
|
|
661
|
+
.sort(([, a], [, b]) => b.vary.length - a.vary.length);
|
|
662
|
+
for (const [learnedKey, candidate] of learned) {
|
|
663
|
+
const params = matchRoutePattern(candidate.route, pathname, candidate.catchAllOptional);
|
|
664
|
+
if (!params) continue;
|
|
665
|
+
// The pattern matches, but the segment it matched is a literal the server
|
|
666
|
+
// published as a STATIC SIBLING of this dynamic level: that sibling's own
|
|
667
|
+
// route serves the URL, so the dynamic route's shared bytes are not its.
|
|
668
|
+
if (namesStaticSibling(pathname, candidate.staticSiblings)) continue;
|
|
669
|
+
keys.push({
|
|
670
|
+
key: entryKey(segmentPath, candidate.route, params, search, candidate.vary, { pathname }),
|
|
671
|
+
learnedKey,
|
|
672
|
+
params,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
return keys;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function entryKey(
|
|
679
|
+
segmentPath: string,
|
|
680
|
+
route: string | null,
|
|
681
|
+
params: Record<string, string | string[]>,
|
|
682
|
+
search: string,
|
|
683
|
+
vary: string[] | null,
|
|
684
|
+
context: { pathname: string },
|
|
685
|
+
): string {
|
|
686
|
+
return segmentVaryCacheKey({
|
|
687
|
+
route: route ?? '',
|
|
688
|
+
segmentPath,
|
|
689
|
+
params,
|
|
690
|
+
search,
|
|
691
|
+
vary: route ? vary : null,
|
|
692
|
+
pathname: context.pathname,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ---------------------------------------------------------------------------
|
|
697
|
+
// Route pattern matching (pure — the unit tests drive this directly).
|
|
698
|
+
// ---------------------------------------------------------------------------
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Match `pathname` against a colon-form route pattern (`/vary/:cat`,
|
|
702
|
+
* `/blog/:slug*`) and return its params, or null when it does not match.
|
|
703
|
+
* `optionalCatchAll` lets a trailing `:slug*` match zero segments.
|
|
704
|
+
*/
|
|
705
|
+
export function matchRoutePattern(
|
|
706
|
+
pattern: string,
|
|
707
|
+
pathname: string,
|
|
708
|
+
optionalCatchAll = false,
|
|
709
|
+
): Record<string, string | string[]> | null {
|
|
710
|
+
const patternParts = pattern.split('/').filter(Boolean);
|
|
711
|
+
const pathParts = pathname.split('/').filter(Boolean);
|
|
712
|
+
const params: Record<string, string | string[]> = {};
|
|
713
|
+
let cursor = 0;
|
|
714
|
+
for (const part of patternParts) {
|
|
715
|
+
const catchAll = /^:([\w$]+)\*$/.exec(part);
|
|
716
|
+
if (catchAll) {
|
|
717
|
+
const rest = pathParts.slice(cursor).map(decodeSegment);
|
|
718
|
+
if (rest.length === 0 && !optionalCatchAll) return null;
|
|
719
|
+
if (rest.length > 0) params[catchAll[1]!] = rest;
|
|
720
|
+
return params;
|
|
721
|
+
}
|
|
722
|
+
const dynamic = /^:([\w$]+)$/.exec(part);
|
|
723
|
+
const value = pathParts[cursor++];
|
|
724
|
+
if (value === undefined) return null;
|
|
725
|
+
if (dynamic) params[dynamic[1]!] = decodeSegment(value);
|
|
726
|
+
else if (part !== value) return null;
|
|
727
|
+
}
|
|
728
|
+
return cursor === pathParts.length ? params : null;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ---------------------------------------------------------------------------
|
|
732
|
+
// Shared-payload param re-stamping (pure — no DOM, driven by the unit tests).
|
|
733
|
+
// ---------------------------------------------------------------------------
|
|
734
|
+
|
|
735
|
+
/** The island prop placeholder the server fills per request (slots.tsx). */
|
|
736
|
+
const PROMISE_MARKER_KEY = '__pnextPromise';
|
|
737
|
+
const ISLAND_PARAMS_ATTRIBUTE = 'data-pnext-params';
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Rewrite every param carrier in a payload rendered for a SIBLING URL of the
|
|
741
|
+
* same route so it describes `params` instead.
|
|
742
|
+
*
|
|
743
|
+
* Three carriers, all of which must agree (the server's `withRequestRouteParams`
|
|
744
|
+
* writes exactly these three): `window.__PNEXT_ROUTE__` (what `useParams()`
|
|
745
|
+
* reads), the `data-pnext-params` island scopes (which OVERRIDE the window
|
|
746
|
+
* state for the island carrying them), and the `data-pnext-props` promise
|
|
747
|
+
* markers a partial prerender baked for a hanging `params` prop. Leaving any of
|
|
748
|
+
* them behind hands the destination URL the source URL's params for the whole
|
|
749
|
+
* time its dynamic stage is in flight.
|
|
750
|
+
*
|
|
751
|
+
* The payload's own embedded route state is the source of truth for what to
|
|
752
|
+
* replace: only values byte-identical to ITS params are rewritten, so app
|
|
753
|
+
* content that merely happens to contain the same string is untouched.
|
|
754
|
+
*/
|
|
755
|
+
function restampSharedParams(html: string, params: Record<string, string | string[]>): string {
|
|
756
|
+
const match = /(window\.__PNEXT_ROUTE__=)(\{.*?\})(;?<\/script>)/s.exec(html);
|
|
757
|
+
if (!match?.[2]) return html;
|
|
758
|
+
let state: Record<string, unknown>;
|
|
759
|
+
try {
|
|
760
|
+
state = JSON.parse(match[2]) as Record<string, unknown>;
|
|
761
|
+
} catch {
|
|
762
|
+
// A route script we cannot parse cannot be re-stamped safely.
|
|
763
|
+
return html;
|
|
764
|
+
}
|
|
765
|
+
const baked = (state.params ?? {}) as Record<string, string | string[]>;
|
|
766
|
+
const bakedJson = paramsJson(baked);
|
|
767
|
+
const targetJson = paramsJson(params);
|
|
768
|
+
if (bakedJson === targetJson) return html;
|
|
769
|
+
const rewritten =
|
|
770
|
+
html.slice(0, match.index) +
|
|
771
|
+
`${match[1]}${paramsJson({ ...state, params })}${match[3]}` +
|
|
772
|
+
html.slice(match.index + match[0].length);
|
|
773
|
+
const replacements: [string, string][] = [
|
|
774
|
+
[islandParamsAttribute(bakedJson), islandParamsAttribute(targetJson)],
|
|
775
|
+
[promiseMarkerJson(bakedJson), promiseMarkerJson(targetJson)],
|
|
776
|
+
];
|
|
777
|
+
return rewriteIslandMarkup(rewritten, text => {
|
|
778
|
+
let next = text;
|
|
779
|
+
for (const [from, to] of replacements) {
|
|
780
|
+
next = next.split(from).join(to).split(escapeAttribute(from)).join(escapeAttribute(to));
|
|
781
|
+
}
|
|
782
|
+
return next;
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const islandParamsAttribute = (json: string) =>
|
|
787
|
+
`${ISLAND_PARAMS_ATTRIBUTE}="${json.replaceAll('"', '"')}"`;
|
|
788
|
+
const promiseMarkerJson = (json: string) => `{"${PROMISE_MARKER_KEY}":${json}}`;
|
|
789
|
+
const escapeAttribute = (json: string) => json.replaceAll('"', '"');
|
|
790
|
+
|
|
791
|
+
/** `serializeProps`' output shape, mirrored so the strings compare byte-wise. */
|
|
792
|
+
const JSON_ESCAPES: Record<string, string> = {
|
|
793
|
+
'<': '\\u003c',
|
|
794
|
+
'>': '\\u003e',
|
|
795
|
+
'&': '\\u0026',
|
|
796
|
+
'\u2028': '\\u2028',
|
|
797
|
+
'\u2029': '\\u2029',
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
function paramsJson(value: unknown): string {
|
|
801
|
+
return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, char => JSON_ESCAPES[char] ?? char);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Apply `rewrite` to the markup AND to the island markers the renderer emits for neutral islands - the
|
|
806
|
+
* same two-surface pass the server does, since an island's params live inside the marker until it
|
|
807
|
+
* materializes.
|
|
808
|
+
*/
|
|
809
|
+
function rewriteIslandMarkup(html: string, rewrite: (text: string) => string): string {
|
|
810
|
+
return rewrite(html).replace(
|
|
811
|
+
/<!--pnext-(client|client-after|page):([^>]*)-->/g,
|
|
812
|
+
(marker, kind: string, encoded: string) => {
|
|
813
|
+
const decoded = encoded.replaceAll('<', '<').replaceAll('>', '>');
|
|
814
|
+
const next = rewrite(decoded);
|
|
815
|
+
return next === decoded
|
|
816
|
+
? marker
|
|
817
|
+
: `<!--pnext-${kind}:${next.replaceAll('<', '<').replaceAll('>', '>')}-->`;
|
|
818
|
+
},
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function decodeSegment(segment: string): string {
|
|
823
|
+
try {
|
|
824
|
+
return decodeURIComponent(segment);
|
|
825
|
+
} catch {
|
|
826
|
+
// A malformed escape is the route-cache identity as written; keep it.
|
|
827
|
+
return segment;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// ---------------------------------------------------------------------------
|
|
832
|
+
// Eviction.
|
|
833
|
+
// ---------------------------------------------------------------------------
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* A same-URL navigation refreshes PAGE segments only: the layout/head segments of the current tree stay
|
|
837
|
+
* cached (Next's router.refresh semantics - the shared layout is not re-fetched). Everything below the
|
|
838
|
+
* page segment is dropped.
|
|
839
|
+
*/
|
|
840
|
+
export function evictPageSegments(): void {
|
|
841
|
+
for (const key of [...entries.keys()]) {
|
|
842
|
+
if (BODY_SEGMENT_PATHS.some(path => key.startsWith(path + VARY_KEY_SEPARATOR))) {
|
|
843
|
+
entries.delete(key);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// The vary sets learned for those page segments describe payloads that are
|
|
847
|
+
// gone; keeping them would leak one map per route across refreshes.
|
|
848
|
+
for (const path of BODY_SEGMENT_PATHS) learnedRoutes.delete(path);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Drop every entry (a revalidation invalidates the whole segment cache). */
|
|
852
|
+
export function clearSegmentCache(): void {
|
|
853
|
+
entries.clear();
|
|
854
|
+
learnedRoutes.clear();
|
|
855
|
+
resolvedRoutes.clear();
|
|
856
|
+
// A revalidation invalidates the layouts too: a `/_page`-only navigation
|
|
857
|
+
// after one would keep painting the pre-revalidation layout chain.
|
|
858
|
+
documentLayoutFrames.clear();
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** Test seam: the number of live entries. */
|
|
862
|
+
export function segmentEntryCount(): number {
|
|
863
|
+
return entries.size;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function touch(key: string): void {
|
|
867
|
+
const entry = entries.get(key);
|
|
868
|
+
if (!entry) return;
|
|
869
|
+
entries.delete(key);
|
|
870
|
+
entries.set(key, entry);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function trim(): void {
|
|
874
|
+
while (entries.size > SEGMENT_ENTRY_LIMIT) {
|
|
875
|
+
const oldest = entries.keys().next().value;
|
|
876
|
+
if (oldest === undefined) return;
|
|
877
|
+
entries.delete(oldest);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function touchLearnedRoute(segmentPath: string, learnedKey: string): void {
|
|
882
|
+
const byRoute = learnedRoutes.get(segmentPath);
|
|
883
|
+
const learned = byRoute?.get(learnedKey);
|
|
884
|
+
if (!byRoute || !learned) return;
|
|
885
|
+
byRoute.delete(learnedKey);
|
|
886
|
+
byRoute.set(learnedKey, learned);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function trimLearnedRoutes(byRoute: Map<string, LearnedRoute>): void {
|
|
890
|
+
while (byRoute.size > SEGMENT_ENTRY_LIMIT) {
|
|
891
|
+
const oldest = byRoute.keys().next().value;
|
|
892
|
+
if (oldest === undefined) return;
|
|
893
|
+
byRoute.delete(oldest);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* The route pattern the SERVER resolved `pathname` to (learned from a segment
|
|
899
|
+
* or document payload), or null when this exact pathname was never answered.
|
|
900
|
+
*
|
|
901
|
+
* The router's route-tree cache uses it as the static-sibling guard: a pathname
|
|
902
|
+
* the server resolved to its OWN route must never be predicted onto a dynamic
|
|
903
|
+
* sibling pattern.
|
|
904
|
+
*/
|
|
905
|
+
export function resolvedRouteFor(pathname: string): string | null {
|
|
906
|
+
return resolvedRoutes.get(pathname) ?? null;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Test seam: how many route/vary sets are remembered for a segment path. */
|
|
910
|
+
export function learnedRouteCount(segmentPath: string): number {
|
|
911
|
+
return learnedRoutes.get(segmentPath)?.size ?? 0;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ---------------------------------------------------------------------------
|
|
915
|
+
// Wire decoding.
|
|
916
|
+
// ---------------------------------------------------------------------------
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Decode a segment response body into its payload plus vary metadata. Returns null when the body is not
|
|
920
|
+
* the expected segment payload (a header-stripping proxy, a plain document, ...).
|
|
921
|
+
*/
|
|
922
|
+
export function decodeSegmentPayload(
|
|
923
|
+
body: string,
|
|
924
|
+
expectedSegment: string,
|
|
925
|
+
): (SegmentPayloadMeta & { html: string }) | null {
|
|
926
|
+
const json = body.startsWith('0:') ? body.slice(2) : body;
|
|
927
|
+
let payload: {
|
|
928
|
+
segment?: unknown;
|
|
929
|
+
html?: unknown;
|
|
930
|
+
vary?: unknown;
|
|
931
|
+
layoutVary?: unknown;
|
|
932
|
+
pageVary?: unknown;
|
|
933
|
+
route?: unknown;
|
|
934
|
+
params?: unknown;
|
|
935
|
+
shell?: unknown;
|
|
936
|
+
};
|
|
937
|
+
try {
|
|
938
|
+
payload = JSON.parse(json) as typeof payload;
|
|
939
|
+
} catch {
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
if (payload.segment !== expectedSegment || typeof payload.html !== 'string') return null;
|
|
943
|
+
const payloadSiblings = staticSiblingsFromPayloadHtml(payload.html);
|
|
944
|
+
return {
|
|
945
|
+
html: payload.html,
|
|
946
|
+
vary: Array.isArray(payload.vary) ? payload.vary.map(String) : null,
|
|
947
|
+
// Absent (not null) when the server did not split the response — the shape
|
|
948
|
+
// stays byte-identical to the pre-split payload for every such response.
|
|
949
|
+
...(Array.isArray(payload.layoutVary) ? { layoutVary: payload.layoutVary.map(String) } : {}),
|
|
950
|
+
...(Array.isArray(payload.pageVary) ? { pageVary: payload.pageVary.map(String) } : {}),
|
|
951
|
+
route: typeof payload.route === 'string' ? payload.route : null,
|
|
952
|
+
params: normalizeWireParams(payload.params),
|
|
953
|
+
...(payloadSiblings ? { staticSiblings: payloadSiblings } : {}),
|
|
954
|
+
...(appShellOf(payload.shell) ? { shell: appShellOf(payload.shell)! } : {}),
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** The `shell` field of a segment payload, when it carries a usable one. */
|
|
959
|
+
function appShellOf(value: unknown): { html: string; route: string } | null {
|
|
960
|
+
const shell = value as { html?: unknown; route?: unknown } | null | undefined;
|
|
961
|
+
if (!shell || typeof shell.html !== 'string' || typeof shell.route !== 'string') return null;
|
|
962
|
+
return { html: shell.html, route: shell.route };
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Decode the segment metadata of a whole HTML DOCUMENT - a navigation response or the initial
|
|
967
|
+
* hard-loaded page - rather than a framed segment payload.
|
|
968
|
+
*
|
|
969
|
+
* An ordinary document render carries no vary set (the server only tracks param access inside a
|
|
970
|
+
* segment-prefetch render), so the vary set is UNKNOWN and the entry keys on its exact URL. The route
|
|
971
|
+
* identity it does carry still matters: it teaches the sibling guard which route serves this pathname.
|
|
972
|
+
*
|
|
973
|
+
* A document resumed from a BAKED SHELL is the exception: the server publishes that shell's own vary
|
|
974
|
+
* set as `pageVary`, and that set IS the assertion the exact-URL fallback exists for want of. Without
|
|
975
|
+
* it a hard load of `/route/foo` seeds an entry no navigation to `/route/bar` can key onto.
|
|
976
|
+
*/
|
|
977
|
+
export function decodeDocumentSegmentMeta(html: string): SegmentPayloadMeta {
|
|
978
|
+
const state = documentRouteState(html);
|
|
979
|
+
if (!state) return { vary: null, route: null, params: {} };
|
|
980
|
+
const siblings = staticSiblingsOf(state);
|
|
981
|
+
return {
|
|
982
|
+
vary: Array.isArray(state.pageVary) ? state.pageVary.map(String) : null,
|
|
983
|
+
route: typeof state.route === 'string' ? state.route : null,
|
|
984
|
+
params: normalizeWireParams(state.params),
|
|
985
|
+
...(siblings ? { staticSiblings: siblings } : {}),
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
interface WireRouteState {
|
|
990
|
+
route?: unknown;
|
|
991
|
+
params?: unknown;
|
|
992
|
+
pageVary?: unknown;
|
|
993
|
+
staticChildren?: unknown;
|
|
994
|
+
staticChildrenBySegment?: unknown;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** The `window.__PNEXT_ROUTE__` state embedded in a rendered document. */
|
|
998
|
+
function documentRouteState(html: string): WireRouteState | null {
|
|
999
|
+
const match = /window\.__PNEXT_ROUTE__=(\{.*?\});?<\/script>/s.exec(html);
|
|
1000
|
+
if (!match?.[1]) return null;
|
|
1001
|
+
try {
|
|
1002
|
+
return JSON.parse(match[1]) as WireRouteState;
|
|
1003
|
+
} catch {
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* The literal siblings the server published for each dynamic level of the
|
|
1010
|
+
* route, keyed by segment index (`staticChildrenBySegment`, plus the legacy
|
|
1011
|
+
* deepest-level `staticChildren` at the pattern's last dynamic index).
|
|
1012
|
+
*/
|
|
1013
|
+
function staticSiblingsOf(state: WireRouteState): Record<string, string[]> | undefined {
|
|
1014
|
+
const siblings: Record<string, string[]> = {};
|
|
1015
|
+
const bySegment = state.staticChildrenBySegment;
|
|
1016
|
+
if (typeof bySegment === 'object' && bySegment !== null) {
|
|
1017
|
+
for (const [index, names] of Object.entries(bySegment as Record<string, unknown>)) {
|
|
1018
|
+
if (Array.isArray(names)) siblings[index] = names.map(String);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
if (Array.isArray(state.staticChildren) && typeof state.route === 'string') {
|
|
1022
|
+
const parts = state.route.split('/').filter(Boolean);
|
|
1023
|
+
const deepest = parts.reduce((last, part, index) => (part.startsWith(':') ? index : last), -1);
|
|
1024
|
+
if (deepest >= 0 && siblings[String(deepest)] === undefined) {
|
|
1025
|
+
siblings[String(deepest)] = state.staticChildren.map(String);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return Object.keys(siblings).length > 0 ? siblings : undefined;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/** A framed segment payload's static siblings, read off the HTML it carries. */
|
|
1032
|
+
function staticSiblingsFromPayloadHtml(html: string): Record<string, string[]> | undefined {
|
|
1033
|
+
const state = documentRouteState(html);
|
|
1034
|
+
return state ? staticSiblingsOf(state) : undefined;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* True when `pathname` names one of the learned route's STATIC SIBLINGS at one of its dynamic levels -
|
|
1039
|
+
* `/products/sale` beside `/products/:id`.
|
|
1040
|
+
*
|
|
1041
|
+
* The pattern match alone cannot tell the two apart: a dynamic segment matches any literal, so a
|
|
1042
|
+
* learned route's (param-shared) entry would otherwise serve the sibling's URL with the dynamic
|
|
1043
|
+
* route's bytes. The server publishes the literal siblings of every dynamic level alongside the route,
|
|
1044
|
+
* the same signal the optimistic predictor bails on. Only a POSITIVE match rejects: a route whose
|
|
1045
|
+
* siblings the server did not publish keeps sharing exactly as before.
|
|
1046
|
+
*/
|
|
1047
|
+
function namesStaticSibling(
|
|
1048
|
+
pathname: string,
|
|
1049
|
+
siblings: Record<string, string[]> | undefined,
|
|
1050
|
+
): boolean {
|
|
1051
|
+
if (!siblings) return false;
|
|
1052
|
+
const parts = pathname.split('/').filter(Boolean).map(decodeSegment);
|
|
1053
|
+
return Object.entries(siblings).some(([index, names]) => {
|
|
1054
|
+
const segment = parts[Number(index)];
|
|
1055
|
+
return segment !== undefined && names.includes(segment);
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function normalizeWireParams(value: unknown): Record<string, string | string[]> {
|
|
1060
|
+
if (typeof value !== 'object' || value === null) return {};
|
|
1061
|
+
const params: Record<string, string | string[]> = {};
|
|
1062
|
+
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
|
1063
|
+
if (Array.isArray(raw)) params[key] = (raw as unknown[]).map(stringifyParam);
|
|
1064
|
+
else if (raw !== undefined && raw !== null) params[key] = stringifyParam(raw);
|
|
1065
|
+
}
|
|
1066
|
+
return params;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/** Wire params are strings; anything else is coerced defensively. */
|
|
1070
|
+
function stringifyParam(value: unknown): string {
|
|
1071
|
+
return typeof value === 'string' ? value : JSON.stringify(value) ?? '';
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** True when a decoded vary set makes the segment query-dependent. */
|
|
1075
|
+
export function varyIncludesSearch(vary: readonly string[] | null): boolean {
|
|
1076
|
+
return vary?.includes(SEARCH_PARAMS_SENTINEL) === true;
|
|
1077
|
+
}
|