@rangojs/router 0.0.0-experimental.14 → 0.0.0-experimental.141
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/AGENTS.md +17 -0
- package/README.md +432 -7
- package/dist/bin/rango.js +2073 -213
- package/dist/testing/vitest.js +82 -0
- package/dist/vite/index.js +7258 -2714
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +140 -67
- package/skills/api-client/SKILL.md +211 -0
- package/skills/breadcrumbs/SKILL.md +329 -0
- package/skills/bundle-analysis/SKILL.md +159 -0
- package/skills/cache-guide/SKILL.md +487 -0
- package/skills/caching/SKILL.md +357 -25
- package/skills/comparison/SKILL.md +50 -0
- package/skills/comparison/agents/openai.yaml +4 -0
- package/skills/comparison/references/framework-comparison.md +837 -0
- package/skills/composability/SKILL.md +246 -0
- package/skills/css/SKILL.md +76 -0
- package/skills/debug-manifest/SKILL.md +16 -10
- package/skills/document-cache/SKILL.md +87 -62
- package/skills/fonts/SKILL.md +6 -4
- package/skills/handler-use/SKILL.md +364 -0
- package/skills/hooks/SKILL.md +557 -79
- package/skills/host-router/SKILL.md +320 -0
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +207 -15
- package/skills/layout/SKILL.md +146 -6
- package/skills/links/SKILL.md +304 -25
- package/skills/loader/SKILL.md +616 -54
- package/skills/middleware/SKILL.md +217 -37
- package/skills/migrate-nextjs/SKILL.md +611 -0
- package/skills/migrate-react-router/SKILL.md +927 -0
- package/skills/mime-routes/SKILL.md +42 -11
- package/skills/observability/SKILL.md +194 -0
- package/skills/parallel/SKILL.md +284 -3
- package/skills/ppr/SKILL.md +293 -0
- package/skills/prerender/SKILL.md +437 -52
- package/skills/rango/SKILL.md +369 -22
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/response-routes/SKILL.md +263 -121
- package/skills/route/SKILL.md +350 -21
- package/skills/router-setup/SKILL.md +246 -33
- package/skills/scripts/SKILL.md +179 -0
- package/skills/server-actions/SKILL.md +775 -0
- package/skills/shell-manifest/SKILL.md +185 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/tailwind/SKILL.md +27 -3
- package/skills/testing/SKILL.md +126 -222
- package/skills/testing/bindings.md +103 -0
- package/skills/testing/cache-prerender.md +127 -0
- package/skills/testing/client-components.md +124 -0
- package/skills/testing/e2e-parity.md +125 -0
- package/skills/testing/flight.md +91 -0
- package/skills/testing/handles.md +131 -0
- package/skills/testing/loader.md +128 -0
- package/skills/testing/middleware.md +99 -0
- package/skills/testing/render-handler.md +122 -0
- package/skills/testing/response-routes.md +95 -0
- package/skills/testing/reverse-and-types.md +85 -0
- package/skills/testing/server-actions.md +107 -0
- package/skills/testing/server-tree.md +128 -0
- package/skills/testing/setup.md +123 -0
- package/skills/theme/SKILL.md +9 -8
- package/skills/typesafety/SKILL.md +532 -103
- package/skills/use-cache/SKILL.md +367 -0
- package/skills/vercel/SKILL.md +128 -0
- package/skills/view-transitions/SKILL.md +337 -0
- package/src/__augment-tests__/augment.ts +81 -0
- package/src/__augment-tests__/augmented.check.ts +116 -0
- package/src/__internal.ts +77 -44
- package/src/bin/rango.ts +312 -15
- package/src/browser/action-coordinator.ts +114 -0
- package/src/browser/action-fence.ts +47 -0
- package/src/browser/app-shell.ts +39 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/connection-warmup.ts +134 -0
- package/src/browser/cookie-name.ts +140 -0
- package/src/browser/event-controller.ts +293 -202
- package/src/browser/history-state.ts +101 -0
- package/src/browser/index.ts +3 -3
- package/src/browser/intercept-utils.ts +52 -0
- package/src/browser/invalidate-client-cache.ts +52 -0
- package/src/browser/link-interceptor.ts +24 -4
- package/src/browser/logging.ts +11 -0
- package/src/browser/merge-segment-loaders.ts +20 -12
- package/src/browser/navigation-bridge.ts +385 -576
- package/src/browser/navigation-client.ts +245 -75
- package/src/browser/navigation-store-handle.ts +38 -0
- package/src/browser/navigation-store.ts +184 -118
- package/src/browser/navigation-transaction.ts +247 -0
- package/src/browser/network-error-handler.ts +88 -0
- package/src/browser/partial-update.ts +412 -364
- package/src/browser/prefetch/cache.ts +359 -0
- package/src/browser/prefetch/fetch.ts +452 -0
- package/src/browser/prefetch/observer.ts +65 -0
- package/src/browser/prefetch/policy.ts +48 -0
- package/src/browser/prefetch/queue.ts +209 -0
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/rango-state.ts +194 -0
- package/src/browser/react/Link.tsx +275 -68
- package/src/browser/react/NavigationProvider.tsx +265 -109
- package/src/browser/react/ScrollRestoration.tsx +10 -6
- package/src/browser/react/context.ts +11 -0
- package/src/browser/react/filter-segment-order.ts +70 -0
- package/src/browser/react/index.ts +0 -48
- package/src/browser/react/location-state-shared.ts +272 -60
- package/src/browser/react/location-state.ts +90 -20
- package/src/browser/react/mount-context.ts +6 -1
- package/src/browser/react/nonce-context.ts +23 -0
- package/src/browser/react/shallow-equal.ts +27 -0
- package/src/browser/react/use-action.ts +35 -66
- package/src/browser/react/use-handle.ts +39 -126
- package/src/browser/react/use-href.tsx +8 -1
- package/src/browser/react/use-link-status.ts +39 -13
- package/src/browser/react/use-navigation.ts +53 -69
- package/src/browser/react/use-params.ts +75 -0
- package/src/browser/react/use-pathname.ts +47 -0
- package/src/browser/react/use-reverse.ts +106 -0
- package/src/browser/react/use-router.ts +98 -0
- package/src/browser/react/use-search-params.ts +51 -0
- package/src/browser/react/use-segments.ts +72 -99
- package/src/browser/response-adapter.ts +164 -0
- package/src/browser/rsc-router.tsx +300 -72
- package/src/browser/scroll-restoration.ts +138 -50
- package/src/browser/segment-reconciler.ts +243 -0
- package/src/browser/segment-structure-assert.ts +17 -1
- package/src/browser/server-action-bridge.ts +668 -613
- package/src/browser/types.ts +223 -51
- package/src/browser/validate-redirect-origin.ts +56 -0
- package/src/build/collect-fallback-refs.ts +107 -0
- package/src/build/generate-manifest.ts +252 -161
- package/src/build/generate-route-types.ts +41 -1038
- package/src/build/index.ts +12 -7
- package/src/build/prefix-tree-utils.ts +123 -0
- package/src/build/route-trie.ts +225 -42
- package/src/build/route-types/ast-helpers.ts +25 -0
- package/src/build/route-types/ast-route-extraction.ts +105 -0
- package/src/build/route-types/codegen.ts +113 -0
- package/src/build/route-types/include-resolution.ts +812 -0
- package/src/build/route-types/param-extraction.ts +51 -0
- package/src/build/route-types/per-module-writer.ts +144 -0
- package/src/build/route-types/router-processing.ts +695 -0
- package/src/build/route-types/scan-filter.ts +85 -0
- package/src/build/route-types/source-scan.ts +216 -0
- package/src/build/runtime-discovery.ts +223 -0
- package/src/cache/background-task.ts +34 -0
- package/src/cache/cache-error.ts +104 -0
- package/src/cache/cache-key-utils.ts +60 -0
- package/src/cache/cache-policy.ts +199 -0
- package/src/cache/cache-runtime.ts +525 -0
- package/src/cache/cache-scope.ts +298 -332
- package/src/cache/cache-tag.ts +103 -0
- package/src/cache/cf/cf-base64.ts +33 -0
- package/src/cache/cf/cf-cache-constants.ts +127 -0
- package/src/cache/cf/cf-cache-store.ts +2508 -158
- package/src/cache/cf/cf-cache-types.ts +349 -0
- package/src/cache/cf/cf-kv-utils.ts +46 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
- package/src/cache/cf/index.ts +17 -17
- package/src/cache/document-cache.ts +199 -92
- package/src/cache/handle-capture.ts +81 -0
- package/src/cache/handle-snapshot.ts +111 -0
- package/src/cache/index.ts +24 -35
- package/src/cache/memory-segment-store.ts +363 -30
- package/src/cache/profile-registry.ts +88 -0
- package/src/cache/read-through-swr.ts +178 -0
- package/src/cache/segment-codec.ts +248 -0
- package/src/cache/shell-snapshot.ts +368 -0
- package/src/cache/tag-invalidation.ts +230 -0
- package/src/cache/taint.ts +153 -0
- package/src/cache/types.ts +222 -211
- package/src/cache/vercel/index.ts +11 -0
- package/src/cache/vercel/vercel-cache-store.ts +1113 -0
- package/src/client.rsc.tsx +43 -21
- package/src/client.tsx +131 -347
- package/src/cloudflare/index.ts +11 -0
- package/src/cloudflare/tracing.ts +109 -0
- package/src/component-utils.ts +23 -4
- package/src/components/DefaultDocument.tsx +13 -3
- package/src/context-var.ts +168 -0
- package/src/debug.ts +19 -9
- package/src/decode-loader-results.ts +52 -0
- package/src/defer.ts +185 -0
- package/src/deps/ssr.ts +0 -1
- package/src/encode-kv.ts +49 -0
- package/src/errors.ts +106 -10
- package/src/escape-script.ts +52 -0
- package/src/handle.ts +110 -35
- package/src/handles/MetaTags.tsx +83 -59
- package/src/handles/Scripts.tsx +183 -0
- package/src/handles/breadcrumbs.ts +93 -0
- package/src/handles/deferred-resolution.ts +127 -0
- package/src/handles/is-thenable.ts +18 -0
- package/src/handles/meta.ts +44 -53
- package/src/handles/script.ts +244 -0
- package/src/host/cookie-handler.ts +20 -65
- package/src/host/errors.ts +21 -30
- package/src/host/index.ts +13 -9
- package/src/host/pattern-matcher.ts +50 -79
- package/src/host/router.ts +151 -121
- package/src/host/testing.ts +45 -32
- package/src/host/types.ts +52 -11
- package/src/host/utils.ts +2 -2
- package/src/href-client.ts +192 -57
- package/src/index.rsc.ts +173 -35
- package/src/index.ts +241 -73
- package/src/internal-debug.ts +9 -2
- package/src/loader-store.ts +500 -0
- package/src/loader.rsc.ts +31 -99
- package/src/loader.ts +30 -12
- package/src/missing-id-error.ts +68 -0
- package/src/outlet-context.ts +1 -1
- package/src/outlet-provider.tsx +41 -0
- package/src/prerender/param-hash.ts +16 -14
- package/src/prerender/store.ts +121 -21
- package/src/prerender.ts +460 -26
- package/src/redirect-origin.ts +100 -0
- package/src/regex-escape.ts +8 -0
- package/src/render-error-thrower.tsx +20 -0
- package/src/response-utils.ts +62 -0
- package/src/reverse.ts +198 -128
- package/src/root-error-boundary.tsx +42 -48
- package/src/route-content-wrapper.tsx +22 -77
- package/src/route-definition/dsl-helpers.ts +1116 -0
- package/src/route-definition/helper-factories.ts +88 -0
- package/src/route-definition/helpers-types.ts +505 -0
- package/src/route-definition/index.ts +54 -0
- package/src/route-definition/redirect.ts +134 -0
- package/src/route-definition/resolve-handler-use.ts +160 -0
- package/src/route-definition/use-item-types.ts +29 -0
- package/src/route-definition.ts +1 -1481
- package/src/route-map-builder.ts +82 -144
- package/src/route-name.ts +53 -0
- package/src/route-types.ts +71 -45
- package/src/router/basename.ts +14 -0
- package/src/router/content-negotiation.ts +263 -0
- package/src/router/debug-manifest.ts +72 -0
- package/src/router/error-handling.ts +54 -27
- package/src/router/find-match.ts +245 -0
- package/src/router/handler-context.ts +377 -125
- package/src/router/instrument.ts +350 -0
- package/src/router/intercept-resolution.ts +59 -28
- package/src/router/lazy-includes.ts +254 -0
- package/src/router/loader-resolution.ts +421 -157
- package/src/router/logging.ts +106 -6
- package/src/router/manifest.ts +131 -57
- package/src/router/match-api.ts +167 -246
- package/src/router/match-context.ts +4 -24
- package/src/router/match-handlers.ts +440 -0
- package/src/router/match-middleware/background-revalidation.ts +117 -93
- package/src/router/match-middleware/cache-lookup.ts +297 -150
- package/src/router/match-middleware/cache-store.ts +123 -51
- package/src/router/match-middleware/intercept-resolution.ts +44 -43
- package/src/router/match-middleware/segment-resolution.ts +64 -22
- package/src/router/match-pipelines.ts +11 -87
- package/src/router/match-result.ts +121 -50
- package/src/router/metrics.ts +219 -28
- package/src/router/middleware-types.ts +93 -0
- package/src/router/middleware.ts +505 -441
- package/src/router/navigation-snapshot.ts +133 -0
- package/src/router/params-util.ts +23 -0
- package/src/router/parse-pattern.ts +115 -0
- package/src/router/pattern-matching.ts +311 -142
- package/src/router/prefetch-cache-ttl.ts +51 -0
- package/src/router/prefetch-limits.ts +37 -0
- package/src/router/prerender-match.ts +547 -0
- package/src/router/preview-match.ts +102 -0
- package/src/router/request-classification.ts +278 -0
- package/src/router/revalidation.ts +203 -62
- package/src/router/route-snapshot.ts +246 -0
- package/src/router/router-context.ts +45 -48
- package/src/router/router-interfaces.ts +554 -0
- package/src/router/router-options.ts +779 -0
- package/src/router/router-registry.ts +21 -0
- package/src/router/segment-resolution/fresh.ts +772 -0
- package/src/router/segment-resolution/helpers.ts +348 -0
- package/src/router/segment-resolution/loader-cache.ts +250 -0
- package/src/router/segment-resolution/loader-mask.ts +44 -0
- package/src/router/segment-resolution/revalidation.ts +1331 -0
- package/src/router/segment-resolution/static-store.ts +81 -0
- package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
- package/src/router/segment-resolution/view-transition-default.ts +56 -0
- package/src/router/segment-resolution.ts +25 -1354
- package/src/router/segment-wrappers.ts +292 -0
- package/src/router/state-cookie-name.ts +33 -0
- package/src/router/substitute-pattern-params.ts +75 -0
- package/src/router/telemetry-otel.ts +261 -0
- package/src/router/telemetry.ts +377 -0
- package/src/router/timeout.ts +128 -0
- package/src/router/tracing.ts +206 -0
- package/src/router/trie-matching.ts +240 -61
- package/src/router/types.ts +23 -70
- package/src/router/url-params.ts +57 -0
- package/src/router.ts +781 -2378
- package/src/rsc/full-payload.ts +70 -0
- package/src/rsc/handler-context.ts +46 -0
- package/src/rsc/handler.ts +905 -1142
- package/src/rsc/helpers.ts +275 -19
- package/src/rsc/index.ts +2 -25
- package/src/rsc/json-route-result.ts +38 -0
- package/src/rsc/loader-fetch.ts +305 -0
- package/src/rsc/manifest-init.ts +77 -0
- package/src/rsc/nonce.ts +14 -0
- package/src/rsc/origin-guard.ts +155 -0
- package/src/rsc/progressive-enhancement.ts +502 -0
- package/src/rsc/redirect-guard.ts +99 -0
- package/src/rsc/response-cache-serve.ts +238 -0
- package/src/rsc/response-error.ts +104 -0
- package/src/rsc/response-route-handler.ts +257 -0
- package/src/rsc/rsc-rendering.ts +527 -0
- package/src/rsc/runtime-warnings.ts +55 -0
- package/src/rsc/server-action.ts +522 -0
- package/src/rsc/shell-capture.ts +897 -0
- package/src/rsc/shell-serve.ts +124 -0
- package/src/rsc/ssr-setup.ts +144 -0
- package/src/rsc/transition-gate.ts +89 -0
- package/src/rsc/types.ts +95 -12
- package/src/runtime-env.ts +18 -0
- package/src/search-params.ts +99 -82
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +149 -0
- package/src/segment-system.tsx +349 -134
- package/src/serialize.ts +243 -0
- package/src/server/context.ts +459 -85
- package/src/server/cookie-parse.ts +32 -0
- package/src/server/cookie-store.ts +310 -0
- package/src/server/fetchable-loader-store.ts +11 -6
- package/src/server/handle-store.ts +123 -42
- package/src/server/loader-registry.ts +51 -100
- package/src/server/request-context.ts +848 -157
- package/src/server.ts +15 -8
- package/src/ssr/index.tsx +443 -135
- package/src/ssr/ssr-root.tsx +228 -0
- package/src/static-handler.ts +45 -18
- package/src/testing/cache-status.ts +162 -0
- package/src/testing/collect-handle.ts +46 -0
- package/src/testing/dispatch.ts +701 -0
- package/src/testing/dom.entry.ts +22 -0
- package/src/testing/e2e/fixture.ts +188 -0
- package/src/testing/e2e/index.ts +128 -0
- package/src/testing/e2e/matchers.ts +35 -0
- package/src/testing/e2e/page-helpers.ts +272 -0
- package/src/testing/e2e/parity.ts +387 -0
- package/src/testing/e2e/server.ts +195 -0
- package/src/testing/flight-matchers.ts +97 -0
- package/src/testing/flight-normalize.ts +11 -0
- package/src/testing/flight-runtime.d.ts +57 -0
- package/src/testing/flight-tree.ts +682 -0
- package/src/testing/flight.entry.ts +52 -0
- package/src/testing/flight.ts +257 -0
- package/src/testing/generated-routes.ts +199 -0
- package/src/testing/index.ts +105 -0
- package/src/testing/internal/context.ts +371 -0
- package/src/testing/internal/flight-client-globals.ts +30 -0
- package/src/testing/internal/seed-vars.ts +54 -0
- package/src/testing/render-handler.ts +357 -0
- package/src/testing/render-route.tsx +584 -0
- package/src/testing/run-loader.ts +385 -0
- package/src/testing/run-middleware.ts +205 -0
- package/src/testing/run-transition-when.ts +164 -0
- package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
- package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
- package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
- package/src/testing/vitest-stubs/version.ts +5 -0
- package/src/testing/vitest.ts +305 -0
- package/src/theme/ThemeProvider.tsx +76 -98
- package/src/theme/ThemeScript.tsx +12 -14
- package/src/theme/constants.ts +57 -15
- package/src/theme/index.ts +3 -20
- package/src/theme/theme-context.ts +5 -35
- package/src/theme/theme-script.ts +43 -39
- package/src/theme/use-theme.ts +0 -3
- package/src/types/boundaries.ts +123 -0
- package/src/types/cache-types.ts +207 -0
- package/src/types/error-types.ts +132 -0
- package/src/types/global-namespace.ts +113 -0
- package/src/types/handler-context.ts +839 -0
- package/src/types/index.ts +81 -0
- package/src/types/loader-types.ts +212 -0
- package/src/types/request-scope.ts +112 -0
- package/src/types/route-config.ts +138 -0
- package/src/types/route-entry.ts +114 -0
- package/src/types/segments.ts +271 -0
- package/src/types.ts +1 -1795
- package/src/urls/include-helper.ts +162 -0
- package/src/urls/include-provider.ts +71 -0
- package/src/urls/index.ts +44 -0
- package/src/urls/path-helper-types.ts +413 -0
- package/src/urls/path-helper.ts +280 -0
- package/src/urls/pattern-types.ts +160 -0
- package/src/urls/response-types.ts +109 -0
- package/src/urls/type-extraction.ts +316 -0
- package/src/urls/urls-function.ts +80 -0
- package/src/urls.ts +1 -1341
- package/src/use-loader.tsx +406 -141
- package/src/vercel/index.ts +11 -0
- package/src/vercel/tracing.ts +88 -0
- package/src/vite/debug.ts +185 -0
- package/src/vite/discovery/bundle-postprocess.ts +182 -0
- package/src/vite/discovery/discover-routers.ts +389 -0
- package/src/vite/discovery/discovery-errors.ts +255 -0
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +467 -0
- package/src/vite/discovery/route-types-writer.ts +214 -0
- package/src/vite/discovery/self-gen-tracking.ts +73 -0
- package/src/vite/discovery/state.ts +161 -0
- package/src/vite/discovery/virtual-module-codegen.ts +183 -0
- package/src/vite/index.ts +23 -2255
- package/src/vite/inject-client-debug.ts +36 -0
- package/src/vite/plugin-types.ts +303 -0
- package/src/vite/plugins/cjs-to-esm.ts +90 -0
- package/src/vite/plugins/client-ref-dedup.ts +120 -0
- package/src/vite/plugins/client-ref-hashing.ts +118 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +194 -0
- package/src/vite/{expose-action-id.ts → plugins/expose-action-id.ts} +88 -110
- package/src/vite/{expose-id-utils.ts → plugins/expose-id-utils.ts} +89 -79
- package/src/vite/plugins/expose-ids/export-analysis.ts +363 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +130 -0
- package/src/vite/plugins/expose-ids/loader-transform.ts +64 -0
- package/src/vite/plugins/expose-ids/router-transform.ts +199 -0
- package/src/vite/plugins/expose-ids/types.ts +45 -0
- package/src/vite/plugins/expose-internal-ids.ts +805 -0
- package/src/vite/plugins/performance-tracks.ts +89 -0
- package/src/vite/plugins/refresh-cmd.ts +127 -0
- package/src/vite/plugins/use-cache-transform.ts +313 -0
- package/src/vite/plugins/vercel-output.ts +384 -0
- package/src/vite/plugins/version-injector.ts +94 -0
- package/src/vite/plugins/version-plugin.ts +263 -0
- package/src/vite/plugins/virtual-entries.ts +234 -0
- package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
- package/src/vite/rango.ts +560 -0
- package/src/vite/router-discovery.ts +1638 -0
- package/src/vite/{ast-handler-extract.ts → utils/ast-handler-extract.ts} +200 -37
- package/src/vite/utils/banner.ts +36 -0
- package/src/vite/utils/bundle-analysis.ts +132 -0
- package/src/vite/utils/client-chunks.ts +184 -0
- package/src/vite/utils/directive-prologue.ts +40 -0
- package/src/vite/utils/forward-user-plugins.ts +171 -0
- package/src/vite/utils/manifest-utils.ts +15 -0
- package/src/vite/utils/package-resolution.ts +89 -0
- package/src/vite/utils/prerender-utils.ts +249 -0
- package/src/vite/utils/shared-utils.ts +269 -0
- package/CLAUDE.md +0 -43
- package/dist/vite/index.named-routes.gen.ts +0 -103
- package/src/browser/lru-cache.ts +0 -69
- package/src/browser/react/use-client-cache.ts +0 -56
- package/src/browser/request-controller.ts +0 -164
- package/src/browser/shallow.ts +0 -35
- package/src/cache/memory-store.ts +0 -253
- package/src/handles/index.ts +0 -6
- package/src/href-context.ts +0 -33
- package/src/network-error-thrower.tsx +0 -21
- package/src/router.gen.ts +0 -6
- package/src/static-handler.gen.ts +0 -5
- package/src/urls.gen.ts +0 -8
- package/src/vite/expose-internal-ids.ts +0 -1167
- package/src/vite/package-resolution.ts +0 -125
- package/src/vite/virtual-entries.ts +0 -114
- /package/src/vite/{version.d.ts → plugins/version.d.ts} +0 -0
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PPR shell capture orchestration (Axis 2, see docs/design/ppr-shell-resume.md).
|
|
3
|
+
*
|
|
4
|
+
* Capture does NOT flow through the HTTP middleware pipeline. The integrated PPR
|
|
5
|
+
* serve path (rsc-rendering.ts + shell-serve.ts) builds a ShellCaptureDescriptor
|
|
6
|
+
* from the route's `ppr` path option after the served response is built and calls
|
|
7
|
+
* scheduleShellCapture. The capture then runs as a background task that re-derives
|
|
8
|
+
* the page via `ctx.router.match()` under its OWN derived request context — fresh
|
|
9
|
+
* handle store, `_shellCaptureRun: true` so loaders mask (loader-mask.ts) and every
|
|
10
|
+
* loading() subtree postpones. The render is MIXED-CHAIN: cache()'d segments replay
|
|
11
|
+
* from ring 3, uncached segments execute their handlers fresh. It drives the static
|
|
12
|
+
* prerender to a quiescent shell, aborts to freeze the prelude + postponed state,
|
|
13
|
+
* and stores the pair via putShell. Because it uses match() rather than the HTTP
|
|
14
|
+
* pipeline, the middleware chain (auth, logging) never re-runs — it already ran for
|
|
15
|
+
* the triggering request, and the derived context inherits its post-middleware
|
|
16
|
+
* state (variables, cache store). Guarding is serve-time.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import React from "react";
|
|
20
|
+
import { bufferToBase64 } from "../cache/cf/cf-base64.js";
|
|
21
|
+
import { reportCacheError } from "../cache/cache-error.js";
|
|
22
|
+
import { runBackground } from "../cache/background-task.js";
|
|
23
|
+
import { observePhase, PHASES } from "../router/instrument.js";
|
|
24
|
+
import {
|
|
25
|
+
runWithRequestContext,
|
|
26
|
+
setRequestContextParams,
|
|
27
|
+
UNTRACKED_BACKGROUND_TASK,
|
|
28
|
+
type RequestContext,
|
|
29
|
+
} from "../server/request-context.js";
|
|
30
|
+
import { createHandleStore, type HandleStore } from "../server/handle-store.js";
|
|
31
|
+
import type {
|
|
32
|
+
ShellCacheEntry,
|
|
33
|
+
SegmentCacheStore,
|
|
34
|
+
ShellSnapshotRecord,
|
|
35
|
+
} from "../cache/types.js";
|
|
36
|
+
import {
|
|
37
|
+
RecordingShellStore,
|
|
38
|
+
getRecordingStore,
|
|
39
|
+
} from "../cache/shell-snapshot.js";
|
|
40
|
+
import type { HandlerContext } from "./handler-context.js";
|
|
41
|
+
import type { RscPayload, SSRModule } from "./types.js";
|
|
42
|
+
import { buildFullPayload } from "./full-payload.js";
|
|
43
|
+
import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Task-quantized quiesce: the number of consecutive macrotask hops with zero new
|
|
47
|
+
* Flight bytes that marks the shell "quiet". This replaces the old 50ms
|
|
48
|
+
* wall-clock debounce.
|
|
49
|
+
*
|
|
50
|
+
* The capture Flight render is a REGULAR renderToReadableStream (not a static
|
|
51
|
+
* prerender), so React schedules both its retries and its byte-flush on
|
|
52
|
+
* setTimeout(0) MACROTASKS (verified against the vendored edge production
|
|
53
|
+
* react-server-dom build: pingTask uses scheduleMicrotask only when
|
|
54
|
+
* request.type === PRERENDER, otherwise setTimeout; enqueueFlush is always
|
|
55
|
+
* setTimeout). Masked loaders are the live lane — their rows never emit — so once
|
|
56
|
+
* the shell rows finish flushing the stream goes permanently byte-silent, and K
|
|
57
|
+
* consecutive quiet macrotask hops after the last observed byte declare quiesce.
|
|
58
|
+
*
|
|
59
|
+
* K=2 gives a race window of ~two event-loop turns: shell work still producing
|
|
60
|
+
* bytes keeps resetting the counter; anything not producing bytes within the
|
|
61
|
+
* window (the masked loaders, and any genuinely pending I/O) becomes a hole. The
|
|
62
|
+
* only residual is raw per-request I/O rendered directly in shell (not via a
|
|
63
|
+
* loader) that resolves inside the window — a documented shell anti-pattern; put
|
|
64
|
+
* per-request data in loaders. See docs/design/ppr-shell-resume.md.
|
|
65
|
+
*/
|
|
66
|
+
const FLIGHT_QUIET_HOPS = 2;
|
|
67
|
+
|
|
68
|
+
/** Default upper bound on the capture prerender wait before forcing the abort. */
|
|
69
|
+
const SHELL_CAPTURE_MAX_WAIT_MS = 5000;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Upper bound on waiting for the capture's DEFERRED cache writes to settle before
|
|
73
|
+
* draining the snapshot. Cache writes run under waitUntil (fire-and-forget on
|
|
74
|
+
* Node, executionContext on workerd), so a MISS-at-capture value's setItem/set —
|
|
75
|
+
* hence its snapshot record — can land after the shell has quiesced. We collect
|
|
76
|
+
* those write promises and await them here so the written value is pinned. Kept
|
|
77
|
+
* short: a pathological slow write must never stall the background capture; a key
|
|
78
|
+
* that does not settle in time is simply left unpinned (it drifts, the
|
|
79
|
+
* pre-snapshot behavior) rather than hanging. Reads that HIT are recorded
|
|
80
|
+
* synchronously during the render and do not depend on this.
|
|
81
|
+
*/
|
|
82
|
+
const SHELL_SNAPSHOT_WRITE_SETTLE_MS = 1000;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Upper bound on the pre-render WRITE BARRIER: before the capture's match/render,
|
|
86
|
+
* settle the background tasks the FOREGROUND request already scheduled — its
|
|
87
|
+
* deferred ring-3 cacheRoute and ring-1 setItem writes all go through
|
|
88
|
+
* reqCtx.waitUntil, and every one of them is scheduled BEFORE scheduleShellCapture
|
|
89
|
+
* runs (the response, and its onResponse callbacks, are committed first). Draining
|
|
90
|
+
* them turns the capture's cache reads from a RACE into an ORDERING EDGE: the
|
|
91
|
+
* capture deterministically observes the foreground's cache generation, replays it
|
|
92
|
+
* (handler skipped, module-level side effects untouched), and records THAT
|
|
93
|
+
* generation into the snapshot — so prelude, snapshot, and ring-3 all agree on the
|
|
94
|
+
* foreground's generation and the capture can never clobber a foreground-produced
|
|
95
|
+
* entry with a re-render of its own. Scar tissue: without this, the capture's
|
|
96
|
+
* ring-3 lookup could land between the foreground write chain's serialization and
|
|
97
|
+
* its store.set, MISS, re-execute the route handler (bumping module-level
|
|
98
|
+
* counters), and — via the synthetic onResponse fire below — overwrite the
|
|
99
|
+
* foreground's entry (the mini shell-manifest regression). Bounded: a slow
|
|
100
|
+
* consumer waitUntil task must never stall the background capture; on timeout the
|
|
101
|
+
* capture proceeds with the pre-barrier (racy) behavior.
|
|
102
|
+
*/
|
|
103
|
+
const SHELL_CAPTURE_WRITE_BARRIER_MS = 1500;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Settle the tracked background tasks on `reqCtx._pendingBackgroundTasks`,
|
|
107
|
+
* ITERATIVELY: a settled task can have scheduled a nested one (cache-store's
|
|
108
|
+
* cacheRoute outer task schedules the actual store.set in a second waitUntil), so
|
|
109
|
+
* each awaited batch may append more. Loop until no new tasks appear or the
|
|
110
|
+
* deadline passes. The capture's own task never enters the list
|
|
111
|
+
* (UNTRACKED_BACKGROUND_TASK), so the loop terminates.
|
|
112
|
+
*/
|
|
113
|
+
async function settleTrackedBackgroundTasks(
|
|
114
|
+
reqCtx: RequestContext<any>,
|
|
115
|
+
timeoutMs: number,
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
const tasks = reqCtx._pendingBackgroundTasks;
|
|
118
|
+
if (!tasks) return;
|
|
119
|
+
const deadline = Date.now() + timeoutMs;
|
|
120
|
+
let seen = 0;
|
|
121
|
+
while (tasks.length > seen) {
|
|
122
|
+
const remaining = deadline - Date.now();
|
|
123
|
+
if (remaining <= 0) return;
|
|
124
|
+
const batch = tasks.slice(seen);
|
|
125
|
+
seen = tasks.length;
|
|
126
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
127
|
+
const guard = new Promise<void>((resolve) => {
|
|
128
|
+
timer = setTimeout(resolve, remaining);
|
|
129
|
+
(timer as { unref?: () => void }).unref?.();
|
|
130
|
+
});
|
|
131
|
+
await Promise.race([Promise.allSettled(batch).then(() => {}), guard]);
|
|
132
|
+
if (timer) clearTimeout(timer);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Delay before the in-place retry of a capture that produced no usable shell.
|
|
138
|
+
*
|
|
139
|
+
* The dominant reason a first capture comes back with a trivial prelude is a
|
|
140
|
+
* COLD render: in dev the module transform graph (route modules, the SSR/Flight
|
|
141
|
+
* transforms) is being built lazily and outlasts the task-quantized quiesce, so
|
|
142
|
+
* the shell has not finished rendering when we freeze it; on a cold worker the
|
|
143
|
+
* first invocation pays the same one-time cost. The first attempt WARMS that
|
|
144
|
+
* graph, so a second attempt a short beat later usually completes the shell in
|
|
145
|
+
* the SAME background task — no extra HTTP request needed. Short enough to feel
|
|
146
|
+
* instant, long enough for the module graph to settle. See
|
|
147
|
+
* docs/design/ppr-shell-resume.md ("Capture retry-in-place").
|
|
148
|
+
*/
|
|
149
|
+
const SHELL_CAPTURE_RETRY_DELAY_MS = 400;
|
|
150
|
+
|
|
151
|
+
/** Sleep `ms`, unref'd so a Node dev process is never kept alive by the timer. */
|
|
152
|
+
function delay(ms: number): Promise<void> {
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
const t = setTimeout(resolve, ms);
|
|
155
|
+
(t as { unref?: () => void }).unref?.();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Module-level in-flight key set: the stampede guard for background captures, and
|
|
161
|
+
* its single owner. One capture runs per key per isolate; concurrent MISS/stale
|
|
162
|
+
* requests for the same key coalesce onto the first (the rest see the key present
|
|
163
|
+
* in scheduleShellCapture and skip). Added when a capture is scheduled and cleared
|
|
164
|
+
* in the task's finally once it settles, so a later request can recapture when TTL
|
|
165
|
+
* rolls. Living here (not split across the middleware) keeps the add/clear
|
|
166
|
+
* lifecycle in one layer.
|
|
167
|
+
*/
|
|
168
|
+
const inFlightCaptures = new Set<string>();
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Refused-capture backoff bounds. The window is EXPONENTIAL in the consecutive
|
|
172
|
+
* failure count: `min(BASE * 2^(failures-1), MAX)` — 1s, 2s, 4s, … capped at 60s.
|
|
173
|
+
*
|
|
174
|
+
* Why exponential and not a flat 60s: a flat long window conflates two very
|
|
175
|
+
* different failures. A STRUCTURALLY ineligible route (no loading(), a cookie
|
|
176
|
+
* reader) fails forever and wants the long 60s cap. But a cold-but-ELIGIBLE route
|
|
177
|
+
* can also fail the in-place retry under a truly cold graph (dev module transform,
|
|
178
|
+
* or a cold worker under parallel load) — and it must recover FAST, on the next
|
|
179
|
+
* request or two, not be frozen for 60s (that would re-break the very cold-start DX
|
|
180
|
+
* the retry fixes; it bit the cloudflare dev e2e). Escalating from 1s means the
|
|
181
|
+
* eligible route re-probes almost immediately (warm now → HIT and clear), while the
|
|
182
|
+
* doomed route ramps to the 60s cap within a handful of failures. Either way an
|
|
183
|
+
* app-wide mount never re-renders a doomed route on EVERY request.
|
|
184
|
+
*/
|
|
185
|
+
const REFUSED_CAPTURE_BASE_MS = 1_000;
|
|
186
|
+
const REFUSED_CAPTURE_MAX_MS = 60_000;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Refused-capture backoff: key -> { consecutive failure count, epoch ms until which
|
|
190
|
+
* the key is not re-probed }. A key enters backoff only after runShellCapture's
|
|
191
|
+
* in-place retry ALSO failed (or a genuine error). A successful capture clears the
|
|
192
|
+
* entry outright (failure count resets). Module-level (same lifetime as
|
|
193
|
+
* inFlightCaptures) so the whole lifecycle lives in one layer.
|
|
194
|
+
*/
|
|
195
|
+
const refusedCaptures = new Map<string, { failures: number; until: number }>();
|
|
196
|
+
|
|
197
|
+
/** True iff `key` is still inside its (exponential) backoff window. */
|
|
198
|
+
function isCaptureBackedOff(key: string): boolean {
|
|
199
|
+
const entry = refusedCaptures.get(key);
|
|
200
|
+
if (entry === undefined) return false;
|
|
201
|
+
// Window elapsed: allow a re-probe. Keep the entry (its failure count drives the
|
|
202
|
+
// NEXT window's escalation if the re-probe also fails); a success clears it.
|
|
203
|
+
return Date.now() < entry.until;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Record a refused/failed capture, escalating the backoff window exponentially. */
|
|
207
|
+
function markCaptureBackoff(key: string): void {
|
|
208
|
+
const failures = (refusedCaptures.get(key)?.failures ?? 0) + 1;
|
|
209
|
+
const window = Math.min(
|
|
210
|
+
REFUSED_CAPTURE_BASE_MS * 2 ** (failures - 1),
|
|
211
|
+
REFUSED_CAPTURE_MAX_MS,
|
|
212
|
+
);
|
|
213
|
+
refusedCaptures.set(key, { failures, until: Date.now() + window });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Clear any backoff for a key that just captured successfully. */
|
|
217
|
+
function clearCaptureBackoff(key: string): void {
|
|
218
|
+
refusedCaptures.delete(key);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Keys already warned about a refused (null) capture, so the eternal-MISS shape
|
|
223
|
+
* logs once per key per isolate instead of on every request.
|
|
224
|
+
*/
|
|
225
|
+
const warnedNullCaptures = new Set<string>();
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Warn once per key that a capture produced no usable shell EVEN AFTER the
|
|
229
|
+
* in-place retry (runShellCapture attempt 2). Naming both causes with the
|
|
230
|
+
* distinguishing signal — does the route ever flip to HIT — is the whole point:
|
|
231
|
+
* the pre-retry version blamed "a loader route without loading()" unconditionally
|
|
232
|
+
* and misled users whose route DID have loading() and was merely cold. Because the
|
|
233
|
+
* retry already absorbs the cold-start case, by the time this fires cold-start has
|
|
234
|
+
* usually healed, so a firing warning leans toward the structural cause — but we
|
|
235
|
+
* still name both so a cold-start straggler is not misdiagnosed.
|
|
236
|
+
*
|
|
237
|
+
* The pointer is shipped-path-safe (a05c8251 convention): the /ppr skill ships in
|
|
238
|
+
* the npm tarball, but docs/design/ is repo-only, so link it by absolute GitHub URL
|
|
239
|
+
* rather than a relative path that dead-ends for consumers.
|
|
240
|
+
*/
|
|
241
|
+
function warnNullCaptureOnce(key: string): void {
|
|
242
|
+
if (warnedNullCaptures.has(key)) return;
|
|
243
|
+
warnedNullCaptures.add(key);
|
|
244
|
+
console.warn(
|
|
245
|
+
`[rango] Shell capture for "${key}" produced no usable shell after an in-place ` +
|
|
246
|
+
"retry; nothing was stored, so this request stays on MISS. Two things cause this, " +
|
|
247
|
+
"told apart by whether the route ever flips to HIT:\n" +
|
|
248
|
+
" 1. Cold-start warmup (dev module transform, or a cold worker): the capture raced " +
|
|
249
|
+
"an unfinished shell render. This SELF-HEALS — the route flips to HIT once a later " +
|
|
250
|
+
"request warms the modules. Usually nothing to do.\n" +
|
|
251
|
+
" 2. A loader route WITHOUT a route-level loading() boundary: its loader data is " +
|
|
252
|
+
"awaited at tree-build, so under capture's masked loaders no shell exists above " +
|
|
253
|
+
"<body>, and the route NEVER flips to HIT. Add loading() to the loader route (and " +
|
|
254
|
+
"keep shell material in a layout) to make it PPR-capturable.\n" +
|
|
255
|
+
'See the /ppr skill (node_modules/@rangojs/router/skills/ppr/SKILL.md), "The hole ' +
|
|
256
|
+
'contract", or the design doc: ' +
|
|
257
|
+
"https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/design/ppr-shell-resume.md",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface FlightCaptureGate {
|
|
262
|
+
/** Identity passthrough of the source stream; feed this to captureShellHTML. */
|
|
263
|
+
stream: ReadableStream<Uint8Array>;
|
|
264
|
+
/**
|
|
265
|
+
* Resolves once the source has been byte-quiet for FLIGHT_QUIET_HOPS macrotask
|
|
266
|
+
* hops (or has closed — the DATA variant). At that instant the gate FREEZES:
|
|
267
|
+
* no further source byte reaches the fizz side, and the readable is left open
|
|
268
|
+
* (never closed / errored) so fizz postpones the still-pending references
|
|
269
|
+
* instead of seeing "Connection closed".
|
|
270
|
+
*/
|
|
271
|
+
quiesce: Promise<void>;
|
|
272
|
+
/**
|
|
273
|
+
* Stop the internal macrotask-hop loop. captureShellHTML's maxWaitMs bounds the
|
|
274
|
+
* overall wait; dispose() is the clean shutdown for the pathological case where
|
|
275
|
+
* the source never goes byte-quiet (quiesce never fires), so the hop loop would
|
|
276
|
+
* otherwise keep rescheduling after captureShellHTML has already aborted and
|
|
277
|
+
* returned.
|
|
278
|
+
*/
|
|
279
|
+
dispose(): void;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Wrap the capture Flight stream so the fizz shell prerender reads a stream that
|
|
284
|
+
* (a) forwards the shell rows unchanged, (b) resolves `quiesce` after the rows go
|
|
285
|
+
* byte-silent for FLIGHT_QUIET_HOPS macrotask hops, and (c) FREEZES at that
|
|
286
|
+
* instant — dropping any later byte without closing or erroring the readable, so
|
|
287
|
+
* the pending masked-loader references stay pending and fizz postpones them (the
|
|
288
|
+
* "unclosing stream" property, here for free because the masked rows never emit).
|
|
289
|
+
* Freezing also guarantees no post-quiesce byte — including an error row from any
|
|
290
|
+
* later abort/cancel of the underlying render — can corrupt the frozen prelude.
|
|
291
|
+
*
|
|
292
|
+
* Quiet is measured in TASKS, not wall-clock: after the first byte a macrotask
|
|
293
|
+
* hop loop compares a byte counter each turn and fires after K quiet turns. The
|
|
294
|
+
* hop timers are unref'd so they never keep a Node process alive, and the source
|
|
295
|
+
* closing (no holes) fires quiesce immediately for the DATA variant — the
|
|
296
|
+
* TransformStream then closes the readable, so fizz completes with postponed null.
|
|
297
|
+
*
|
|
298
|
+
* `holdUntil` keeps the gate from FREEZING before shell material with real latency
|
|
299
|
+
* has emitted. The hole doctrine bakes TOP-LEVEL pushed handle promises into the
|
|
300
|
+
* shell (resolvedHandleStream awaits them before the handles row emits), but a
|
|
301
|
+
* pushed promise that takes longer than the quiet window would otherwise be frozen
|
|
302
|
+
* out — the handles row would never reach fizz and the prelude would come back
|
|
303
|
+
* trivial. While `holdUntil` is pending, byte-quiet detection keeps running but the
|
|
304
|
+
* gate neither fires nor freezes; once it resolves, the quiet counter restarts so a
|
|
305
|
+
* burst of rows unblocked by it (the resolved handles row) is still captured. It
|
|
306
|
+
* never delays a HOLE from postponing: holes are pending promises that emit no
|
|
307
|
+
* bytes, so holding the gate open longer only ever admits shell rows. Bounded by
|
|
308
|
+
* captureShellHTML's maxWaitMs like every other quiesce input.
|
|
309
|
+
*/
|
|
310
|
+
export function gateFlightForCapture(
|
|
311
|
+
source: ReadableStream<Uint8Array>,
|
|
312
|
+
quietHops: number = FLIGHT_QUIET_HOPS,
|
|
313
|
+
holdUntil?: Promise<unknown>,
|
|
314
|
+
): FlightCaptureGate {
|
|
315
|
+
let resolveQuiet!: () => void;
|
|
316
|
+
const quiesce = new Promise<void>((resolve) => {
|
|
317
|
+
resolveQuiet = resolve;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
let bytesSeen = 0;
|
|
321
|
+
let armed = false;
|
|
322
|
+
let settled = false;
|
|
323
|
+
let disposed = false;
|
|
324
|
+
let frozen = false;
|
|
325
|
+
let held = holdUntil !== undefined;
|
|
326
|
+
let heldFirePending = false;
|
|
327
|
+
|
|
328
|
+
if (holdUntil !== undefined) {
|
|
329
|
+
const release = (): void => {
|
|
330
|
+
held = false;
|
|
331
|
+
if (heldFirePending && !settled && !disposed) {
|
|
332
|
+
// Quiet elapsed while held: restart the quiet count instead of firing
|
|
333
|
+
// immediately, so rows unblocked by the hold (the baked handles row)
|
|
334
|
+
// still flow before the freeze.
|
|
335
|
+
heldFirePending = false;
|
|
336
|
+
armed = false;
|
|
337
|
+
arm();
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
// Resolve OR reject releases the hold (a rejected handle value is dropped by
|
|
341
|
+
// resolveDeferredHandleValues; the capture must not hang on it).
|
|
342
|
+
holdUntil.then(release, release);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const fire = (): void => {
|
|
346
|
+
if (settled) return;
|
|
347
|
+
if (held) {
|
|
348
|
+
heldFirePending = true;
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
settled = true;
|
|
352
|
+
frozen = true;
|
|
353
|
+
resolveQuiet();
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const scheduleHop = (fn: () => void): void => {
|
|
357
|
+
const t = setTimeout(fn, 0);
|
|
358
|
+
// Never let the quiet-detection hop alone keep a Node process alive
|
|
359
|
+
// (no-op on workerd).
|
|
360
|
+
(t as { unref?: () => void }).unref?.();
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// The hop loop starts only after the first byte, so it can never declare
|
|
364
|
+
// quiesce before fizz has begun pulling rows through the transform.
|
|
365
|
+
const arm = (): void => {
|
|
366
|
+
if (armed || settled || disposed) return;
|
|
367
|
+
armed = true;
|
|
368
|
+
let lastSeen = bytesSeen;
|
|
369
|
+
let quiet = 0;
|
|
370
|
+
const hop = (): void => {
|
|
371
|
+
if (settled || disposed) return;
|
|
372
|
+
if (bytesSeen === lastSeen) {
|
|
373
|
+
quiet += 1;
|
|
374
|
+
if (quiet >= quietHops) {
|
|
375
|
+
fire();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
} else {
|
|
379
|
+
lastSeen = bytesSeen;
|
|
380
|
+
quiet = 0;
|
|
381
|
+
}
|
|
382
|
+
scheduleHop(hop);
|
|
383
|
+
};
|
|
384
|
+
scheduleHop(hop);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const monitor = new TransformStream<Uint8Array, Uint8Array>({
|
|
388
|
+
transform(chunk, controller) {
|
|
389
|
+
// Post-quiesce: drop the byte. Do NOT enqueue and do NOT close/error — the
|
|
390
|
+
// frozen fizz input must stay a fixed byte set behind an open (unclosing)
|
|
391
|
+
// readable so still-pending references postpone.
|
|
392
|
+
if (frozen) return;
|
|
393
|
+
bytesSeen += chunk.length;
|
|
394
|
+
arm();
|
|
395
|
+
controller.enqueue(chunk);
|
|
396
|
+
},
|
|
397
|
+
flush() {
|
|
398
|
+
// Source closed with no freeze => DATA variant (no holes): quiet
|
|
399
|
+
// immediately. The TransformStream then closes the readable, so fizz
|
|
400
|
+
// completes and postponed comes back null.
|
|
401
|
+
fire();
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
stream: source.pipeThrough(monitor),
|
|
407
|
+
quiesce,
|
|
408
|
+
dispose(): void {
|
|
409
|
+
disposed = true;
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The background shell-capture descriptor: everything the capture task needs to
|
|
416
|
+
* store the shell. Built by the integrated PPR serve path (rsc-rendering.ts) from
|
|
417
|
+
* the route's `ppr` path option (`PartialPrerenderProps`) and the app-level cache
|
|
418
|
+
* store, and passed to scheduleShellCapture directly — it is NOT threaded through
|
|
419
|
+
* the request context. `tags` carries the route's OPERATIONAL `ppr.tags`; the
|
|
420
|
+
* capture UNIONS them with the shell's own auto-collected (non-loader) request
|
|
421
|
+
* tags from its derived render (the collected set stays authoritative). `store`
|
|
422
|
+
* is the same store the serve path resolved for its getShell read
|
|
423
|
+
* (requestCtx._cacheStore), so the capture writes where the serve reads.
|
|
424
|
+
*/
|
|
425
|
+
export interface ShellCaptureDescriptor {
|
|
426
|
+
key: string;
|
|
427
|
+
ttl?: number;
|
|
428
|
+
swr?: number;
|
|
429
|
+
tags?: string[];
|
|
430
|
+
store?: SegmentCacheStore<any>;
|
|
431
|
+
/** Gates the concise per-attempt capture breadcrumbs (INTERNAL_RANGO_DEBUG). */
|
|
432
|
+
debug?: boolean;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Schedule the background shell capture for a served document. Stampede-guarded:
|
|
437
|
+
* one capture per key per isolate. Runs via runBackground (waitUntil on workerd,
|
|
438
|
+
* fire-and-forget in Node dev), so the served response is never blocked on it. Any
|
|
439
|
+
* error is routed through reportCacheError — capture is best-effort; a failure just
|
|
440
|
+
* means the next request recaptures.
|
|
441
|
+
*
|
|
442
|
+
* Eligibility (nonce/allReady/partial/status/strategy) is decided by the caller
|
|
443
|
+
* (rsc-rendering.ts maybeScheduleShellCapture); this function only owns the
|
|
444
|
+
* stampede guard and the background dispatch.
|
|
445
|
+
*/
|
|
446
|
+
export function scheduleShellCapture(
|
|
447
|
+
ctx: HandlerContext<any>,
|
|
448
|
+
request: Request,
|
|
449
|
+
env: any,
|
|
450
|
+
url: URL,
|
|
451
|
+
reqCtx: RequestContext<any>,
|
|
452
|
+
ssrModule: SSRModule,
|
|
453
|
+
descriptor: ShellCaptureDescriptor,
|
|
454
|
+
): void {
|
|
455
|
+
const key = descriptor.key;
|
|
456
|
+
if (inFlightCaptures.has(key)) return;
|
|
457
|
+
// Refused/failed within the window → skip the doomed re-render (one probe per
|
|
458
|
+
// key per window per isolate). Expired entries self-evict inside the check.
|
|
459
|
+
if (isCaptureBackedOff(key)) return;
|
|
460
|
+
inFlightCaptures.add(key);
|
|
461
|
+
const captureTask = async () => {
|
|
462
|
+
try {
|
|
463
|
+
const outcome = await runShellCapture(
|
|
464
|
+
ctx,
|
|
465
|
+
request,
|
|
466
|
+
env,
|
|
467
|
+
url,
|
|
468
|
+
reqCtx,
|
|
469
|
+
ssrModule,
|
|
470
|
+
descriptor,
|
|
471
|
+
);
|
|
472
|
+
// Update the negative cache off the terminal outcome. A stored shell clears
|
|
473
|
+
// any prior backoff; a `no-shell` (after the in-place retry) backs the key
|
|
474
|
+
// off so the next requests don't re-probe it. A `redirect` has no shell but
|
|
475
|
+
// is not a doomed render — leave the backoff untouched.
|
|
476
|
+
if (outcome === "stored") clearCaptureBackoff(key);
|
|
477
|
+
else if (outcome === "no-shell") markCaptureBackoff(key);
|
|
478
|
+
} catch (error) {
|
|
479
|
+
// Detached background task — pass reqCtx so onError still fires when the ALS
|
|
480
|
+
// context is gone. A genuine failure recurs, so back it off too (re-probe
|
|
481
|
+
// once per window, not every request) and report it once.
|
|
482
|
+
markCaptureBackoff(key);
|
|
483
|
+
reportCacheError(error, "cache-write", "[ShellCache] capture", reqCtx);
|
|
484
|
+
} finally {
|
|
485
|
+
inFlightCaptures.delete(key);
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
// The capture's own task must NOT enter reqCtx._pendingBackgroundTasks: the
|
|
489
|
+
// capture drains that list before rendering (the write-barrier ordering edge),
|
|
490
|
+
// and awaiting its own still-running promise would burn the whole barrier
|
|
491
|
+
// deadline on every capture.
|
|
492
|
+
(captureTask as { [UNTRACKED_BACKGROUND_TASK]?: boolean })[
|
|
493
|
+
UNTRACKED_BACKGROUND_TASK
|
|
494
|
+
] = true;
|
|
495
|
+
runBackground(reqCtx, captureTask);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The outcome of one capture attempt.
|
|
500
|
+
* - `stored`: a usable shell was captured (and a putShell was attempted; a store
|
|
501
|
+
* I/O failure is reported separately and does NOT make the attempt retryable —
|
|
502
|
+
* the capture itself worked).
|
|
503
|
+
* - `redirect`: the matched route redirects, so there is no shell to capture.
|
|
504
|
+
* - `no-shell`: the prelude came back trivial (no <body>) OR captureShellHTML
|
|
505
|
+
* rejected with our own abort. This is the only RETRYABLE outcome.
|
|
506
|
+
*/
|
|
507
|
+
type CaptureAttemptOutcome = "stored" | "redirect" | "no-shell";
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Run the shell capture with a single in-place retry, then store the result.
|
|
511
|
+
*
|
|
512
|
+
* Each attempt re-derives EVERYTHING (fresh context, fresh router.match, fresh
|
|
513
|
+
* Flight render) via {@link attemptCapture} — a capture consumes its handle store,
|
|
514
|
+
* its request-tag set, and its one-shot Flight stream, so none of them are
|
|
515
|
+
* reusable across attempts. A first attempt that comes back `no-shell` is almost
|
|
516
|
+
* always a cold render (dev module transform / cold worker) that had not finished
|
|
517
|
+
* when we froze the shell; the attempt itself warmed the module graph, so a second
|
|
518
|
+
* attempt a short beat later usually completes the shell in the SAME background
|
|
519
|
+
* task. That kills the old multi-request warmup where the caller had to re-issue
|
|
520
|
+
* several HTTP requests before a capture stuck. We retry ONLY on `no-shell` (and a
|
|
521
|
+
* defensively-caught abort); a genuine render error is NOT retried — it propagates
|
|
522
|
+
* to scheduleShellCapture's reportCacheError. See docs/design/ppr-shell-resume.md.
|
|
523
|
+
*
|
|
524
|
+
* `retryDelayMs` is a parameter (defaulting to the module const) so unit tests can
|
|
525
|
+
* drive the retry without a real 400ms wall-clock wait.
|
|
526
|
+
*/
|
|
527
|
+
async function runShellCapture(
|
|
528
|
+
ctx: HandlerContext<any>,
|
|
529
|
+
request: Request,
|
|
530
|
+
env: any,
|
|
531
|
+
url: URL,
|
|
532
|
+
reqCtx: RequestContext<any>,
|
|
533
|
+
ssrModule: SSRModule,
|
|
534
|
+
descriptor: ShellCaptureDescriptor,
|
|
535
|
+
retryDelayMs: number = SHELL_CAPTURE_RETRY_DELAY_MS,
|
|
536
|
+
): Promise<CaptureAttemptOutcome> {
|
|
537
|
+
const log = descriptor.debug
|
|
538
|
+
? (message: string) => console.log(message)
|
|
539
|
+
: () => {};
|
|
540
|
+
|
|
541
|
+
const first = await attemptCapture(
|
|
542
|
+
ctx,
|
|
543
|
+
request,
|
|
544
|
+
env,
|
|
545
|
+
url,
|
|
546
|
+
reqCtx,
|
|
547
|
+
ssrModule,
|
|
548
|
+
descriptor,
|
|
549
|
+
);
|
|
550
|
+
// "stored" (success) or "redirect" (no shell exists): nothing to retry.
|
|
551
|
+
if (first !== "no-shell") return first;
|
|
552
|
+
|
|
553
|
+
// Attempt 1 produced no usable shell. Retry ONCE in place — the first attempt
|
|
554
|
+
// warmed the dev transform graph / cold worker, so attempt 2 typically completes
|
|
555
|
+
// the shell without another HTTP request. The concise line is gated on the
|
|
556
|
+
// middleware's debug flag (threaded via the descriptor) so it replaces the old
|
|
557
|
+
// full DOMException dump with one readable breadcrumb.
|
|
558
|
+
log(
|
|
559
|
+
`[ShellCache] capture attempt 1/2 for ${descriptor.key} aborted before shell completed (cold modules?) — retrying`,
|
|
560
|
+
);
|
|
561
|
+
await delay(retryDelayMs);
|
|
562
|
+
const second = await attemptCapture(
|
|
563
|
+
ctx,
|
|
564
|
+
request,
|
|
565
|
+
env,
|
|
566
|
+
url,
|
|
567
|
+
reqCtx,
|
|
568
|
+
ssrModule,
|
|
569
|
+
descriptor,
|
|
570
|
+
);
|
|
571
|
+
if (second !== "no-shell") return second;
|
|
572
|
+
|
|
573
|
+
// Both attempts came back with no usable shell. Cold-start would have healed by
|
|
574
|
+
// now, so the eternal-MISS structural shape (a loader route without loading()) is
|
|
575
|
+
// the likely cause — warn once per key. Ordering matters: because the retry
|
|
576
|
+
// absorbs cold-start, cold-start routes almost never reach this warning. The
|
|
577
|
+
// caller (scheduleShellCapture) reads this `no-shell` return to back the key off.
|
|
578
|
+
log(
|
|
579
|
+
`[ShellCache] capture attempt 2/2 for ${descriptor.key} aborted — giving up until next request`,
|
|
580
|
+
);
|
|
581
|
+
warnNullCaptureOnce(descriptor.key);
|
|
582
|
+
return "no-shell";
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* One capture attempt in a DERIVED request context.
|
|
587
|
+
*
|
|
588
|
+
* The derived context is `Object.create(reqCtx)` so it inherits the foreground's
|
|
589
|
+
* post-middleware state (variables, cache store, env/request/url, waitUntil) while
|
|
590
|
+
* overriding the render-scoped accumulators as own properties:
|
|
591
|
+
* - _handleStore: a fresh store. The foreground store is already drained to
|
|
592
|
+
* completion (its stream() flipped `completed` on settle) and would throw
|
|
593
|
+
* LateHandlePushError on any re-push. Every downstream reader resolves the
|
|
594
|
+
* store off the ambient context (setupLoaderAccess captures
|
|
595
|
+
* _getRequestContext()._handleStore; trackHandler reads it), so the fresh
|
|
596
|
+
* store on the derived context is what the capture match() writes handles to.
|
|
597
|
+
* - _requestTags: a fresh Set. The capture collects its OWN shell tags here —
|
|
598
|
+
* non-loader tags only, since loaders are masked — which is exactly the tag
|
|
599
|
+
* set a shell entry should be invalidatable by (loader tags belong to holes).
|
|
600
|
+
* - _transitionWhen: a fresh [] so the capture's transition gating is its own.
|
|
601
|
+
* - _shellCaptureRun: true — the switch loaders/cookies/headers guards read.
|
|
602
|
+
* - _metricsStore: undefined so the capture never appends to the foreground's
|
|
603
|
+
* (already-finalized) metrics.
|
|
604
|
+
*
|
|
605
|
+
* The capture is MIXED-CHAIN: its match() behaves like a normal render with
|
|
606
|
+
* respect to the segment cache — cache()'d segments replay from ring 3, UNCACHED
|
|
607
|
+
* segments execute their handlers fresh (which is why the cookies()/headers()
|
|
608
|
+
* capture guard is load-bearing). Middleware is NOT re-run: it already ran for the
|
|
609
|
+
* triggering request, and the derived context inherits its post-middleware state
|
|
610
|
+
* (guarding is serve-time; the shell is never served without the full chain).
|
|
611
|
+
*
|
|
612
|
+
* A FRESH context (and match/render) per attempt is what makes the retry sound:
|
|
613
|
+
* the second attempt is a clean capture, not a resumption of the first.
|
|
614
|
+
*/
|
|
615
|
+
async function attemptCapture(
|
|
616
|
+
ctx: HandlerContext<any>,
|
|
617
|
+
request: Request,
|
|
618
|
+
env: any,
|
|
619
|
+
url: URL,
|
|
620
|
+
reqCtx: RequestContext<any>,
|
|
621
|
+
ssrModule: SSRModule,
|
|
622
|
+
descriptor: ShellCaptureDescriptor,
|
|
623
|
+
): Promise<CaptureAttemptOutcome> {
|
|
624
|
+
// WRITE BARRIER (ordering edge, not a narrower race): settle the foreground's
|
|
625
|
+
// already-scheduled background tasks — its deferred ring-3/ring-1 cache writes —
|
|
626
|
+
// BEFORE this attempt's match/render, so the capture's cache reads observe the
|
|
627
|
+
// foreground's generation deterministically. Contract: a capture must never
|
|
628
|
+
// clobber a ring-3 entry the foreground produced; with the barrier, the
|
|
629
|
+
// capture's ring-3 lookup HITs the foreground's entry and REPLAYS it (handler
|
|
630
|
+
// skipped, cache-store middleware's write path gated off by state.cacheHit), so
|
|
631
|
+
// prelude, snapshot, and ring-3 agree on the foreground's generation. Runs per
|
|
632
|
+
// attempt (the retry re-checks; already-settled promises are free).
|
|
633
|
+
await settleTrackedBackgroundTasks(reqCtx, SHELL_CAPTURE_WRITE_BARRIER_MS);
|
|
634
|
+
|
|
635
|
+
const freshHandleStore = createHandleStore();
|
|
636
|
+
freshHandleStore.onError = reqCtx._handleStore.onError;
|
|
637
|
+
|
|
638
|
+
const derivedCtx: RequestContext = Object.create(reqCtx);
|
|
639
|
+
derivedCtx._handleStore = freshHandleStore;
|
|
640
|
+
derivedCtx._requestTags = new Set<string>();
|
|
641
|
+
derivedCtx._transitionWhen = [];
|
|
642
|
+
derivedCtx._shellCaptureRun = true;
|
|
643
|
+
derivedCtx._metricsStore = undefined;
|
|
644
|
+
// Own onResponse list so the capture's match-middleware callbacks (the ring-3
|
|
645
|
+
// segment cache write registers here) are ISOLATED from the foreground's shared
|
|
646
|
+
// array AND can be fired by captureAndStoreShell. The segment write is gated
|
|
647
|
+
// behind onResponse, which the capture never triggers (it builds no Response) —
|
|
648
|
+
// without firing it, a ring-3 cache() MISS at capture renders fresh into the
|
|
649
|
+
// prelude but is never written, so it is never recorded and drifts on a HIT.
|
|
650
|
+
derivedCtx._onResponseCallbacks = [];
|
|
651
|
+
|
|
652
|
+
// Capture data snapshot: read every cache-store hit/write through a recording
|
|
653
|
+
// wrapper on the DERIVED context's store (own property, so the shared
|
|
654
|
+
// reqCtx._cacheStore is untouched — the snapshot is per-capture). Its records
|
|
655
|
+
// ride inside the ShellCacheEntry so a HIT can reproduce the shell's cached
|
|
656
|
+
// content byte-identically. See cache/shell-snapshot.ts and the design doc.
|
|
657
|
+
//
|
|
658
|
+
// Cache writes are deferred (waitUntil): a MISS-at-capture value's setItem/set
|
|
659
|
+
// — hence its record — would otherwise land after the shell quiesces. Override
|
|
660
|
+
// the derived context's waitUntil to COLLECT those write promises (still
|
|
661
|
+
// forwarding to the parent so the write persists and the worker stays alive),
|
|
662
|
+
// then captureAndStoreShell awaits them before draining. Reads that HIT are
|
|
663
|
+
// recorded synchronously during the render and need none of this.
|
|
664
|
+
if (reqCtx._cacheStore) {
|
|
665
|
+
const recordingStore = new RecordingShellStore(reqCtx._cacheStore);
|
|
666
|
+
derivedCtx._cacheStore = recordingStore;
|
|
667
|
+
derivedCtx.waitUntil = (fn: () => Promise<void>): void => {
|
|
668
|
+
const p = Promise.resolve().then(fn);
|
|
669
|
+
recordingStore.trackWrite(p);
|
|
670
|
+
reqCtx.waitUntil(() => p);
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return runWithRequestContext(derivedCtx, async () => {
|
|
675
|
+
const match = await ctx.router.match(request, { env });
|
|
676
|
+
// A route that redirects has no shell to capture — bail (no store write, no
|
|
677
|
+
// retry: a redirect is deterministic).
|
|
678
|
+
if (match.redirect) return "redirect";
|
|
679
|
+
|
|
680
|
+
setRequestContextParams(match.params, match.routeName);
|
|
681
|
+
|
|
682
|
+
const payload = buildFullPayload(
|
|
683
|
+
match,
|
|
684
|
+
ctx,
|
|
685
|
+
url,
|
|
686
|
+
derivedCtx,
|
|
687
|
+
freshHandleStore,
|
|
688
|
+
);
|
|
689
|
+
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
690
|
+
onError: (error: unknown) => {
|
|
691
|
+
ctx.callOnError(error, "rendering", { request, url, env });
|
|
692
|
+
},
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// Shell tags = the non-loader request tags the capture render recorded on its
|
|
696
|
+
// own fresh _requestTags (loaders are masked, so loader cache tags — which
|
|
697
|
+
// belong to the holes, not the shell — are correctly excluded), UNIONED with
|
|
698
|
+
// the middleware's operational `tags` option (descriptor.tags). The collected
|
|
699
|
+
// set is authoritative; the option only adds tags the render cannot know.
|
|
700
|
+
const collected = [...derivedCtx._requestTags];
|
|
701
|
+
const union = new Set<string>([...(descriptor.tags ?? []), ...collected]);
|
|
702
|
+
const tags = union.size > 0 ? [...union] : undefined;
|
|
703
|
+
|
|
704
|
+
return captureAndStoreShell(
|
|
705
|
+
ssrModule,
|
|
706
|
+
rscStream,
|
|
707
|
+
freshHandleStore,
|
|
708
|
+
derivedCtx,
|
|
709
|
+
{
|
|
710
|
+
...descriptor,
|
|
711
|
+
tags,
|
|
712
|
+
},
|
|
713
|
+
);
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Seal handles, derive the quiesce signal, prerender + abort via the SSR module's
|
|
719
|
+
* captureShellHTML, and store the result. Returns the attempt outcome (the caller
|
|
720
|
+
* owns retry/warn decisions — this function no longer warns). Never throws out of
|
|
721
|
+
* the store write: a failed putShell is routed through reportCacheError so the
|
|
722
|
+
* background task stays best-effort, and the attempt still counts as `stored` (the
|
|
723
|
+
* capture worked; only the store I/O failed). `ssrModule.captureShellHTML` MUST be
|
|
724
|
+
* present (eligibility is checked before scheduling).
|
|
725
|
+
*
|
|
726
|
+
* A `no-shell` result (trivial prelude, or a defensively-caught abort) is the only
|
|
727
|
+
* retryable outcome; a genuine (non-abort) captureShellHTML error propagates so it
|
|
728
|
+
* reaches reportCacheError and is NOT retried.
|
|
729
|
+
*/
|
|
730
|
+
async function captureAndStoreShell(
|
|
731
|
+
ssrModule: SSRModule,
|
|
732
|
+
rscStream: ReadableStream<Uint8Array>,
|
|
733
|
+
handleStore: HandleStore,
|
|
734
|
+
reqCtx: RequestContext<any>,
|
|
735
|
+
capture: ShellCaptureDescriptor,
|
|
736
|
+
): Promise<Exclude<CaptureAttemptOutcome, "redirect">> {
|
|
737
|
+
const captureShellHTML = ssrModule.captureShellHTML!;
|
|
738
|
+
|
|
739
|
+
// Seal the handle store so the payload's handles generator (resolvedHandleStream
|
|
740
|
+
// -> handleStore.stream()) converges and completes even though masked loaders
|
|
741
|
+
// never resolve. handleStore.settled gates ONLY on tracked HANDLER promises
|
|
742
|
+
// (handleStore.track, via trackHandler) — NOT on deferred handle VALUES pushed
|
|
743
|
+
// through ctx.use(Handle).defer(), which are plain pushed promises. So seal()
|
|
744
|
+
// does not reject or hang on outstanding defers: settled resolves once the
|
|
745
|
+
// handlers settle, and each deferred slot resolves on its own createDeferred
|
|
746
|
+
// timeout (defer.ts, default 10s) or when its resolver fires. A defer whose
|
|
747
|
+
// resolver depends on a masked loader can never fire, so it stays pending until
|
|
748
|
+
// that 10s timeout — longer than maxWaitMs (5s). At the abort the handles
|
|
749
|
+
// generator has not yielded, SsrRoot suspends at the root (consumeAsyncGenerator
|
|
750
|
+
// sits above every boundary), the prelude comes back trivial, and
|
|
751
|
+
// captureShellHTML's sanity gate returns null: the designed fail-safe no-op, not
|
|
752
|
+
// an error. This mirrors the __prerender_collect seal+settled regime, which also
|
|
753
|
+
// excludes loaders. See docs/design/ppr-shell-resume.md ("Loaders and handles").
|
|
754
|
+
handleStore.seal();
|
|
755
|
+
|
|
756
|
+
// Handles contract, shell half ("nesting = liveness"): TOP-LEVEL pushed handle
|
|
757
|
+
// promises are BAKED into the shell — resolvedHandleStream awaits them before
|
|
758
|
+
// the payload's handles row emits. A pushed promise with real latency would lose
|
|
759
|
+
// the byte-quiet race (the pending handles row emits no bytes, the gate freezes,
|
|
760
|
+
// the row is dropped, SsrRoot suspends at the root), so the gate is HELD open
|
|
761
|
+
// until the same await completes: handlesBaked mirrors resolvedHandleStream's
|
|
762
|
+
// resolution (getData waits the tracked-handler barrier; resolveDeferredHandleValues
|
|
763
|
+
// awaits the top-level thenables). NESTED promises inside pushed containers are
|
|
764
|
+
// shallow-skipped by isThenable and never hold the gate — they stay holes.
|
|
765
|
+
// Bounded by maxWaitMs like every quiesce input (a defer hanging on a masked
|
|
766
|
+
// loader still ends in the sanity-gate refusal).
|
|
767
|
+
const handlesBaked = handleStore.getData().then(resolveDeferredHandleValues);
|
|
768
|
+
const gate = gateFlightForCapture(rscStream, undefined, handlesBaked);
|
|
769
|
+
// Quiesce = handles settled AND the Flight shell rows went task-quiet. Either
|
|
770
|
+
// half stalling is bounded by captureShellHTML's maxWaitMs.
|
|
771
|
+
const quiesce = Promise.all([handleStore.settled, gate.quiesce]).then(
|
|
772
|
+
() => {},
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
try {
|
|
776
|
+
// captureShellHTML CONSUMES the (gated) stream — it is not also SSR'd.
|
|
777
|
+
let result: Awaited<ReturnType<typeof captureShellHTML>>;
|
|
778
|
+
try {
|
|
779
|
+
result = await observePhase(PHASES.ssr, () =>
|
|
780
|
+
captureShellHTML(gate.stream, {
|
|
781
|
+
quiesce,
|
|
782
|
+
maxWaitMs: SHELL_CAPTURE_MAX_WAIT_MS,
|
|
783
|
+
}),
|
|
784
|
+
);
|
|
785
|
+
} catch (error) {
|
|
786
|
+
// captureShellHTML normally converts its OWN deliberate abort to a null
|
|
787
|
+
// return (index.tsx). This catch is defensive: if an AbortError still escapes
|
|
788
|
+
// (a runtime where the abort surfaces as a stream rejection outside its
|
|
789
|
+
// guard), treat it as the same retryable "no usable shell" degradation rather
|
|
790
|
+
// than a failure — do NOT report it as an error. A genuine (non-abort) render
|
|
791
|
+
// error is a real failure: rethrow so it reaches reportCacheError (no retry).
|
|
792
|
+
if ((error as { name?: string } | null)?.name === "AbortError") {
|
|
793
|
+
return "no-shell";
|
|
794
|
+
}
|
|
795
|
+
throw error;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// null = sanity gate refused (trivial/empty prelude, no <body>). Store nothing
|
|
799
|
+
// and report `no-shell` so the caller (runShellCapture) can retry once and, if
|
|
800
|
+
// that also fails, warn once per key. On a cold render this is the shell not
|
|
801
|
+
// yet finished; on a loader route WITHOUT a route-level loading() boundary it is
|
|
802
|
+
// the structural eternal-MISS shape (the masked loader pins the tree above
|
|
803
|
+
// <body> at tree-build). The caller's warning names both.
|
|
804
|
+
if (result === null) {
|
|
805
|
+
return "no-shell";
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Store per the flag's key/ttl/swr/tags, into the flag's store: the middleware
|
|
809
|
+
// threads the SAME store it resolved for its getShell read (options.store ??
|
|
810
|
+
// _cacheStore), so a store-attached middleware writes captures where it reads
|
|
811
|
+
// them. The _cacheStore fallback covers a flag armed without a store (tests).
|
|
812
|
+
// reactVersion is read from the same React.version import the middleware
|
|
813
|
+
// validates reads against, so capture and serve always agree.
|
|
814
|
+
// Fire the capture's isolated onResponse callbacks with a synthetic 200 so
|
|
815
|
+
// the ring-3 segment cache write (cacheScope.cacheRoute, registered via
|
|
816
|
+
// onResponse by the cache-store match-middleware and gated on a 200) runs
|
|
817
|
+
// DURING capture, routed through the recording store. The foreground path
|
|
818
|
+
// never fires for the capture — it builds no Response — so without this a
|
|
819
|
+
// cache() SEGMENT that MISSED at capture would be rendered fresh into the
|
|
820
|
+
// prelude yet never written, hence never recorded, and would drift on a HIT
|
|
821
|
+
// (an item-family "use cache" write already runs inline during the render, so
|
|
822
|
+
// it needs none of this; only segment writes are onResponse-gated). The
|
|
823
|
+
// derived context's own _onResponseCallbacks holds only capture match-
|
|
824
|
+
// middleware callbacks (HTTP middleware never runs for a capture), so firing
|
|
825
|
+
// them is safe. Best-effort: a throwing callback must not fail the capture.
|
|
826
|
+
const responseCallbacks = reqCtx._onResponseCallbacks;
|
|
827
|
+
if (responseCallbacks && responseCallbacks.length > 0) {
|
|
828
|
+
const synthetic = new Response(null, { status: 200 });
|
|
829
|
+
for (const cb of responseCallbacks) {
|
|
830
|
+
try {
|
|
831
|
+
cb(synthetic);
|
|
832
|
+
} catch {
|
|
833
|
+
// A capture-time cache write that throws is degradation, not failure.
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Drain the capture data snapshot from the recording store on the derived
|
|
839
|
+
// context. Await the deferred cache writes first so a MISS-at-capture value
|
|
840
|
+
// (setItem/set scheduled under waitUntil, including the segment write just
|
|
841
|
+
// fired) is pinned, not just read-hits. When no recording store is installed
|
|
842
|
+
// (unit tests that call this directly), there is simply no snapshot.
|
|
843
|
+
const recording = getRecordingStore(reqCtx._cacheStore);
|
|
844
|
+
let snapshot: ShellSnapshotRecord[] | undefined;
|
|
845
|
+
if (recording) {
|
|
846
|
+
await recording.settleWrites(SHELL_SNAPSHOT_WRITE_SETTLE_MS);
|
|
847
|
+
snapshot = recording.drainSnapshot();
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const store = capture.store ?? reqCtx._cacheStore;
|
|
851
|
+
if (store?.putShell) {
|
|
852
|
+
try {
|
|
853
|
+
const entry: ShellCacheEntry = {
|
|
854
|
+
// slice() copies just this view's bytes into a fresh ArrayBuffer, so a
|
|
855
|
+
// prelude that is a subarray of a larger backing buffer encodes only its
|
|
856
|
+
// own region — bufferToBase64 reads the whole ArrayBuffer it is handed.
|
|
857
|
+
prelude: bufferToBase64(result.prelude.slice().buffer as ArrayBuffer),
|
|
858
|
+
postponed: result.postponed,
|
|
859
|
+
reactVersion: React.version,
|
|
860
|
+
// The theme this capture's payload was built with (buildFullPayload
|
|
861
|
+
// reads reqCtx.theme off the derived context). The serve tail replays
|
|
862
|
+
// it so the resume tree matches the frozen prelude — see
|
|
863
|
+
// ShellCacheEntry.initialTheme.
|
|
864
|
+
initialTheme: reqCtx.theme,
|
|
865
|
+
snapshot,
|
|
866
|
+
createdAt: Date.now(),
|
|
867
|
+
};
|
|
868
|
+
await store.putShell(
|
|
869
|
+
capture.key,
|
|
870
|
+
entry,
|
|
871
|
+
capture.ttl,
|
|
872
|
+
capture.swr,
|
|
873
|
+
capture.tags,
|
|
874
|
+
);
|
|
875
|
+
} catch (error) {
|
|
876
|
+
// Best-effort: a failed put must never throw out of the background task.
|
|
877
|
+
reportCacheError(
|
|
878
|
+
error,
|
|
879
|
+
"cache-write",
|
|
880
|
+
"[ShellCache] capture put",
|
|
881
|
+
reqCtx,
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
// A shell was captured (the store I/O may have failed, but that is reported,
|
|
886
|
+
// not retried) — so this attempt is `stored` and the caller does not retry.
|
|
887
|
+
return "stored";
|
|
888
|
+
} finally {
|
|
889
|
+
// Stop the hop loop for the pathological never-quiets path (quiesce never
|
|
890
|
+
// fired, capture returned via maxWaitMs). On the normal path the loop already
|
|
891
|
+
// stopped when it fired quiesce; dispose() is then a no-op.
|
|
892
|
+
gate.dispose();
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Exported for unit tests that drive the capture core directly.
|
|
897
|
+
export { runShellCapture, captureAndStoreShell };
|