@lovrozagar/flare 0.1.0
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/LICENSE +21 -0
- package/README.md +11 -0
- package/package.json +146 -0
- package/scripts/download-fonts.ts +136 -0
- package/scripts/populate-fonts.ts +721 -0
- package/src/await.ts +2 -0
- package/src/boundaries/index.ts +99 -0
- package/src/broadcast/channel.ts +65 -0
- package/src/broadcast/context.ts +12 -0
- package/src/broadcast/index.ts +75 -0
- package/src/broadcast/provider.tsx +7 -0
- package/src/build-location.ts +2 -0
- package/src/caches/index.ts +384 -0
- package/src/client/index.ts +75 -0
- package/src/client.tsx +2 -0
- package/src/codegen/index.ts +41 -0
- package/src/components/await.tsx +140 -0
- package/src/components/dev-error-overlay.tsx +154 -0
- package/src/components/direction-script.tsx +22 -0
- package/src/components/index.ts +150 -0
- package/src/components/locale-script.tsx +26 -0
- package/src/components/reset-css.tsx +14 -0
- package/src/components/ssr-context.tsx +37 -0
- package/src/components/theme-script.tsx +22 -0
- package/src/components/view-transition-css.tsx +21 -0
- package/src/config/index.ts +84 -0
- package/src/create-client-only-fn.ts +1 -0
- package/src/create-isomorphic-fn.ts +2 -0
- package/src/create-layout.ts +1 -0
- package/src/create-page.ts +1 -0
- package/src/create-path-segment.ts +1 -0
- package/src/create-query-client-getter.ts +2 -0
- package/src/create-root-layout.ts +1 -0
- package/src/create-router.ts +8 -0
- package/src/create-server-only-fn.ts +1 -0
- package/src/dedupe/index.ts +151 -0
- package/src/defer/index.ts +82 -0
- package/src/direction/index.tsx +163 -0
- package/src/direction.ts +3 -0
- package/src/duration/index.ts +18 -0
- package/src/env-fn/index.ts +101 -0
- package/src/env-fn.ts +2 -0
- package/src/errors/index.ts +166 -0
- package/src/fonts/abeezee.ts +50 -0
- package/src/fonts/abel.ts +26 -0
- package/src/fonts/abril-fatface.ts +34 -0
- package/src/fonts/acme.ts +26 -0
- package/src/fonts/advent-pro.ts +92 -0
- package/src/fonts/alegreya-sans.ts +228 -0
- package/src/fonts/alegreya.ts +228 -0
- package/src/fonts/alex-brush.ts +42 -0
- package/src/fonts/alfa-slab-one.ts +42 -0
- package/src/fonts/alice.ts +48 -0
- package/src/fonts/amatic-sc.ts +94 -0
- package/src/fonts/amiri.ts +114 -0
- package/src/fonts/antic-slab.ts +26 -0
- package/src/fonts/anton.ts +42 -0
- package/src/fonts/archivo-black.ts +34 -0
- package/src/fonts/archivo-narrow.ts +114 -0
- package/src/fonts/archivo.ts +66 -0
- package/src/fonts/arimo.ts +256 -0
- package/src/fonts/arvo.ts +50 -0
- package/src/fonts/asap.ts +66 -0
- package/src/fonts/assistant.ts +64 -0
- package/src/fonts/baloo-2.ts +82 -0
- package/src/fonts/barlow-condensed.ts +114 -0
- package/src/fonts/barlow-semi-condensed.ts +114 -0
- package/src/fonts/barlow.ts +114 -0
- package/src/fonts/be-vietnam-pro.ts +114 -0
- package/src/fonts/bebas-neue.ts +34 -0
- package/src/fonts/besley.ts +82 -0
- package/src/fonts/biorhyme.ts +50 -0
- package/src/fonts/bitter.ts +94 -0
- package/src/fonts/black-ops-one.ts +49 -0
- package/src/fonts/bree-serif.ts +34 -0
- package/src/fonts/bricolage-grotesque.ts +66 -0
- package/src/fonts/cabin.ts +114 -0
- package/src/fonts/cairo.ts +66 -0
- package/src/fonts/cardo.ts +193 -0
- package/src/fonts/catamaran.ts +41 -0
- package/src/fonts/caveat.ts +78 -0
- package/src/fonts/chakra-petch.ts +142 -0
- package/src/fonts/chivo.ts +66 -0
- package/src/fonts/cinzel.ts +50 -0
- package/src/fonts/comfortaa.ts +109 -0
- package/src/fonts/commissioner.ts +64 -0
- package/src/fonts/component.tsx +27 -0
- package/src/fonts/cormorant-garamond.ts +171 -0
- package/src/fonts/cormorant.ts +170 -0
- package/src/fonts/courgette.ts +34 -0
- package/src/fonts/courier-prime.ts +82 -0
- package/src/fonts/create-font.ts +47 -0
- package/src/fonts/create-registry-font.ts +40 -0
- package/src/fonts/crete-round.ts +50 -0
- package/src/fonts/crimson-text.ts +114 -0
- package/src/fonts/css.ts +86 -0
- package/src/fonts/dancing-script.ts +66 -0
- package/src/fonts/dm-mono.ts +50 -0
- package/src/fonts/dm-sans.ts +50 -0
- package/src/fonts/dm-serif-display.ts +50 -0
- package/src/fonts/domine.ts +50 -0
- package/src/fonts/dosis.ts +66 -0
- package/src/fonts/eb-garamond.ts +228 -0
- package/src/fonts/eczar.ts +94 -0
- package/src/fonts/encode-sans.ts +42 -0
- package/src/fonts/epilogue.ts +66 -0
- package/src/fonts/exo-2.ts +94 -0
- package/src/fonts/exo.ts +66 -0
- package/src/fonts/figtree.ts +82 -0
- package/src/fonts/fira-code.ts +121 -0
- package/src/fonts/fira-sans-condensed.ts +228 -0
- package/src/fonts/fira-sans.ts +228 -0
- package/src/fonts/fjalla-one.ts +49 -0
- package/src/fonts/font-urls.gen.json +1962 -0
- package/src/fonts/fraunces.ts +66 -0
- package/src/fonts/fredoka.ts +64 -0
- package/src/fonts/gelasio.ts +114 -0
- package/src/fonts/gloria-hallelujah.ts +34 -0
- package/src/fonts/gothic-a1.ts +124 -0
- package/src/fonts/great-vibes.ts +64 -0
- package/src/fonts/gudea.ts +66 -0
- package/src/fonts/heebo.ts +57 -0
- package/src/fonts/hind-siliguri.ts +66 -0
- package/src/fonts/hind.ts +66 -0
- package/src/fonts/ibm-plex-mono.ts +172 -0
- package/src/fonts/ibm-plex-sans-arabic.ts +80 -0
- package/src/fonts/ibm-plex-sans.ts +199 -0
- package/src/fonts/ibm-plex-serif.ts +171 -0
- package/src/fonts/inconsolata.ts +66 -0
- package/src/fonts/index.ts +4 -0
- package/src/fonts/indie-flower.ts +34 -0
- package/src/fonts/instrument-sans.ts +82 -0
- package/src/fonts/instrument-serif.ts +50 -0
- package/src/fonts/inter-display.ts +52 -0
- package/src/fonts/inter-tight.ts +124 -0
- package/src/fonts/inter.ts +123 -0
- package/src/fonts/jetbrains-mono.ts +199 -0
- package/src/fonts/josefin-sans.ts +114 -0
- package/src/fonts/josefin-slab.ts +50 -0
- package/src/fonts/jost.ts +64 -0
- package/src/fonts/kalam.ts +66 -0
- package/src/fonts/kanit.ts +142 -0
- package/src/fonts/karla.ts +82 -0
- package/src/fonts/khand.ts +66 -0
- package/src/fonts/lato.ts +82 -0
- package/src/fonts/league-spartan.ts +42 -0
- package/src/fonts/lexend-deca.ts +42 -0
- package/src/fonts/lexend.ts +42 -0
- package/src/fonts/libre-baskerville.ts +82 -0
- package/src/fonts/libre-franklin.ts +95 -0
- package/src/fonts/lilita-one.ts +34 -0
- package/src/fonts/lobster-two.ts +50 -0
- package/src/fonts/lobster.ts +56 -0
- package/src/fonts/lora.ts +235 -0
- package/src/fonts/m-plus-rounded-1c.ts +138 -0
- package/src/fonts/manrope.ts +109 -0
- package/src/fonts/maven-pro.ts +66 -0
- package/src/fonts/merriweather-sans.ts +142 -0
- package/src/fonts/merriweather.ts +171 -0
- package/src/fonts/montserrat-alternates.ts +171 -0
- package/src/fonts/montserrat.ts +94 -0
- package/src/fonts/mukta.ts +66 -0
- package/src/fonts/mulish.ts +170 -0
- package/src/fonts/nanum-gothic.ts +34 -0
- package/src/fonts/nanum-myeongjo.ts +34 -0
- package/src/fonts/neuton.ts +66 -0
- package/src/fonts/newsreader.ts +114 -0
- package/src/fonts/noticia-text.ts +114 -0
- package/src/fonts/noto-sans-arabic.ts +58 -0
- package/src/fonts/noto-sans-hk.ts +49 -0
- package/src/fonts/noto-sans-jp.ts +49 -0
- package/src/fonts/noto-sans-kr.ts +49 -0
- package/src/fonts/noto-sans-sc.ts +49 -0
- package/src/fonts/noto-sans-tc.ts +49 -0
- package/src/fonts/noto-sans.ts +140 -0
- package/src/fonts/noto-serif-jp.ts +80 -0
- package/src/fonts/noto-serif.ts +140 -0
- package/src/fonts/nunito-sans.ts +170 -0
- package/src/fonts/nunito.ts +170 -0
- package/src/fonts/old-standard-tt.ts +133 -0
- package/src/fonts/onest.ts +48 -0
- package/src/fonts/open-sans.ts +340 -0
- package/src/fonts/orbitron.ts +34 -0
- package/src/fonts/oswald.ts +94 -0
- package/src/fonts/outfit.ts +34 -0
- package/src/fonts/overpass.ts +94 -0
- package/src/fonts/oxygen.ts +50 -0
- package/src/fonts/pacifico.ts +56 -0
- package/src/fonts/passion-one.ts +50 -0
- package/src/fonts/pathway-gothic-one.ts +34 -0
- package/src/fonts/permanent-marker.ts +26 -0
- package/src/fonts/philosopher.ts +172 -0
- package/src/fonts/play.ts +109 -0
- package/src/fonts/playfair-display.ts +142 -0
- package/src/fonts/plus-jakarta-sans.ts +142 -0
- package/src/fonts/poppins.ts +114 -0
- package/src/fonts/prompt.ts +142 -0
- package/src/fonts/pt-sans-narrow.ts +78 -0
- package/src/fonts/pt-sans.ts +138 -0
- package/src/fonts/pt-serif.ts +138 -0
- package/src/fonts/public-sans.ts +66 -0
- package/src/fonts/questrial.ts +42 -0
- package/src/fonts/quicksand.ts +66 -0
- package/src/fonts/rajdhani.ts +66 -0
- package/src/fonts/raleway.ts +94 -0
- package/src/fonts/recursive.ts +80 -0
- package/src/fonts/red-hat-display.ts +82 -0
- package/src/fonts/red-rose.ts +66 -0
- package/src/fonts/registry.gen.ts +173 -0
- package/src/fonts/righteous.ts +34 -0
- package/src/fonts/roboto-condensed.ts +124 -0
- package/src/fonts/roboto-flex.ts +64 -0
- package/src/fonts/roboto-mono.ts +199 -0
- package/src/fonts/roboto-slab.ts +72 -0
- package/src/fonts/roboto.ts +156 -0
- package/src/fonts/rokkitt.ts +66 -0
- package/src/fonts/rubik.ts +199 -0
- package/src/fonts/sacramento.ts +34 -0
- package/src/fonts/saira.ts +66 -0
- package/src/fonts/sarabun.ts +142 -0
- package/src/fonts/satisfy.ts +26 -0
- package/src/fonts/sawarabi-mincho.ts +41 -0
- package/src/fonts/shadows-into-light.ts +34 -0
- package/src/fonts/signika-negative.ts +66 -0
- package/src/fonts/signika.ts +66 -0
- package/src/fonts/slabo-27px.ts +34 -0
- package/src/fonts/sora.ts +50 -0
- package/src/fonts/source-code-pro.ts +228 -0
- package/src/fonts/source-sans-3.ts +228 -0
- package/src/fonts/source-serif-4.ts +199 -0
- package/src/fonts/space-grotesk.ts +66 -0
- package/src/fonts/space-mono.ts +114 -0
- package/src/fonts/spectral.ts +170 -0
- package/src/fonts/tajawal.ts +50 -0
- package/src/fonts/teko.ts +66 -0
- package/src/fonts/tenor-sans.ts +41 -0
- package/src/fonts/tinos.ts +256 -0
- package/src/fonts/titillium-web.ts +82 -0
- package/src/fonts/types.ts +43 -0
- package/src/fonts/ubuntu-condensed.ts +63 -0
- package/src/fonts/ubuntu-mono.ts +195 -0
- package/src/fonts/ubuntu.ts +195 -0
- package/src/fonts/unbounded.ts +94 -0
- package/src/fonts/unicode-ranges.gen.ts +28 -0
- package/src/fonts/urbanist.ts +50 -0
- package/src/fonts/varela-round.ts +49 -0
- package/src/fonts/vollkorn.ts +199 -0
- package/src/fonts/work-sans.ts +66 -0
- package/src/fonts/yanone-kaffeesatz.ts +128 -0
- package/src/fonts/yantramanav.ts +66 -0
- package/src/fonts/yellowtail.ts +34 -0
- package/src/fonts/young-serif.ts +34 -0
- package/src/fonts/zen-kaku-gothic-new.ts +64 -0
- package/src/fonts/zilla-slab.ts +82 -0
- package/src/form/index.tsx +205 -0
- package/src/generators/index.ts +1589 -0
- package/src/head-client/index.ts +649 -0
- package/src/history/index.ts +210 -0
- package/src/hooks.ts +61 -0
- package/src/hydrate/index.tsx +381 -0
- package/src/hydration/index.ts +187 -0
- package/src/i18n/index.ts +250 -0
- package/src/i18n/jsx.tsx +42 -0
- package/src/i18n/message-format.ts +174 -0
- package/src/image/index.tsx +473 -0
- package/src/image.ts +12 -0
- package/src/intercept-outlet/index.tsx +18 -0
- package/src/intercept-outlet.ts +2 -0
- package/src/internal/index.ts +143 -0
- package/src/internal/once-idle.ts +24 -0
- package/src/internal.ts +12 -0
- package/src/lazy/index.tsx +205 -0
- package/src/link/index.tsx +367 -0
- package/src/loader-pipeline/index.ts +639 -0
- package/src/locale/index.tsx +137 -0
- package/src/locale.ts +3 -0
- package/src/logger.ts +22 -0
- package/src/middleware/builtins/api-proxy.ts +64 -0
- package/src/middleware/builtins/cdn-proxy.ts +58 -0
- package/src/middleware/builtins/i18n.ts +256 -0
- package/src/middleware/builtins/keepalive.ts +24 -0
- package/src/middleware/builtins/markdown-negotiation.ts +141 -0
- package/src/middleware/builtins/static-assets.ts +32 -0
- package/src/middleware/index.ts +223 -0
- package/src/module-graph/index.ts +181 -0
- package/src/mount/index.ts +146 -0
- package/src/navigate.ts +1 -0
- package/src/navigation/index.ts +1278 -0
- package/src/navigation/types.ts +59 -0
- package/src/navigation-progress/index.tsx +247 -0
- package/src/navigation-progress.ts +1 -0
- package/src/ndjson-client/index.ts +376 -0
- package/src/ndjson-server/index.ts +284 -0
- package/src/outlet/index.tsx +633 -0
- package/src/outlet/types.ts +243 -0
- package/src/plugins/css-transform.ts +70 -0
- package/src/plugins/dev-cdn-cache.ts +454 -0
- package/src/plugins/dev-dashboard/client.tsx +2603 -0
- package/src/plugins/dev-dashboard/match-route-tree.ts +159 -0
- package/src/plugins/dev-dashboard/plugin.ts +404 -0
- package/src/plugins/dev-prerender.ts +168 -0
- package/src/plugins/dev-server.ts +327 -0
- package/src/plugins/generate-watch.ts +32 -0
- package/src/plugins/image-plugin.ts +171 -0
- package/src/plugins/index.ts +445 -0
- package/src/plugins/options.ts +37 -0
- package/src/plugins/parse-utils.ts +59 -0
- package/src/plugins/prerender-plugin.ts +144 -0
- package/src/plugins/purge.ts +242 -0
- package/src/plugins/server-fn.ts +641 -0
- package/src/plugins/service-worker.ts +225 -0
- package/src/plugins/solid-jsx.d.ts +12 -0
- package/src/plugins/sx-ast/atomic.ts +166 -0
- package/src/plugins/sx-ast/evaluator.ts +235 -0
- package/src/plugins/sx-ast/index.ts +372 -0
- package/src/plugins/sx-ast/parser.ts +22 -0
- package/src/plugins/sx-ast/rewrite.ts +763 -0
- package/src/plugins/sx.d.ts +14 -0
- package/src/plugins/tw-compile.ts +240 -0
- package/src/plugins/types.ts +41 -0
- package/src/plugins/virtual.d.ts +110 -0
- package/src/plugins/virtual.ts +184 -0
- package/src/preload/index.ts +77 -0
- package/src/prerender/index.ts +431 -0
- package/src/query-client/index.tsx +168 -0
- package/src/registry/index.ts +88 -0
- package/src/reset-css.ts +1 -0
- package/src/revalidation/index.ts +84 -0
- package/src/rewrite/index.ts +83 -0
- package/src/route-builder/create-layout.ts +655 -0
- package/src/route-builder/create-page.ts +806 -0
- package/src/route-builder/create-path-segment.ts +34 -0
- package/src/route-builder/create-root-layout.ts +608 -0
- package/src/route-builder/index.ts +49 -0
- package/src/route-builder/register.ts +242 -0
- package/src/route-builder/types.ts +294 -0
- package/src/router-config/index.ts +91 -0
- package/src/router-primitives/index.ts +25 -0
- package/src/router-primitives/location.ts +29 -0
- package/src/router-primitives/match-id.ts +111 -0
- package/src/router-primitives/paths.ts +109 -0
- package/src/router-primitives/tree.ts +243 -0
- package/src/router-primitives/types.ts +74 -0
- package/src/router.ts +31 -0
- package/src/search-engine/bing.ts +32 -0
- package/src/search-engine/google-jwt.ts +95 -0
- package/src/search-engine/google.ts +139 -0
- package/src/search-engine/index-now.ts +64 -0
- package/src/search-engine/index.ts +12 -0
- package/src/security/index.ts +230 -0
- package/src/server/index.ts +286 -0
- package/src/server-context/browser.ts +113 -0
- package/src/server-context/index.ts +217 -0
- package/src/server-fn/index.ts +710 -0
- package/src/server-fn-query.ts +169 -0
- package/src/server-handler/etag.ts +51 -0
- package/src/server-handler/headers.ts +5 -0
- package/src/server-handler/index.ts +1664 -0
- package/src/server-handler/types.ts +24 -0
- package/src/server-handler/validate-static-params.ts +130 -0
- package/src/server.ts +2 -0
- package/src/service-worker/template.ts +153 -0
- package/src/service-worker-hook/index.ts +70 -0
- package/src/sitemap/index.ts +231 -0
- package/src/ssr/critical-css.ts +150 -0
- package/src/ssr/head-prefix.ts +65 -0
- package/src/ssr/head.ts +259 -0
- package/src/ssr/index.tsx +879 -0
- package/src/state-parser/index.ts +266 -0
- package/src/store/filesystem.ts +131 -0
- package/src/store/index.ts +29 -0
- package/src/styles/index.ts +1200 -0
- package/src/styles/sx-types.ts +11 -0
- package/src/testing/index.ts +251 -0
- package/src/testing.ts +2 -0
- package/src/theme/index.tsx +232 -0
- package/src/theme.ts +3 -0
- package/src/tracing/index.ts +4 -0
- package/src/tracing/noop.ts +13 -0
- package/src/tracing/otel.ts +37 -0
- package/src/tracing/timing.ts +34 -0
- package/src/tracing/types.ts +9 -0
- package/src/url/index.ts +140 -0
- package/src/use-router.ts +11 -0
- package/src/use-suspense-query.ts +2 -0
- package/src/validation/index.ts +57 -0
- package/src/view-transition-css.ts +1 -0
|
@@ -0,0 +1,1664 @@
|
|
|
1
|
+
import clientEntryPath from "virtual:flare-client-entry";
|
|
2
|
+
import { layoutModuleIds } from "virtual:flare-generated";
|
|
3
|
+
import virtualIsDev from "virtual:flare-is-dev";
|
|
4
|
+
/* `__FLARE_IS_DEV__` is injected as a Vite `define` by Flare's virtual plugin — reliable across
|
|
5
|
+
all environments including cf-vite-plugin's SSR worker (where `import.meta.env.DEV` is forced
|
|
6
|
+
to false because the SSR env defaults to `mode: "production"` even during `vite serve`).
|
|
7
|
+
Tests mock `virtual:flare-is-dev` and never get the define — fall back to that module. */
|
|
8
|
+
declare const __FLARE_IS_DEV__: boolean | undefined;
|
|
9
|
+
function readIsDev(): boolean {
|
|
10
|
+
return typeof __FLARE_IS_DEV__ === "boolean" ? __FLARE_IS_DEV__ : virtualIsDev;
|
|
11
|
+
}
|
|
12
|
+
import { clientManifest, entryPreloads } from "virtual:flare-module-preloads";
|
|
13
|
+
import { sxManifest } from "virtual:flare-sx-manifest";
|
|
14
|
+
import serverFnsDefault from "virtual:flare-server-fn-map";
|
|
15
|
+
import { disableFetchDedupe, enableFetchDedupe } from "../dedupe/index.ts";
|
|
16
|
+
import { parseSeconds } from "../duration/index.ts";
|
|
17
|
+
import {
|
|
18
|
+
isNotFoundError,
|
|
19
|
+
isRedirectResponse,
|
|
20
|
+
isServerFnValidationError,
|
|
21
|
+
isUnauthenticatedError,
|
|
22
|
+
isUnauthorizedError,
|
|
23
|
+
NotFoundError,
|
|
24
|
+
type RedirectResponse,
|
|
25
|
+
} from "../errors/index.ts";
|
|
26
|
+
import { registerFormActionContextGetter } from "../form/index.tsx";
|
|
27
|
+
import type { AuthenticateFn, AuthenticateFnContext, PipelineMatch, ResolvedRoute } from "../loader-pipeline/index.ts";
|
|
28
|
+
import { runPipeline } from "../loader-pipeline/index.ts";
|
|
29
|
+
import { error as logError, warn } from "../logger.ts";
|
|
30
|
+
import type { MiddlewareEntry, RequestType, ResponseHandler } from "../middleware/index.ts";
|
|
31
|
+
import { resolveMiddlewareEntries, runMiddlewares } from "../middleware/index.ts";
|
|
32
|
+
import { mergePreloads, resolveRoutePreloads, type ViteManifest } from "../module-graph/index.ts";
|
|
33
|
+
import { dispatchMount, type MountConfig, matchMount } from "../mount/index.ts";
|
|
34
|
+
import { setRewrite } from "../navigation/index.ts";
|
|
35
|
+
import {
|
|
36
|
+
createNDJSONResponse,
|
|
37
|
+
createRedirectNDJSONResponse,
|
|
38
|
+
createStreamingNDJSONResponse,
|
|
39
|
+
} from "../ndjson-server/index.ts";
|
|
40
|
+
import { extractNonce, NONCE_PLACEHOLDER, replaceNonce } from "../prerender/index.ts";
|
|
41
|
+
import { createRevalidateFn } from "../revalidation/index.ts";
|
|
42
|
+
import { composeRewrites, executeRewriteInput, type LocationRewrite, rewriteBasePath } from "../rewrite/index.ts";
|
|
43
|
+
import type { CacheConfig, ResponseHeaders } from "../route-builder/types.ts";
|
|
44
|
+
import type { MarkedRouterConfig } from "../router-config/index.ts";
|
|
45
|
+
import type { RouteData } from "../router-primitives/index.ts";
|
|
46
|
+
import {
|
|
47
|
+
deriveLayouts,
|
|
48
|
+
isRootLayoutPath,
|
|
49
|
+
matchRoute,
|
|
50
|
+
matchRoutePartial,
|
|
51
|
+
toLocaleMatch,
|
|
52
|
+
} from "../router-primitives/index.ts";
|
|
53
|
+
import { submitUrlsToBing } from "../search-engine/bing.ts";
|
|
54
|
+
import { submitSitemapToGoogle } from "../search-engine/google.ts";
|
|
55
|
+
import type { ServiceAccountCredentials } from "../search-engine/google-jwt.ts";
|
|
56
|
+
import { submitIndexNow } from "../search-engine/index-now.ts";
|
|
57
|
+
import type { SecurityConfig, SecurityContext } from "../security/index.ts";
|
|
58
|
+
import { buildSecurityHeaders } from "../security/index.ts";
|
|
59
|
+
import {
|
|
60
|
+
background,
|
|
61
|
+
generateNonce,
|
|
62
|
+
getFormActionContext,
|
|
63
|
+
getServerContext,
|
|
64
|
+
getServerLogs,
|
|
65
|
+
runWithServerContext,
|
|
66
|
+
serverLog,
|
|
67
|
+
setFormActionContext,
|
|
68
|
+
} from "@lovrozagar/flare/server-context";
|
|
69
|
+
import { formDataToObject, handleServerFnRequest } from "../server-fn/index.ts";
|
|
70
|
+
import { applyResponseHeaders, mergeResponseHeaders, renderToStream, type SSRConfig } from "../ssr/index.tsx";
|
|
71
|
+
import type { StaticEntryData } from "../store/index.ts";
|
|
72
|
+
import { noopTracer } from "../tracing/noop.ts";
|
|
73
|
+
import { buildServerTimingHeader, createTimingTracer, type TimingTracer } from "../tracing/timing.ts";
|
|
74
|
+
import type { FlareTracer } from "../tracing/types.ts";
|
|
75
|
+
import { computeEtag } from "./etag.ts";
|
|
76
|
+
import { FLARE_CACHE_HEADER, FLARE_RENDER_HEADER, type FlareCacheStatus, type FlareRenderMode } from "./headers.ts";
|
|
77
|
+
import type { CspDirectives, TracingConfig } from "./types.ts";
|
|
78
|
+
import { extractParamsFn, validateStaticParams } from "./validate-static-params.ts";
|
|
79
|
+
|
|
80
|
+
export { FLARE_CACHE_HEADER, FLARE_RENDER_HEADER };
|
|
81
|
+
export type { CspDirectives, FlareCacheStatus, FlareRenderMode, TracingConfig };
|
|
82
|
+
|
|
83
|
+
interface ResolvedQueryClient {
|
|
84
|
+
provider: (props: { children: unknown; client: unknown }) => unknown;
|
|
85
|
+
queryClient: unknown;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let _cachedQCProvider: ResolvedQueryClient["provider"] | undefined;
|
|
89
|
+
|
|
90
|
+
async function resolveQueryClient(router: MarkedRouterConfig): Promise<ResolvedQueryClient | undefined> {
|
|
91
|
+
if (!router.queryClientGetter) return undefined;
|
|
92
|
+
const queryClient = router.queryClientGetter();
|
|
93
|
+
if (!_cachedQCProvider) {
|
|
94
|
+
const mod = await import("../query-client");
|
|
95
|
+
_cachedQCProvider = mod.QueryClientProvider as ResolvedQueryClient["provider"];
|
|
96
|
+
}
|
|
97
|
+
return { provider: _cachedQCProvider, queryClient };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const SECURITY_HEADERS: Record<string, string> = {
|
|
101
|
+
"Cross-Origin-Opener-Policy": "same-origin-allow-popups",
|
|
102
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
103
|
+
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
|
|
104
|
+
"X-Content-Type-Options": "nosniff",
|
|
105
|
+
"X-Frame-Options": "DENY",
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export function normalizeUrl(url: URL, trailingSlash: "always" | "never" | "preserve" = "never"): Response | null {
|
|
109
|
+
if (trailingSlash === "never" && url.pathname !== "/" && url.pathname.endsWith("/")) {
|
|
110
|
+
const target = url.pathname.slice(0, -1) + url.search + url.hash;
|
|
111
|
+
return new Response(null, {
|
|
112
|
+
headers: { Location: target },
|
|
113
|
+
status: 301,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (trailingSlash === "always" && url.pathname !== "/" && !url.pathname.endsWith("/")) {
|
|
118
|
+
const target = `${url.pathname}/${url.search}${url.hash}`;
|
|
119
|
+
return new Response(null, {
|
|
120
|
+
headers: { Location: target },
|
|
121
|
+
status: 301,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* 'strict-dynamic' is intentionally omitted from script-src.
|
|
129
|
+
*
|
|
130
|
+
* Chrome bug (crbug.com/702612): strict-dynamic prevents the browser
|
|
131
|
+
* from setting isLinkPreload=true on <link rel="modulepreload"> fetches.
|
|
132
|
+
* Without that flag, Lighthouse reports all JS in the critical request
|
|
133
|
+
* chain ("Avoid chaining critical requests" warning). The root cause is
|
|
134
|
+
* that strict-dynamic nullifies 'self' and host sources, and Chrome's
|
|
135
|
+
* CSP nonce propagation is broken for <link> elements (only works on
|
|
136
|
+
* <script>). The modulepreload fetch gets blocked or downgraded.
|
|
137
|
+
*
|
|
138
|
+
* 'nonce-xxx' + 'self' provides equivalent protection for SSR frameworks:
|
|
139
|
+
* - nonce: prevents XSS injection of inline scripts
|
|
140
|
+
* - 'self': restricts all script sources to same origin
|
|
141
|
+
* - object-src 'none': closes plugin-based XSS vectors
|
|
142
|
+
* The only lost capability is trust propagation to dynamically-created
|
|
143
|
+
* scripts, which Flare doesn't need (we control all script output). */
|
|
144
|
+
const CSP_DEFAULTS: Record<string, string[] | boolean> = {
|
|
145
|
+
"base-uri": ["'self'"],
|
|
146
|
+
"connect-src": ["'self'", "https:"],
|
|
147
|
+
"default-src": ["'self'"],
|
|
148
|
+
"img-src": ["'self'", "data:", "https:"],
|
|
149
|
+
"object-src": ["'none'"],
|
|
150
|
+
"script-src": ["'self'"],
|
|
151
|
+
"style-src": ["'self'", "'unsafe-inline'"],
|
|
152
|
+
"upgrade-insecure-requests": true,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export function buildCspHeader(nonce: string, overrides?: CspDirectives, isDev?: boolean): string {
|
|
156
|
+
const directives: Record<string, string[] | boolean> = {};
|
|
157
|
+
|
|
158
|
+
for (const [key, value] of Object.entries(CSP_DEFAULTS)) {
|
|
159
|
+
directives[key] = Array.isArray(value) ? [...value] : value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (isDev) {
|
|
163
|
+
const connectSrc = directives["connect-src"] as string[];
|
|
164
|
+
connectSrc.push("ws://localhost:*", "http://localhost:*");
|
|
165
|
+
|
|
166
|
+
const scriptSrc = directives["script-src"] as string[];
|
|
167
|
+
scriptSrc.push("'unsafe-inline'", "'unsafe-eval'");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (overrides) {
|
|
171
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
172
|
+
if (value === undefined) continue;
|
|
173
|
+
if (typeof value === "boolean") {
|
|
174
|
+
directives[key] = value;
|
|
175
|
+
} else if (Array.isArray(value)) {
|
|
176
|
+
const existing = directives[key];
|
|
177
|
+
if (Array.isArray(existing)) {
|
|
178
|
+
existing.push(...value);
|
|
179
|
+
} else {
|
|
180
|
+
directives[key] = [...value];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const scriptSrc = directives["script-src"];
|
|
187
|
+
if (Array.isArray(scriptSrc)) {
|
|
188
|
+
scriptSrc.push(`'nonce-${nonce}'`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const parts: string[] = [];
|
|
192
|
+
for (const [key, value] of Object.entries(directives)) {
|
|
193
|
+
if (value === true) {
|
|
194
|
+
parts.push(key);
|
|
195
|
+
} else if (Array.isArray(value)) {
|
|
196
|
+
parts.push(`${key} ${value.join(" ")}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return parts.join("; ");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const SKIP_STATIC_HEADERS = new Set([
|
|
204
|
+
"connection",
|
|
205
|
+
"content-encoding",
|
|
206
|
+
"content-length",
|
|
207
|
+
"keep-alive",
|
|
208
|
+
"transfer-encoding",
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
function sanitizeStaticHeaders(headers: Record<string, string>): Record<string, string> {
|
|
212
|
+
const result: Record<string, string> = {};
|
|
213
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
214
|
+
if (SKIP_STATIC_HEADERS.has(key.toLowerCase())) continue;
|
|
215
|
+
result[key] = value;
|
|
216
|
+
}
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function headersToStaticRecord(headers: Headers): Record<string, string> {
|
|
221
|
+
const result: Record<string, string> = {};
|
|
222
|
+
headers.forEach((value, key) => {
|
|
223
|
+
if (SKIP_STATIC_HEADERS.has(key.toLowerCase())) return;
|
|
224
|
+
result[key] = value;
|
|
225
|
+
});
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* ── Types ────────────────────────────────────────────────────────────── */
|
|
230
|
+
|
|
231
|
+
export interface ServerHandler<TEnv = unknown> {
|
|
232
|
+
fetch(request: Request, env: TEnv): Promise<Response>;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface CdnPurgeAdapter {
|
|
236
|
+
purgeByKeys?(keys: string[], callerData?: unknown): Promise<void>;
|
|
237
|
+
purgeByTags(tags: string[], callerData?: unknown): Promise<void>;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface HandlerCacheConfig<TEnv = unknown> {
|
|
241
|
+
cdn?: CdnPurgeAdapter | ((env: TEnv) => CdnPurgeAdapter);
|
|
242
|
+
headers?: boolean;
|
|
243
|
+
isr?: { revalidate?: import("../duration").Duration };
|
|
244
|
+
revalidateSecret?: string | ((env: TEnv) => string);
|
|
245
|
+
ssr?: import("../route-builder/types").SsrCacheConfig;
|
|
246
|
+
store?: import("../store").FlareStore | ((env: TEnv) => import("../store").FlareStore);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface SitemapSubmitEngines<TEnv = unknown> {
|
|
250
|
+
bing?: {
|
|
251
|
+
apiKey: string | ((env: TEnv) => string);
|
|
252
|
+
siteUrl: string;
|
|
253
|
+
};
|
|
254
|
+
google?: {
|
|
255
|
+
credentials: ServiceAccountCredentials | ((env: TEnv) => ServiceAccountCredentials);
|
|
256
|
+
siteUrl: string;
|
|
257
|
+
};
|
|
258
|
+
indexNow?: {
|
|
259
|
+
host: string;
|
|
260
|
+
key: string | ((env: TEnv) => string);
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface SitemapSubmitConfig<TEnv = unknown> {
|
|
265
|
+
engines: SitemapSubmitEngines<TEnv>;
|
|
266
|
+
secret: string | ((env: TEnv) => string);
|
|
267
|
+
sitemapUrl: string;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface ServerHandlerConfig<
|
|
271
|
+
TAuth = unknown,
|
|
272
|
+
TEnv = unknown,
|
|
273
|
+
TServerContext extends Record<string, unknown> = Record<string, unknown>,
|
|
274
|
+
> {
|
|
275
|
+
authenticateFn?: (ctx: AuthenticateFnContext<TEnv>) => TAuth | null | Promise<TAuth | null>;
|
|
276
|
+
cache?: HandlerCacheConfig<TEnv>;
|
|
277
|
+
csp?: CspDirectives;
|
|
278
|
+
dedupeFetch?: boolean;
|
|
279
|
+
securityConfig?: SecurityConfig | ((ctx: SecurityContext) => SecurityConfig);
|
|
280
|
+
middlewareEntries?: MiddlewareEntry<TEnv>[];
|
|
281
|
+
mount?: MountConfig<TEnv> | MountConfig<TEnv>[];
|
|
282
|
+
router: MarkedRouterConfig;
|
|
283
|
+
serverContext?: (ctx: { env: TEnv; request: Request }) => TServerContext | Promise<TServerContext>;
|
|
284
|
+
/** Forward server logs to browser devtools. Default: true in dev, false in prod. */
|
|
285
|
+
serverLogs?: boolean;
|
|
286
|
+
sitemap?: SitemapSubmitConfig<TEnv>;
|
|
287
|
+
tracing?: import("./types").TracingConfig;
|
|
288
|
+
/**
|
|
289
|
+
* Platform-agnostic background work adapter.
|
|
290
|
+
* Called with a promise that should outlive the current request.
|
|
291
|
+
* - Cloudflare Workers: `ctx.waitUntil`
|
|
292
|
+
* - Node/Bun: fire-and-forget (or custom tracking)
|
|
293
|
+
*/
|
|
294
|
+
waitUntil?: (promise: Promise<unknown>) => void;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* ── Fallback pages ───────────────────────────────────────────────────── */
|
|
298
|
+
|
|
299
|
+
const FALLBACK_404 =
|
|
300
|
+
'<!doctype html><html lang="en"><head><title>Not Found</title></head><body><h1>404</h1><p>Not Found</p></body></html>';
|
|
301
|
+
const FALLBACK_401 =
|
|
302
|
+
'<!doctype html><html lang="en"><head><title>Unauthorized</title></head><body><h1>401</h1><p>Unauthorized</p></body></html>';
|
|
303
|
+
const FALLBACK_403 =
|
|
304
|
+
'<!doctype html><html lang="en"><head><title>Forbidden</title></head><body><h1>403</h1><p>Forbidden</p></body></html>';
|
|
305
|
+
const FALLBACK_500 =
|
|
306
|
+
'<!doctype html><html lang="en"><head><title>Server Error</title></head><body><h1>500</h1><p>Internal Server Error</p></body></html>';
|
|
307
|
+
|
|
308
|
+
function unmatchedNdjson404(): Response {
|
|
309
|
+
const body = `${JSON.stringify({
|
|
310
|
+
e: { message: "Not found", name: "NotFoundError" },
|
|
311
|
+
t: "e",
|
|
312
|
+
})}\n${JSON.stringify({ t: "r" })}\n${JSON.stringify({ t: "d" })}\n`;
|
|
313
|
+
return new Response(body, {
|
|
314
|
+
headers: {
|
|
315
|
+
"Cache-Control": "no-store",
|
|
316
|
+
"Content-Type": "application/x-ndjson",
|
|
317
|
+
},
|
|
318
|
+
status: 404,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function fallbackHtmlResponse(html: string, status: number, secHeaders: Record<string, string>): Response {
|
|
323
|
+
return new Response(html, {
|
|
324
|
+
headers: {
|
|
325
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
326
|
+
...secHeaders,
|
|
327
|
+
},
|
|
328
|
+
status,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function escapeHtml(str: string): string {
|
|
333
|
+
return str
|
|
334
|
+
.replace(/&/g, "&")
|
|
335
|
+
.replace(/</g, "<")
|
|
336
|
+
.replace(/>/g, ">")
|
|
337
|
+
.replace(/"/g, """)
|
|
338
|
+
.replace(/'/g, "'");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function buildDevErrorHtml(error: unknown): string {
|
|
342
|
+
if (error instanceof Error) {
|
|
343
|
+
const msg = escapeHtml(error.message);
|
|
344
|
+
const stack = escapeHtml(error.stack ?? "");
|
|
345
|
+
return `<!doctype html><html lang="en"><head><title>Server Error</title></head><body><h1>500</h1><p>${msg}</p><pre>${stack}</pre></body></html>`;
|
|
346
|
+
}
|
|
347
|
+
return FALLBACK_500;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/* ── Helpers ──────────────────────────────────────────────────────────── */
|
|
351
|
+
|
|
352
|
+
function addSecurityHeaders(response: Response, secHeaders: Record<string, string>): Response {
|
|
353
|
+
const headers = new Headers(response.headers);
|
|
354
|
+
|
|
355
|
+
for (const [key, value] of Object.entries(secHeaders)) {
|
|
356
|
+
if (!headers.has(key)) {
|
|
357
|
+
headers.set(key, value);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* Ensure Vary includes x-d — merge with any existing Vary from cdn.vary config */
|
|
362
|
+
const existing = headers.get("Vary");
|
|
363
|
+
if (existing) {
|
|
364
|
+
const parts = existing.split(",").map((v) => v.trim());
|
|
365
|
+
if (!parts.some((v) => v.toLowerCase() === "x-d")) {
|
|
366
|
+
headers.set("Vary", `x-d, ${existing}`);
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
headers.set("Vary", "x-d");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return new Response(response.body, {
|
|
373
|
+
headers,
|
|
374
|
+
status: response.status,
|
|
375
|
+
statusText: response.statusText,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function applyResponseHandlers(response: Response, handlers: ResponseHandler[]): Promise<Response> {
|
|
380
|
+
let result = response;
|
|
381
|
+
for (const handler of handlers) {
|
|
382
|
+
try {
|
|
383
|
+
result = await handler(result);
|
|
384
|
+
} catch (e) {
|
|
385
|
+
warn("middleware", "onResponse handler failed", e);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function findRedirectInMatches(matches: PipelineMatch[]): RedirectResponse | null {
|
|
392
|
+
for (const match of matches) {
|
|
393
|
+
if (match.error && isRedirectResponse(match.error)) {
|
|
394
|
+
return match.error;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function loadRouteModules(
|
|
401
|
+
route: RouteData,
|
|
402
|
+
layouts: Record<string, () => Promise<{ default: unknown }>>,
|
|
403
|
+
): Promise<ResolvedRoute[]> {
|
|
404
|
+
const layoutKeys = deriveLayouts(route.x);
|
|
405
|
+
const loadedKeys: string[] = [];
|
|
406
|
+
const loaders: Array<Promise<{ default: unknown }>> = [];
|
|
407
|
+
|
|
408
|
+
for (const key of layoutKeys) {
|
|
409
|
+
const loader = layouts[key];
|
|
410
|
+
if (loader) {
|
|
411
|
+
loadedKeys.push(key);
|
|
412
|
+
loaders.push(loader());
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const pagePromise = route.p();
|
|
417
|
+
const [pageModule, ...layoutModules] = await Promise.all([pagePromise, ...loaders]);
|
|
418
|
+
|
|
419
|
+
const routes: ResolvedRoute[] = [];
|
|
420
|
+
|
|
421
|
+
for (let i = 0; i < layoutModules.length; i++) {
|
|
422
|
+
const mod = layoutModules[i] as { default: Record<string, unknown> } | undefined;
|
|
423
|
+
const key = loadedKeys[i] ?? "";
|
|
424
|
+
if (mod?.default) {
|
|
425
|
+
const modDefault = mod.default;
|
|
426
|
+
const resolvedType: "layout" | "root-layout" = key.split("/").some((s) => isRootLayoutPath(s))
|
|
427
|
+
? "root-layout"
|
|
428
|
+
: "layout";
|
|
429
|
+
routes.push({
|
|
430
|
+
_type: resolvedType,
|
|
431
|
+
variablePath: key,
|
|
432
|
+
virtualPath: key,
|
|
433
|
+
...modDefault,
|
|
434
|
+
} as ResolvedRoute);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (pageModule?.default) {
|
|
439
|
+
routes.push({
|
|
440
|
+
_type: route.t === "x" ? "response" : "render",
|
|
441
|
+
variablePath: route.v,
|
|
442
|
+
virtualPath: route.x,
|
|
443
|
+
...(pageModule.default as Record<string, unknown>),
|
|
444
|
+
} as ResolvedRoute);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return routes;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Collect source module IDs for the page + all its layouts.
|
|
452
|
+
* Used to populate sxRenderedModules so Show/Switch branch classes are
|
|
453
|
+
* included in critical CSS even when their host elements aren't in the
|
|
454
|
+
* initial SSR output.
|
|
455
|
+
*/
|
|
456
|
+
function collectRouteModuleIds(route: RouteData): string[] {
|
|
457
|
+
const ids: string[] = [];
|
|
458
|
+
if (route.f) ids.push(route.f);
|
|
459
|
+
const layoutKeys = deriveLayouts(route.x);
|
|
460
|
+
for (const key of layoutKeys) {
|
|
461
|
+
const moduleId = (layoutModuleIds as Record<string, string>)[key];
|
|
462
|
+
if (moduleId) ids.push(moduleId);
|
|
463
|
+
}
|
|
464
|
+
return ids;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Compute modulepreload hints for a matched route chain.
|
|
469
|
+
* Collects module IDs for the page + all its layouts, then resolves
|
|
470
|
+
* their transitive chunk dependencies from the Vite client manifest.
|
|
471
|
+
*/
|
|
472
|
+
function computeRoutePreloads(route: RouteData): { css: string[]; js: string[] } | undefined {
|
|
473
|
+
if (!clientManifest) return undefined;
|
|
474
|
+
|
|
475
|
+
const moduleIds: string[] = [];
|
|
476
|
+
|
|
477
|
+
/* Page module ID */
|
|
478
|
+
if (route.f) {
|
|
479
|
+
moduleIds.push(route.f);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/* Layout module IDs from the route chain */
|
|
483
|
+
const layoutKeys = deriveLayouts(route.x);
|
|
484
|
+
for (const key of layoutKeys) {
|
|
485
|
+
const moduleId = (layoutModuleIds as Record<string, string>)[key];
|
|
486
|
+
if (moduleId) {
|
|
487
|
+
moduleIds.push(moduleId);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (moduleIds.length === 0) return undefined;
|
|
492
|
+
|
|
493
|
+
const routePreloads = resolveRoutePreloads(
|
|
494
|
+
clientManifest as ViteManifest,
|
|
495
|
+
moduleIds,
|
|
496
|
+
entryPreloads as { css: string[]; js: string[] },
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
return mergePreloads(entryPreloads as { css: string[]; js: string[] }, routePreloads);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/* ── Revalidation endpoint ────────────────────────────────────────────── */
|
|
503
|
+
|
|
504
|
+
const VALID_TIERS = new Set(["cdn", "ssr"]);
|
|
505
|
+
|
|
506
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
507
|
+
const len = Math.max(a.length, b.length);
|
|
508
|
+
let mismatch = a.length ^ b.length;
|
|
509
|
+
for (let i = 0; i < len; i++) {
|
|
510
|
+
mismatch |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
|
|
511
|
+
}
|
|
512
|
+
return mismatch === 0;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function jsonResponse(body: Record<string, unknown>, status: number): Response {
|
|
516
|
+
return new Response(JSON.stringify(body), {
|
|
517
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
518
|
+
status,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function handleRevalidateRequest<TEnv>(
|
|
523
|
+
request: Request,
|
|
524
|
+
_url: URL,
|
|
525
|
+
env: TEnv,
|
|
526
|
+
cacheConfig: HandlerCacheConfig<TEnv>,
|
|
527
|
+
): Promise<Response> {
|
|
528
|
+
/* Only POST allowed — GET leaks secret in URL (access logs, Referer, browser history) */
|
|
529
|
+
if (request.method !== "POST") {
|
|
530
|
+
return jsonResponse({ error: "Method not allowed" }, 405);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (!cacheConfig.revalidateSecret) {
|
|
534
|
+
return jsonResponse({ error: "Revalidation not configured" }, 500);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const expectedSecret =
|
|
538
|
+
typeof cacheConfig.revalidateSecret === "function"
|
|
539
|
+
? cacheConfig.revalidateSecret(env)
|
|
540
|
+
: cacheConfig.revalidateSecret;
|
|
541
|
+
|
|
542
|
+
const providedSecret = request.headers.get("x-revalidation-secret") ?? "";
|
|
543
|
+
if (!providedSecret || !constantTimeEqual(providedSecret, expectedSecret)) {
|
|
544
|
+
return jsonResponse({ error: "Unauthorized" }, 401);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
let body: Record<string, unknown>;
|
|
548
|
+
try {
|
|
549
|
+
body = (await request.json()) as Record<string, unknown>;
|
|
550
|
+
} catch {
|
|
551
|
+
return jsonResponse({ error: "Invalid JSON body" }, 400);
|
|
552
|
+
}
|
|
553
|
+
const tags = Array.isArray(body.tags) ? body.tags.filter((t): t is string => typeof t === "string") : undefined;
|
|
554
|
+
const keys = Array.isArray(body.keys) ? body.keys.filter((k): k is string => typeof k === "string") : undefined;
|
|
555
|
+
const tiers = Array.isArray(body.tiers) ? body.tiers.filter((t): t is string => typeof t === "string") : undefined;
|
|
556
|
+
|
|
557
|
+
if (!tags?.length && !keys?.length) {
|
|
558
|
+
return jsonResponse({ error: "Must provide tags or keys" }, 400);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (!tiers?.length) {
|
|
562
|
+
return jsonResponse({ error: "Must provide tiers" }, 400);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
for (const tier of tiers) {
|
|
566
|
+
if (!VALID_TIERS.has(tier)) {
|
|
567
|
+
return jsonResponse({ error: `Invalid tier: ${tier}` }, 400);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const resolvedStore = typeof cacheConfig.store === "function" ? cacheConfig.store(env) : cacheConfig.store;
|
|
572
|
+
|
|
573
|
+
let resolvedCdn: CdnPurgeAdapter | undefined;
|
|
574
|
+
if (cacheConfig.cdn) {
|
|
575
|
+
resolvedCdn = typeof cacheConfig.cdn === "function" ? cacheConfig.cdn(env) : cacheConfig.cdn;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const revalidateFn = createRevalidateFn({
|
|
579
|
+
cdnPurgeAdapter: resolvedCdn,
|
|
580
|
+
store: resolvedStore,
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
await revalidateFn({
|
|
584
|
+
keys,
|
|
585
|
+
tags,
|
|
586
|
+
tiers: tiers as Array<"cdn" | "ssr">,
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
return jsonResponse(
|
|
590
|
+
{
|
|
591
|
+
keys: keys ?? [],
|
|
592
|
+
revalidated: true,
|
|
593
|
+
tags: tags ?? [],
|
|
594
|
+
tiers,
|
|
595
|
+
},
|
|
596
|
+
200,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/* ── Sitemap submit endpoint ──────────────────────────────────────────── */
|
|
601
|
+
|
|
602
|
+
interface EngineResult {
|
|
603
|
+
error?: string;
|
|
604
|
+
ok: boolean;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function handleSitemapSubmitRequest<TEnv>(
|
|
608
|
+
request: Request,
|
|
609
|
+
url: URL,
|
|
610
|
+
env: TEnv,
|
|
611
|
+
sitemapConfig: SitemapSubmitConfig<TEnv>,
|
|
612
|
+
): Promise<Response> {
|
|
613
|
+
if (request.method !== "POST") {
|
|
614
|
+
return jsonResponse({ error: "Method not allowed" }, 405);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const expectedSecret = typeof sitemapConfig.secret === "function" ? sitemapConfig.secret(env) : sitemapConfig.secret;
|
|
618
|
+
|
|
619
|
+
const providedSecret = request.headers.get("x-sitemap-secret") ?? "";
|
|
620
|
+
|
|
621
|
+
if (!providedSecret || !constantTimeEqual(providedSecret, expectedSecret)) {
|
|
622
|
+
return jsonResponse({ error: "Unauthorized" }, 401);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/* Optional POST body with URLs for Bing/IndexNow */
|
|
626
|
+
let urls: string[] | undefined;
|
|
627
|
+
if (request.method === "POST") {
|
|
628
|
+
try {
|
|
629
|
+
const body = (await request.json()) as Record<string, unknown>;
|
|
630
|
+
if (Array.isArray(body.urls)) {
|
|
631
|
+
const allowedOrigin = new URL(sitemapConfig.sitemapUrl).origin;
|
|
632
|
+
urls = body.urls
|
|
633
|
+
.filter((u): u is string => typeof u === "string")
|
|
634
|
+
.filter((u) => {
|
|
635
|
+
try {
|
|
636
|
+
return new URL(u).origin === allowedOrigin;
|
|
637
|
+
} catch {
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
} catch {
|
|
643
|
+
/* no body or invalid JSON — fine, sitemap-only submission */
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const engines = sitemapConfig.engines;
|
|
648
|
+
const results: Record<string, EngineResult> = {};
|
|
649
|
+
|
|
650
|
+
/* Google: submit sitemap to Search Console */
|
|
651
|
+
if (engines.google) {
|
|
652
|
+
const credentials =
|
|
653
|
+
typeof engines.google.credentials === "function" ? engines.google.credentials(env) : engines.google.credentials;
|
|
654
|
+
try {
|
|
655
|
+
results.google = await submitSitemapToGoogle({
|
|
656
|
+
credentials,
|
|
657
|
+
siteUrl: engines.google.siteUrl,
|
|
658
|
+
sitemapUrl: sitemapConfig.sitemapUrl,
|
|
659
|
+
});
|
|
660
|
+
} catch (e: unknown) {
|
|
661
|
+
results.google = {
|
|
662
|
+
error: e instanceof Error ? e.message : "Unknown error",
|
|
663
|
+
ok: false,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/* Bing: submit URLs if provided */
|
|
669
|
+
if (engines.bing && urls && urls.length > 0) {
|
|
670
|
+
const apiKey = typeof engines.bing.apiKey === "function" ? engines.bing.apiKey(env) : engines.bing.apiKey;
|
|
671
|
+
try {
|
|
672
|
+
results.bing = await submitUrlsToBing({
|
|
673
|
+
apiKey,
|
|
674
|
+
siteUrl: engines.bing.siteUrl,
|
|
675
|
+
urls,
|
|
676
|
+
});
|
|
677
|
+
} catch (e: unknown) {
|
|
678
|
+
results.bing = { error: e instanceof Error ? e.message : "Unknown error", ok: false };
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/* IndexNow: submit URLs if provided */
|
|
683
|
+
if (engines.indexNow && urls && urls.length > 0) {
|
|
684
|
+
const key = typeof engines.indexNow.key === "function" ? engines.indexNow.key(env) : engines.indexNow.key;
|
|
685
|
+
try {
|
|
686
|
+
results.indexNow = await submitIndexNow({
|
|
687
|
+
host: engines.indexNow.host,
|
|
688
|
+
key,
|
|
689
|
+
urls,
|
|
690
|
+
});
|
|
691
|
+
} catch (e: unknown) {
|
|
692
|
+
results.indexNow = { error: e instanceof Error ? e.message : "Unknown error", ok: false };
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return jsonResponse({ engines: results, submitted: true }, 200);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/* ── getStaticParams helpers ──────────────────────────────────────────── */
|
|
700
|
+
|
|
701
|
+
export type StaticParamsMap = Map<string, Record<string, string | string[]>[]>;
|
|
702
|
+
|
|
703
|
+
function collectDynamicRoutes(node: import("../router-primitives").TreeNode): RouteData[] {
|
|
704
|
+
const results: RouteData[] = [];
|
|
705
|
+
function walk(n: typeof node): void {
|
|
706
|
+
if (n.r?.x.includes("[")) {
|
|
707
|
+
results.push(n.r);
|
|
708
|
+
}
|
|
709
|
+
for (const child of Object.values(n.s)) {
|
|
710
|
+
walk(child);
|
|
711
|
+
}
|
|
712
|
+
if (n.p) walk(n.p);
|
|
713
|
+
if (n.q) walk(n.q);
|
|
714
|
+
if (n.c) walk(n.c);
|
|
715
|
+
if (n.o) walk(n.o);
|
|
716
|
+
}
|
|
717
|
+
walk(node);
|
|
718
|
+
return results;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async function buildGetStaticParams(router: MarkedRouterConfig): Promise<StaticParamsMap> {
|
|
722
|
+
const dynamicRoutes = collectDynamicRoutes(router.routeTree);
|
|
723
|
+
if (dynamicRoutes.length === 0) return new Map();
|
|
724
|
+
|
|
725
|
+
const resultMap: StaticParamsMap = new Map();
|
|
726
|
+
|
|
727
|
+
for (const route of dynamicRoutes) {
|
|
728
|
+
try {
|
|
729
|
+
/* Load page + layout modules */
|
|
730
|
+
const layoutKeys = deriveLayouts(route.x);
|
|
731
|
+
const loadedKeys: string[] = [];
|
|
732
|
+
const layoutPromises: Array<Promise<{ default: unknown }>> = [];
|
|
733
|
+
|
|
734
|
+
for (const key of layoutKeys) {
|
|
735
|
+
const loader = router.layouts[key];
|
|
736
|
+
if (loader) {
|
|
737
|
+
loadedKeys.push(key);
|
|
738
|
+
layoutPromises.push(loader());
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const [pageModule, ...layoutModules] = await Promise.all([route.p(), ...layoutPromises]);
|
|
743
|
+
|
|
744
|
+
/* Build chain: [root-layout, ...layouts, page] in order */
|
|
745
|
+
const chain: Array<{
|
|
746
|
+
mod: Record<string, unknown>;
|
|
747
|
+
virtualPath: string;
|
|
748
|
+
}> = [];
|
|
749
|
+
|
|
750
|
+
for (let i = 0; i < layoutModules.length; i++) {
|
|
751
|
+
const mod = layoutModules[i] as { default: Record<string, unknown> } | undefined;
|
|
752
|
+
const key = loadedKeys[i] ?? "";
|
|
753
|
+
if (mod?.default) {
|
|
754
|
+
chain.push({ mod: mod.default, virtualPath: key });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (pageModule?.default) {
|
|
759
|
+
chain.push({
|
|
760
|
+
mod: pageModule.default as Record<string, unknown>,
|
|
761
|
+
virtualPath: route.x,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/* Compose params level-by-level */
|
|
766
|
+
let currentParams: Record<string, string | string[]>[] = [{}];
|
|
767
|
+
|
|
768
|
+
for (const link of chain) {
|
|
769
|
+
const paramsFn = extractParamsFn(link.mod.cache as CacheConfig | undefined);
|
|
770
|
+
if (!paramsFn) continue;
|
|
771
|
+
|
|
772
|
+
const nextParams: Record<string, string | string[]>[] = [];
|
|
773
|
+
for (const parentParams of currentParams) {
|
|
774
|
+
const produced = await paramsFn({ params: parentParams });
|
|
775
|
+
if (!Array.isArray(produced) || produced.length === 0) continue;
|
|
776
|
+
for (const p of produced) {
|
|
777
|
+
nextParams.push({ ...parentParams, ...p });
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (nextParams.length === 0) {
|
|
782
|
+
currentParams = [];
|
|
783
|
+
break;
|
|
784
|
+
}
|
|
785
|
+
currentParams = nextParams;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (currentParams.length > 0 && Object.keys(currentParams[0] ?? {}).length > 0) {
|
|
789
|
+
resultMap.set(route.x, currentParams);
|
|
790
|
+
}
|
|
791
|
+
} catch (e: unknown) {
|
|
792
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
793
|
+
warn("prerender", `getStaticParams failed for ${route.x}: ${msg}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return resultMap;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/* ── createServerHandler ─────────────────────────────────────────────── */
|
|
801
|
+
|
|
802
|
+
const isrInFlight = new Set<string>();
|
|
803
|
+
const ISR_TIMEOUT = 30_000;
|
|
804
|
+
|
|
805
|
+
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
806
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
807
|
+
return Promise.race([
|
|
808
|
+
promise.finally(() => clearTimeout(timer)),
|
|
809
|
+
new Promise<never>((_, reject) => {
|
|
810
|
+
timer = setTimeout(() => reject(new Error("ISR timeout")), ms);
|
|
811
|
+
}),
|
|
812
|
+
]);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const MAX_REDIRECTS = 5;
|
|
816
|
+
|
|
817
|
+
export function createServerHandler<
|
|
818
|
+
TAuth = unknown,
|
|
819
|
+
TEnv = unknown,
|
|
820
|
+
TServerContext extends Record<string, unknown> = Record<string, unknown>,
|
|
821
|
+
>(
|
|
822
|
+
config: ServerHandlerConfig<TAuth, TEnv, TServerContext>,
|
|
823
|
+
): ServerHandler<TEnv> & {
|
|
824
|
+
readonly authenticateFn?: ServerHandlerConfig<TAuth, TEnv, TServerContext>["authenticateFn"];
|
|
825
|
+
readonly getStaticParams: () => Promise<StaticParamsMap>;
|
|
826
|
+
readonly serverContext?: ServerHandlerConfig<TAuth, TEnv, TServerContext>["serverContext"];
|
|
827
|
+
} {
|
|
828
|
+
registerFormActionContextGetter(getFormActionContext);
|
|
829
|
+
|
|
830
|
+
const { dedupeFetch = true } = config;
|
|
831
|
+
const isDev = readIsDev();
|
|
832
|
+
const serverFns = serverFnsDefault;
|
|
833
|
+
const enableServerLogs = config.serverLogs ?? isDev;
|
|
834
|
+
let mounts: MountConfig<TEnv>[] = [];
|
|
835
|
+
if (config.mount) {
|
|
836
|
+
mounts = Array.isArray(config.mount) ? config.mount : [config.mount];
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/* Resolve tracer tier: OTel provider > explicit timing > dev auto-timing > noop */
|
|
840
|
+
function resolveTracer(tracingCfg?: TracingConfig): FlareTracer {
|
|
841
|
+
if (tracingCfg?.provider) return tracingCfg.provider;
|
|
842
|
+
if (tracingCfg?.timing || isDev) return createTimingTracer();
|
|
843
|
+
return noopTracer;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const baseEntries: MiddlewareEntry<TEnv>[] = config.middlewareEntries ?? [];
|
|
847
|
+
|
|
848
|
+
/* Build composed rewrite: basePath (if any) + user rewrite */
|
|
849
|
+
let composedRewrite: LocationRewrite | undefined;
|
|
850
|
+
const rewrites: LocationRewrite[] = [];
|
|
851
|
+
if (config.router.basePath) {
|
|
852
|
+
rewrites.push(
|
|
853
|
+
rewriteBasePath({
|
|
854
|
+
basePath: config.router.basePath,
|
|
855
|
+
caseSensitive: config.router.caseSensitive,
|
|
856
|
+
}),
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
if (config.router.rewrite) {
|
|
860
|
+
rewrites.push(config.router.rewrite);
|
|
861
|
+
}
|
|
862
|
+
if (rewrites.length === 1) {
|
|
863
|
+
composedRewrite = rewrites[0];
|
|
864
|
+
} else if (rewrites.length > 1) {
|
|
865
|
+
composedRewrite = composeRewrites(rewrites);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const handler: ServerHandler<TEnv> = {
|
|
869
|
+
async fetch(request: Request, env: TEnv): Promise<Response> {
|
|
870
|
+
const nonce = generateNonce();
|
|
871
|
+
const abortController = new AbortController();
|
|
872
|
+
const url = new URL(request.url);
|
|
873
|
+
|
|
874
|
+
const matchedMount = matchMount(mounts, url.pathname);
|
|
875
|
+
if (!matchedMount) {
|
|
876
|
+
const normalized = normalizeUrl(url, config.router.trailingSlash);
|
|
877
|
+
if (normalized) return normalized;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const serverCtx = config.serverContext ? await config.serverContext({ env, request }) : {};
|
|
881
|
+
|
|
882
|
+
const secCfg = config.securityConfig;
|
|
883
|
+
const resolvedSecCfg =
|
|
884
|
+
typeof secCfg === "function"
|
|
885
|
+
? secCfg({ env, nonce, request, serverContext: serverCtx as Record<string, unknown> })
|
|
886
|
+
: secCfg;
|
|
887
|
+
/* Bridge legacy csp config into SecurityConfig when securityConfig omits CSP */
|
|
888
|
+
const mergedSecCfg: SecurityConfig | undefined =
|
|
889
|
+
config.csp && !resolvedSecCfg?.["Content-Security-Policy"]
|
|
890
|
+
? { ...resolvedSecCfg, "Content-Security-Policy": config.csp }
|
|
891
|
+
: resolvedSecCfg;
|
|
892
|
+
const secHeaders = buildSecurityHeaders({ config: mergedSecCfg, isDev, nonce });
|
|
893
|
+
const tracer = resolveTracer(config.tracing);
|
|
894
|
+
|
|
895
|
+
let resolvedStore = typeof config.cache?.store === "function" ? config.cache.store(env) : config.cache?.store;
|
|
896
|
+
if (isDev && !resolvedStore) {
|
|
897
|
+
const { createFileSystemStore } = await import("../store/filesystem");
|
|
898
|
+
resolvedStore = createFileSystemStore();
|
|
899
|
+
}
|
|
900
|
+
let resolvedCdnPurge: CdnPurgeAdapter | undefined;
|
|
901
|
+
if (config.cache?.cdn) {
|
|
902
|
+
resolvedCdnPurge = typeof config.cache.cdn === "function" ? config.cache.cdn(env) : config.cache.cdn;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const response = await runWithServerContext(
|
|
906
|
+
{
|
|
907
|
+
cdnPurgeAdapter: resolvedCdnPurge,
|
|
908
|
+
isDev,
|
|
909
|
+
nonce,
|
|
910
|
+
request,
|
|
911
|
+
serverContext: serverCtx,
|
|
912
|
+
store: resolvedStore,
|
|
913
|
+
waitUntil: config.waitUntil,
|
|
914
|
+
},
|
|
915
|
+
async () => {
|
|
916
|
+
if (dedupeFetch) enableFetchDedupe();
|
|
917
|
+
let redirectCount = 0;
|
|
918
|
+
|
|
919
|
+
try {
|
|
920
|
+
/* Determine request type for middleware context */
|
|
921
|
+
let requestType: RequestType = "page";
|
|
922
|
+
if (matchedMount) {
|
|
923
|
+
requestType = "mount";
|
|
924
|
+
} else if (url.pathname.startsWith("/_fn/")) {
|
|
925
|
+
requestType = "server-fn";
|
|
926
|
+
} else if (url.pathname.startsWith("/_flare/")) {
|
|
927
|
+
requestType = "internal";
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const middlewareCtx = {
|
|
931
|
+
env,
|
|
932
|
+
error: (...args: unknown[]) => serverLog("error", ...args),
|
|
933
|
+
locale: config.router.locale,
|
|
934
|
+
log: (...args: unknown[]) => serverLog("log", ...args),
|
|
935
|
+
nonce,
|
|
936
|
+
onResponse: () => {},
|
|
937
|
+
request,
|
|
938
|
+
requestType,
|
|
939
|
+
routeTree: config.router.routeTree,
|
|
940
|
+
serverContext: serverCtx,
|
|
941
|
+
url,
|
|
942
|
+
warn: (...args: unknown[]) => serverLog("warn", ...args),
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
/* Resolve path-filtered middleware from entries */
|
|
946
|
+
const resolvedMiddlewares = resolveMiddlewareEntries(baseEntries, url.pathname);
|
|
947
|
+
|
|
948
|
+
let responseHandlers: ResponseHandler[] = [];
|
|
949
|
+
|
|
950
|
+
if (resolvedMiddlewares.length > 0) {
|
|
951
|
+
const mwOutput = await runMiddlewares(resolvedMiddlewares, middlewareCtx, tracer);
|
|
952
|
+
responseHandlers = mwOutput.responseHandlers;
|
|
953
|
+
|
|
954
|
+
if (mwOutput.result.type === "bypass") {
|
|
955
|
+
return mwOutput.result.response;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (mwOutput.result.type === "respond") {
|
|
959
|
+
let response = mwOutput.result.response;
|
|
960
|
+
response = await applyResponseHandlers(response, responseHandlers);
|
|
961
|
+
return addSecurityHeaders(response, secHeaders);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (matchedMount) {
|
|
966
|
+
let response = await dispatchMount(request, env, matchedMount, url, {
|
|
967
|
+
waitUntil: config.waitUntil,
|
|
968
|
+
});
|
|
969
|
+
response = await applyResponseHandlers(response, responseHandlers);
|
|
970
|
+
return response;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const serverFnAuthFn = config.authenticateFn
|
|
974
|
+
? (fnEnv: unknown, fnReq: Request) =>
|
|
975
|
+
(config.authenticateFn as AuthenticateFn<TEnv>)({
|
|
976
|
+
env: fnEnv as TEnv,
|
|
977
|
+
request: fnReq,
|
|
978
|
+
serverContext: getServerContext(),
|
|
979
|
+
url: new URL(fnReq.url),
|
|
980
|
+
})
|
|
981
|
+
: undefined;
|
|
982
|
+
|
|
983
|
+
if (url.pathname.startsWith("/_fn/")) {
|
|
984
|
+
const fns = serverFns;
|
|
985
|
+
let response = await handleServerFnRequest(request, env, fns, serverFnAuthFn);
|
|
986
|
+
response = await applyResponseHandlers(response, responseHandlers);
|
|
987
|
+
return addSecurityHeaders(response, secHeaders);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/* PE POST: no-JS <Form> submits to page URL with __flare_fn hidden field */
|
|
991
|
+
if (
|
|
992
|
+
request.method === "POST" &&
|
|
993
|
+
!url.pathname.startsWith("/_fn/") &&
|
|
994
|
+
!url.pathname.startsWith("/_flare/")
|
|
995
|
+
) {
|
|
996
|
+
const ct = request.headers.get("content-type") ?? "";
|
|
997
|
+
if (ct.includes("form")) {
|
|
998
|
+
let formData: FormData;
|
|
999
|
+
try {
|
|
1000
|
+
formData = await request.formData();
|
|
1001
|
+
} catch {
|
|
1002
|
+
return addSecurityHeaders(new Response("Bad Request", { status: 400 }), secHeaders);
|
|
1003
|
+
}
|
|
1004
|
+
const flareFnId = formData.get("__flare_fn");
|
|
1005
|
+
if (typeof flareFnId === "string" && serverFns.has(flareFnId)) {
|
|
1006
|
+
const reg = serverFns.get(flareFnId);
|
|
1007
|
+
if (reg) {
|
|
1008
|
+
const fnUrl = `${url.origin}/_fn/${reg.id}/${reg.name}`;
|
|
1009
|
+
/* Forward headers but drop Content-Type so the runtime
|
|
1010
|
+
* auto-sets the correct multipart boundary for FormData body. */
|
|
1011
|
+
const forwardHeaders = new Headers(request.headers);
|
|
1012
|
+
forwardHeaders.delete("content-type");
|
|
1013
|
+
const fnRequest = new Request(fnUrl, {
|
|
1014
|
+
body: formData,
|
|
1015
|
+
headers: forwardHeaders,
|
|
1016
|
+
method: "POST",
|
|
1017
|
+
});
|
|
1018
|
+
try {
|
|
1019
|
+
const fnResponse = await handleServerFnRequest(fnRequest, env, serverFns, serverFnAuthFn);
|
|
1020
|
+
if (fnResponse.ok) {
|
|
1021
|
+
return new Response(null, {
|
|
1022
|
+
headers: { Location: url.pathname + url.search },
|
|
1023
|
+
status: 303,
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
/* CSRF rejection — hard 403, not a form error */
|
|
1027
|
+
if (fnResponse.status === 403) {
|
|
1028
|
+
return fnResponse;
|
|
1029
|
+
}
|
|
1030
|
+
/* Validation/auth error — store context for SSR re-render */
|
|
1031
|
+
const body: unknown = await fnResponse.json().catch(() => null);
|
|
1032
|
+
const formValues = formDataToObject(formData);
|
|
1033
|
+
if (typeof body === "object" && body !== null && "errors" in body) {
|
|
1034
|
+
const errors = (
|
|
1035
|
+
body as {
|
|
1036
|
+
errors: { fieldErrors: Record<string, string[]>; formErrors: string[] };
|
|
1037
|
+
}
|
|
1038
|
+
).errors;
|
|
1039
|
+
setFormActionContext(flareFnId, {
|
|
1040
|
+
fieldErrors: errors.fieldErrors ?? {},
|
|
1041
|
+
formErrors: errors.formErrors ?? [],
|
|
1042
|
+
message: "Validation error",
|
|
1043
|
+
values: formValues,
|
|
1044
|
+
});
|
|
1045
|
+
} else {
|
|
1046
|
+
const msg =
|
|
1047
|
+
typeof body === "object" && body !== null && "message" in body
|
|
1048
|
+
? String((body as Record<string, unknown>).message)
|
|
1049
|
+
: "Request failed";
|
|
1050
|
+
setFormActionContext(flareFnId, {
|
|
1051
|
+
fieldErrors: {},
|
|
1052
|
+
formErrors: [msg],
|
|
1053
|
+
message: msg,
|
|
1054
|
+
values: formValues,
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
/* fall through to SSR render with error context */
|
|
1058
|
+
} catch (e: unknown) {
|
|
1059
|
+
if (isRedirectResponse(e)) {
|
|
1060
|
+
return new Response(null, {
|
|
1061
|
+
headers: { Location: e.url },
|
|
1062
|
+
status: e.status,
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
throw e;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
if (url.pathname === "/_flare/revalidate" && config.cache?.revalidateSecret) {
|
|
1073
|
+
/* Internal endpoints bypass middleware response handlers — only security headers */
|
|
1074
|
+
let revalResponse = await handleRevalidateRequest(request, url, env, config.cache);
|
|
1075
|
+
revalResponse = addSecurityHeaders(revalResponse, secHeaders);
|
|
1076
|
+
return revalResponse;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (url.pathname === "/_flare/sitemap/submit" && config.sitemap) {
|
|
1080
|
+
/* Internal endpoints bypass middleware response handlers — only security headers */
|
|
1081
|
+
let sitemapResponse = await handleSitemapSubmitRequest(request, url, env, config.sitemap);
|
|
1082
|
+
sitemapResponse = addSecurityHeaders(sitemapResponse, secHeaders);
|
|
1083
|
+
return sitemapResponse;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const cs = config.router.caseSensitive;
|
|
1087
|
+
|
|
1088
|
+
/* basePath guard: reject requests outside the mount point */
|
|
1089
|
+
if (config.router.basePath && !url.pathname.startsWith(config.router.basePath)) {
|
|
1090
|
+
return fallbackHtmlResponse(FALLBACK_404, 404, secHeaders);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const rewrittenUrl = composedRewrite ? executeRewriteInput(composedRewrite, url) : url;
|
|
1094
|
+
const matchPathname = rewrittenUrl.pathname;
|
|
1095
|
+
const match = matchRoute(config.router.routeTree, matchPathname, cs, toLocaleMatch(config.router.locale));
|
|
1096
|
+
|
|
1097
|
+
if (!match) {
|
|
1098
|
+
const fuzzy = config.router.notFoundMode !== "root";
|
|
1099
|
+
if (fuzzy) {
|
|
1100
|
+
/* matchRoutePartial skips "/" when the first segment is neither
|
|
1101
|
+
* a locale nor a static child (cross-worker guard). The request
|
|
1102
|
+
* already arrived here, so fall back to the root index and
|
|
1103
|
+
* render the app's notFound boundary instead of generic HTML. */
|
|
1104
|
+
const partial =
|
|
1105
|
+
matchRoutePartial(config.router.routeTree, matchPathname, cs, toLocaleMatch(config.router.locale)) ??
|
|
1106
|
+
matchRoute(config.router.routeTree, "/", cs, toLocaleMatch(config.router.locale));
|
|
1107
|
+
if (partial) {
|
|
1108
|
+
const resolvedRoutes = await loadRouteModules(partial.route, config.router.layouts);
|
|
1109
|
+
/* Keep layouts, replace page loader with one that throws notFound */
|
|
1110
|
+
const lastRoute = resolvedRoutes[resolvedRoutes.length - 1];
|
|
1111
|
+
if (lastRoute) {
|
|
1112
|
+
lastRoute.loader = () => {
|
|
1113
|
+
throw new NotFoundError();
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const pipelineResult = await runPipeline({
|
|
1118
|
+
abortController,
|
|
1119
|
+
authenticateFn: config.authenticateFn,
|
|
1120
|
+
cause: "enter",
|
|
1121
|
+
env,
|
|
1122
|
+
localeConfig: config.router.locale,
|
|
1123
|
+
params: partial.params,
|
|
1124
|
+
prefetch: false,
|
|
1125
|
+
request,
|
|
1126
|
+
routes: resolvedRoutes,
|
|
1127
|
+
ssrCacheDefaults: config.cache?.ssr,
|
|
1128
|
+
store: config.cache?.store,
|
|
1129
|
+
tracer,
|
|
1130
|
+
url,
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
const redirect404 = findRedirectInMatches(pipelineResult.matches);
|
|
1134
|
+
if (redirect404) {
|
|
1135
|
+
return new Response(null, {
|
|
1136
|
+
headers: { Location: redirect404.url },
|
|
1137
|
+
status: redirect404.status,
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const resolvedHead = pipelineResult.matches.findLast((m) => m.headConfig)?.headConfig ?? {};
|
|
1142
|
+
|
|
1143
|
+
const fuzzyLogs = enableServerLogs ? getServerLogs() : undefined;
|
|
1144
|
+
if (request.headers.get("x-d") === "1") {
|
|
1145
|
+
const ndjson = createNDJSONResponse({
|
|
1146
|
+
deferContexts: pipelineResult.deferContexts,
|
|
1147
|
+
matches: pipelineResult.matches,
|
|
1148
|
+
serverLogs: fuzzyLogs && fuzzyLogs.length > 0 ? fuzzyLogs : undefined,
|
|
1149
|
+
});
|
|
1150
|
+
let data404 = new Response(ndjson.body, {
|
|
1151
|
+
headers: ndjson.headers,
|
|
1152
|
+
status: 404,
|
|
1153
|
+
});
|
|
1154
|
+
data404 = await applyResponseHandlers(data404, responseHandlers);
|
|
1155
|
+
return addSecurityHeaders(data404, secHeaders);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const fuzzyQC = await resolveQueryClient(config.router);
|
|
1159
|
+
let ssrResult: ReturnType<typeof renderToStream>;
|
|
1160
|
+
setRewrite(composedRewrite);
|
|
1161
|
+
try {
|
|
1162
|
+
ssrResult = renderToStream({
|
|
1163
|
+
auth: pipelineResult.auth,
|
|
1164
|
+
cause: "enter",
|
|
1165
|
+
deferContexts: pipelineResult.deferContexts.size > 0 ? pipelineResult.deferContexts : undefined,
|
|
1166
|
+
entryScript: clientEntryPath,
|
|
1167
|
+
matches: pipelineResult.matches,
|
|
1168
|
+
modulePreloads: computeRoutePreloads(partial.route),
|
|
1169
|
+
moduleScripts: [],
|
|
1170
|
+
nonce,
|
|
1171
|
+
params: partial.params,
|
|
1172
|
+
prefetch: false,
|
|
1173
|
+
queryClient: fuzzyQC?.queryClient,
|
|
1174
|
+
queryClientGetter: config.router.queryClientGetter,
|
|
1175
|
+
queryClientProvider: fuzzyQC?.provider as SSRConfig["queryClientProvider"],
|
|
1176
|
+
resolvedHead,
|
|
1177
|
+
router: config.router,
|
|
1178
|
+
serverLogs: fuzzyLogs && fuzzyLogs.length > 0 ? fuzzyLogs : undefined,
|
|
1179
|
+
sxCssManifest: sxManifest ?? undefined,
|
|
1180
|
+
sxRenderedModules: collectRouteModuleIds(partial.route),
|
|
1181
|
+
url,
|
|
1182
|
+
});
|
|
1183
|
+
} finally {
|
|
1184
|
+
setRewrite(undefined);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
let response = new Response(ssrResult.body, {
|
|
1188
|
+
headers: ssrResult.headers,
|
|
1189
|
+
status: 404,
|
|
1190
|
+
});
|
|
1191
|
+
response = await applyResponseHandlers(response, responseHandlers);
|
|
1192
|
+
return addSecurityHeaders(response, secHeaders);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
if (request.headers.get("x-d") === "1") {
|
|
1196
|
+
let data404 = unmatchedNdjson404();
|
|
1197
|
+
data404 = await applyResponseHandlers(data404, responseHandlers);
|
|
1198
|
+
return addSecurityHeaders(data404, secHeaders);
|
|
1199
|
+
}
|
|
1200
|
+
return fallbackHtmlResponse(FALLBACK_404, 404, secHeaders);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
const isDataRequest = request.headers.get("x-d") === "1";
|
|
1204
|
+
|
|
1205
|
+
/* ── ISR / Static Store serving ─────────────────────── */
|
|
1206
|
+
const staticMeta = match.route.o.static;
|
|
1207
|
+
const isISRBgRequest = request.headers.get("x-isr-bg") === "1";
|
|
1208
|
+
const isPrerenderRequest = request.headers.get("x-flare-prerender") === "1";
|
|
1209
|
+
/* Skip the store on prerender: loadPrerenderArtifacts() rehydrates
|
|
1210
|
+
the previous build's HTML into the same in-memory store the
|
|
1211
|
+
prerender plugin then fetch()es, which would freeze stale
|
|
1212
|
+
client hashes into the new artifacts. */
|
|
1213
|
+
if (staticMeta && resolvedStore && !isISRBgRequest && !isPrerenderRequest) {
|
|
1214
|
+
const storeKey = `static:${url.pathname}`;
|
|
1215
|
+
const entry = await resolvedStore.get(storeKey);
|
|
1216
|
+
|
|
1217
|
+
if (entry) {
|
|
1218
|
+
const staticData = entry.data as StaticEntryData;
|
|
1219
|
+
const isrRevalidateRaw = staticMeta.revalidate ?? config.cache?.isr?.revalidate;
|
|
1220
|
+
const isrRevalidate = isrRevalidateRaw !== undefined ? parseSeconds(isrRevalidateRaw) : undefined;
|
|
1221
|
+
const isStale =
|
|
1222
|
+
staticMeta.mode === "isr" &&
|
|
1223
|
+
isrRevalidate !== undefined &&
|
|
1224
|
+
Date.now() - entry.storedAt > isrRevalidate * 1000;
|
|
1225
|
+
|
|
1226
|
+
if (isStale && !request.headers.get("x-isr-bg") && !isrInFlight.has(storeKey)) {
|
|
1227
|
+
isrInFlight.add(storeKey);
|
|
1228
|
+
const revalidatePromise = (async () => {
|
|
1229
|
+
try {
|
|
1230
|
+
const reRenderResponse = await withTimeout(
|
|
1231
|
+
handler.fetch(
|
|
1232
|
+
new Request(request.url, {
|
|
1233
|
+
headers: { "x-isr-bg": "1" },
|
|
1234
|
+
method: "GET",
|
|
1235
|
+
}),
|
|
1236
|
+
env,
|
|
1237
|
+
),
|
|
1238
|
+
ISR_TIMEOUT,
|
|
1239
|
+
);
|
|
1240
|
+
const html = await reRenderResponse.text();
|
|
1241
|
+
const dataResponse = await withTimeout(
|
|
1242
|
+
handler.fetch(
|
|
1243
|
+
new Request(request.url, {
|
|
1244
|
+
headers: { "x-d": "1", "x-isr-bg": "1" },
|
|
1245
|
+
method: "GET",
|
|
1246
|
+
}),
|
|
1247
|
+
env,
|
|
1248
|
+
),
|
|
1249
|
+
ISR_TIMEOUT,
|
|
1250
|
+
);
|
|
1251
|
+
const ndjson = await dataResponse.text();
|
|
1252
|
+
const headers = headersToStaticRecord(reRenderResponse.headers);
|
|
1253
|
+
|
|
1254
|
+
/* Replace real nonces with placeholder before storing */
|
|
1255
|
+
let storedHtml = html;
|
|
1256
|
+
const bgNonce = extractNonce(html) ?? extractNonce(headers["content-security-policy"] ?? "");
|
|
1257
|
+
if (bgNonce) {
|
|
1258
|
+
storedHtml = replaceNonce(html, bgNonce);
|
|
1259
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
1260
|
+
if (v.includes(bgNonce)) {
|
|
1261
|
+
headers[k] = replaceNonce(v, bgNonce);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/* Extract fresh tags from re-render response */
|
|
1267
|
+
const surrogateKey = headers["surrogate-key"];
|
|
1268
|
+
const freshTags = surrogateKey ? surrogateKey.split(" ").filter(Boolean) : entry.tags;
|
|
1269
|
+
|
|
1270
|
+
const etag = await computeEtag(storedHtml);
|
|
1271
|
+
await resolvedStore.set(storeKey, {
|
|
1272
|
+
data: { etag, headers, html: storedHtml, ndjson },
|
|
1273
|
+
storedAt: Date.now(),
|
|
1274
|
+
tags: freshTags,
|
|
1275
|
+
});
|
|
1276
|
+
} catch (e) {
|
|
1277
|
+
logError("isr", `background revalidation failed for ${storeKey}`, e);
|
|
1278
|
+
} finally {
|
|
1279
|
+
isrInFlight.delete(storeKey);
|
|
1280
|
+
}
|
|
1281
|
+
})();
|
|
1282
|
+
background(revalidatePromise);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const flareRender: FlareRenderMode = staticMeta.mode === "isr" ? "ISR" : "SSG";
|
|
1286
|
+
const flareCache: FlareCacheStatus = isStale ? "STALE" : "HIT";
|
|
1287
|
+
const headersEnabled = config.cache?.headers !== false;
|
|
1288
|
+
|
|
1289
|
+
if (isDataRequest) {
|
|
1290
|
+
const dataHeaders: Record<string, string> = {
|
|
1291
|
+
"Content-Type": "application/x-ndjson",
|
|
1292
|
+
};
|
|
1293
|
+
if (headersEnabled) {
|
|
1294
|
+
dataHeaders[FLARE_CACHE_HEADER] = flareCache;
|
|
1295
|
+
dataHeaders[FLARE_RENDER_HEADER] = flareRender;
|
|
1296
|
+
}
|
|
1297
|
+
let response = new Response(staticData.ndjson, {
|
|
1298
|
+
headers: dataHeaders,
|
|
1299
|
+
status: 200,
|
|
1300
|
+
});
|
|
1301
|
+
response = await applyResponseHandlers(response, responseHandlers);
|
|
1302
|
+
return addSecurityHeaders(response, secHeaders);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/* Replace nonce placeholder */
|
|
1306
|
+
const entryHeaders: Record<string, string> = {};
|
|
1307
|
+
for (const [k, v] of Object.entries(sanitizeStaticHeaders(staticData.headers))) {
|
|
1308
|
+
entryHeaders[k] = v.replaceAll(NONCE_PLACEHOLDER, nonce);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/* Never 304 HTML that embeds a per-request CSP nonce. A 304 keeps the
|
|
1312
|
+
cached body (old nonce="") and applies this response's new CSP, so
|
|
1313
|
+
ThemeScript and every other inline script are blocked until a full
|
|
1314
|
+
200 rewrite. ETag stays on the 200 for validators that only read it. */
|
|
1315
|
+
if (staticData.etag) {
|
|
1316
|
+
entryHeaders["ETag"] = staticData.etag;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const html = staticData.html.replaceAll(NONCE_PLACEHOLDER, nonce);
|
|
1320
|
+
|
|
1321
|
+
let response = new Response(html, {
|
|
1322
|
+
headers: entryHeaders,
|
|
1323
|
+
status: 200,
|
|
1324
|
+
});
|
|
1325
|
+
if (headersEnabled) {
|
|
1326
|
+
response.headers.set(FLARE_CACHE_HEADER, flareCache);
|
|
1327
|
+
response.headers.set(FLARE_RENDER_HEADER, flareRender);
|
|
1328
|
+
}
|
|
1329
|
+
response = await applyResponseHandlers(response, responseHandlers);
|
|
1330
|
+
return addSecurityHeaders(response, secHeaders);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/* Cache miss — dynamicParams: false → 404, otherwise SSR.
|
|
1334
|
+
* Prerender fetches set x-flare-prerender so listed slugs can
|
|
1335
|
+
* be generated at build time before the store is populated. */
|
|
1336
|
+
if (staticMeta.mode === "isr") {
|
|
1337
|
+
const dynamicParams = staticMeta.dynamicParams ?? true;
|
|
1338
|
+
if (dynamicParams === false && request.headers.get("x-flare-prerender") !== "1") {
|
|
1339
|
+
const missResponse = fallbackHtmlResponse(FALLBACK_404, 404, secHeaders);
|
|
1340
|
+
if (config.cache?.headers !== false) {
|
|
1341
|
+
missResponse.headers.set(FLARE_CACHE_HEADER, "MISS");
|
|
1342
|
+
missResponse.headers.set(FLARE_RENDER_HEADER, "ISR");
|
|
1343
|
+
}
|
|
1344
|
+
return missResponse;
|
|
1345
|
+
}
|
|
1346
|
+
/* dynamicParams: true (default) → fall through to normal SSR */
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const isPrefetch = request.headers.get("x-p") === "1";
|
|
1351
|
+
const MAX_STALE_IDS = 100;
|
|
1352
|
+
const staleMatchIds = (request.headers.get("x-m")?.split(",") ?? []).slice(0, MAX_STALE_IDS);
|
|
1353
|
+
|
|
1354
|
+
const cause = isPrefetch ? "prefetch" : ("enter" as const);
|
|
1355
|
+
|
|
1356
|
+
const resolvedRoutes = await loadRouteModules(match.route, config.router.layouts);
|
|
1357
|
+
|
|
1358
|
+
const paramsAllowed = await validateStaticParams(resolvedRoutes, match.params, isDev);
|
|
1359
|
+
if (!paramsAllowed) {
|
|
1360
|
+
return fallbackHtmlResponse(FALLBACK_404, 404, secHeaders);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/* Response routes: `.response()` returns raw Response, skip SSR entirely */
|
|
1364
|
+
const pageRoute = resolvedRoutes[resolvedRoutes.length - 1];
|
|
1365
|
+
if (pageRoute && pageRoute._type === "response" && pageRoute.response) {
|
|
1366
|
+
let response = await pageRoute.response({
|
|
1367
|
+
env,
|
|
1368
|
+
params: match.params,
|
|
1369
|
+
request,
|
|
1370
|
+
url,
|
|
1371
|
+
});
|
|
1372
|
+
response = await applyResponseHandlers(response, responseHandlers);
|
|
1373
|
+
return addSecurityHeaders(response, secHeaders);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
if (isDataRequest && staleMatchIds.length > 0) {
|
|
1377
|
+
for (const route of resolvedRoutes) {
|
|
1378
|
+
const prefix = `${route.virtualPath}:`;
|
|
1379
|
+
const isStale = staleMatchIds.some((id) => id === route.virtualPath || id.startsWith(prefix));
|
|
1380
|
+
if (!isStale) {
|
|
1381
|
+
route.loader = undefined;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const pageQC = await resolveQueryClient(config.router);
|
|
1387
|
+
|
|
1388
|
+
const pipelineResult = await runPipeline({
|
|
1389
|
+
abortController,
|
|
1390
|
+
authenticateFn: config.authenticateFn,
|
|
1391
|
+
cause,
|
|
1392
|
+
env,
|
|
1393
|
+
localeConfig: config.router.locale,
|
|
1394
|
+
params: match.params,
|
|
1395
|
+
prefetch: isPrefetch,
|
|
1396
|
+
queryClient: pageQC?.queryClient,
|
|
1397
|
+
request,
|
|
1398
|
+
routes: resolvedRoutes,
|
|
1399
|
+
ssrCacheDefaults: config.cache?.ssr,
|
|
1400
|
+
store: config.cache?.store,
|
|
1401
|
+
tracer,
|
|
1402
|
+
url,
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
const pipelineRedirect = findRedirectInMatches(pipelineResult.matches);
|
|
1406
|
+
if (pipelineRedirect) {
|
|
1407
|
+
redirectCount++;
|
|
1408
|
+
if (redirectCount > MAX_REDIRECTS) {
|
|
1409
|
+
throw new Error(
|
|
1410
|
+
`Redirect loop detected after ${redirectCount} redirects. Check route redirects for circular references.`,
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
if (isDataRequest) {
|
|
1415
|
+
/* Data requests always get NDJSON redirect — even for external.
|
|
1416
|
+
* Raw 3xx on data fetch would cause fetch() to follow cross-origin
|
|
1417
|
+
* → CORS block → frozen UI. Client reads NDJSON and does hardNavigate(). */
|
|
1418
|
+
let response = createRedirectNDJSONResponse(pipelineRedirect);
|
|
1419
|
+
response = await applyResponseHandlers(response, responseHandlers);
|
|
1420
|
+
return addSecurityHeaders(response, secHeaders);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
let response: Response = new Response(null, {
|
|
1424
|
+
headers: { Location: pipelineRedirect.url },
|
|
1425
|
+
status: pipelineRedirect.status,
|
|
1426
|
+
});
|
|
1427
|
+
/* middleware-pinned cookies (auth refresh, flash messages) must survive 3xx — without
|
|
1428
|
+
* this, on-fly refresh + loader redirect drops Set-Cookie and the next request loops */
|
|
1429
|
+
response = await applyResponseHandlers(response, responseHandlers);
|
|
1430
|
+
return response;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
let response: Response;
|
|
1434
|
+
const collectedLogs = enableServerLogs ? getServerLogs() : undefined;
|
|
1435
|
+
const logsForTransport = collectedLogs && collectedLogs.length > 0 ? collectedLogs : undefined;
|
|
1436
|
+
|
|
1437
|
+
if (!isDataRequest) {
|
|
1438
|
+
const resolvedHead = pipelineResult.matches.findLast((m) => m.headConfig)?.headConfig ?? {};
|
|
1439
|
+
|
|
1440
|
+
let ssrResult: ReturnType<typeof renderToStream>;
|
|
1441
|
+
setRewrite(composedRewrite);
|
|
1442
|
+
try {
|
|
1443
|
+
ssrResult = renderToStream({
|
|
1444
|
+
auth: pipelineResult.auth,
|
|
1445
|
+
cause,
|
|
1446
|
+
deferContexts: pipelineResult.deferContexts.size > 0 ? pipelineResult.deferContexts : undefined,
|
|
1447
|
+
entryScript: clientEntryPath,
|
|
1448
|
+
matches: pipelineResult.matches,
|
|
1449
|
+
modulePreloads: computeRoutePreloads(match.route),
|
|
1450
|
+
moduleScripts: [],
|
|
1451
|
+
nonce,
|
|
1452
|
+
params: match.params,
|
|
1453
|
+
prefetch: isPrefetch,
|
|
1454
|
+
queryClient: pageQC?.queryClient,
|
|
1455
|
+
queryClientGetter: config.router.queryClientGetter,
|
|
1456
|
+
queryClientProvider: pageQC?.provider as SSRConfig["queryClientProvider"],
|
|
1457
|
+
resolvedHead,
|
|
1458
|
+
router: config.router,
|
|
1459
|
+
serverLogs: logsForTransport,
|
|
1460
|
+
sxCssManifest: sxManifest ?? undefined,
|
|
1461
|
+
sxRenderedModules: collectRouteModuleIds(match.route),
|
|
1462
|
+
url,
|
|
1463
|
+
});
|
|
1464
|
+
} finally {
|
|
1465
|
+
setRewrite(undefined);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
response = new Response(ssrResult.body, {
|
|
1469
|
+
headers: ssrResult.headers,
|
|
1470
|
+
status: ssrResult.status,
|
|
1471
|
+
});
|
|
1472
|
+
} else {
|
|
1473
|
+
let hasDeferreds = false;
|
|
1474
|
+
for (const dCtx of pipelineResult.deferContexts.values()) {
|
|
1475
|
+
if (dCtx.entries().length > 0) {
|
|
1476
|
+
hasDeferreds = true;
|
|
1477
|
+
break;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
if (hasDeferreds) {
|
|
1482
|
+
response = createStreamingNDJSONResponse({
|
|
1483
|
+
deferContexts: pipelineResult.deferContexts,
|
|
1484
|
+
matches: pipelineResult.matches,
|
|
1485
|
+
queryClient: pageQC?.queryClient,
|
|
1486
|
+
serverLogs: logsForTransport,
|
|
1487
|
+
});
|
|
1488
|
+
} else {
|
|
1489
|
+
response = createNDJSONResponse({
|
|
1490
|
+
deferContexts: pipelineResult.deferContexts,
|
|
1491
|
+
matches: pipelineResult.matches,
|
|
1492
|
+
serverLogs: logsForTransport,
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/* apply pipeline response headers to NDJSON response */
|
|
1497
|
+
let merged: ResponseHeaders = {};
|
|
1498
|
+
for (const m of pipelineResult.matches) {
|
|
1499
|
+
if (m.responseHeaders) {
|
|
1500
|
+
merged = mergeResponseHeaders(merged, m.responseHeaders);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
applyResponseHeaders(response.headers, merged);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
response = await applyResponseHandlers(response, responseHandlers);
|
|
1507
|
+
|
|
1508
|
+
/* Set Flare cache headers (skip ISR bg renders — internal only) */
|
|
1509
|
+
const ssrHeadersEnabled = config.cache?.headers !== false;
|
|
1510
|
+
if (!isISRBgRequest && ssrHeadersEnabled) {
|
|
1511
|
+
if (staticMeta) {
|
|
1512
|
+
response.headers.set(FLARE_CACHE_HEADER, "MISS");
|
|
1513
|
+
response.headers.set(FLARE_RENDER_HEADER, staticMeta.mode === "isr" ? "ISR" : "SSG");
|
|
1514
|
+
} else {
|
|
1515
|
+
response.headers.set(FLARE_RENDER_HEADER, "SSR");
|
|
1516
|
+
let hasCacheable = false;
|
|
1517
|
+
let allHit = true;
|
|
1518
|
+
for (const m of pipelineResult.matches) {
|
|
1519
|
+
if (m.cacheHit !== undefined) {
|
|
1520
|
+
hasCacheable = true;
|
|
1521
|
+
if (!m.cacheHit) allHit = false;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
if (hasCacheable) {
|
|
1525
|
+
response.headers.set(FLARE_CACHE_HEADER, allHit ? "HIT" : "MISS");
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
response = addSecurityHeaders(response, secHeaders);
|
|
1531
|
+
|
|
1532
|
+
/*
|
|
1533
|
+
* ISR on-demand: populate store after first SSR miss.
|
|
1534
|
+
* Skip if x-isr-bg header present (prevents recursion from
|
|
1535
|
+
* the background re-render itself triggering another populate).
|
|
1536
|
+
*/
|
|
1537
|
+
if (staticMeta?.mode === "isr" && resolvedStore && !isDataRequest && !request.headers.get("x-isr-bg")) {
|
|
1538
|
+
const isrStoreKey = `static:${url.pathname}`;
|
|
1539
|
+
if (!isrInFlight.has(isrStoreKey)) {
|
|
1540
|
+
isrInFlight.add(isrStoreKey);
|
|
1541
|
+
const isrPopulate = (async () => {
|
|
1542
|
+
try {
|
|
1543
|
+
const htmlRes = await withTimeout(
|
|
1544
|
+
handler.fetch(
|
|
1545
|
+
new Request(request.url, {
|
|
1546
|
+
headers: { "x-isr-bg": "1" },
|
|
1547
|
+
method: "GET",
|
|
1548
|
+
}),
|
|
1549
|
+
env,
|
|
1550
|
+
),
|
|
1551
|
+
ISR_TIMEOUT,
|
|
1552
|
+
);
|
|
1553
|
+
const html = await htmlRes.text();
|
|
1554
|
+
const dataRes = await withTimeout(
|
|
1555
|
+
handler.fetch(
|
|
1556
|
+
new Request(request.url, {
|
|
1557
|
+
headers: { "x-d": "1", "x-isr-bg": "1" },
|
|
1558
|
+
method: "GET",
|
|
1559
|
+
}),
|
|
1560
|
+
env,
|
|
1561
|
+
),
|
|
1562
|
+
ISR_TIMEOUT,
|
|
1563
|
+
);
|
|
1564
|
+
const ndjsonBody = await dataRes.text();
|
|
1565
|
+
const resHeaders = headersToStaticRecord(htmlRes.headers);
|
|
1566
|
+
|
|
1567
|
+
/* Replace real nonces with placeholder before storing */
|
|
1568
|
+
let storedHtml = html;
|
|
1569
|
+
const populateNonce =
|
|
1570
|
+
extractNonce(html) ?? extractNonce(resHeaders["content-security-policy"] ?? "");
|
|
1571
|
+
if (populateNonce) {
|
|
1572
|
+
storedHtml = replaceNonce(html, populateNonce);
|
|
1573
|
+
for (const [k, v] of Object.entries(resHeaders)) {
|
|
1574
|
+
if (v.includes(populateNonce)) {
|
|
1575
|
+
resHeaders[k] = replaceNonce(v, populateNonce);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
const etag = await computeEtag(storedHtml);
|
|
1581
|
+
await resolvedStore.set(isrStoreKey, {
|
|
1582
|
+
data: { etag, headers: resHeaders, html: storedHtml, ndjson: ndjsonBody },
|
|
1583
|
+
storedAt: Date.now(),
|
|
1584
|
+
});
|
|
1585
|
+
} catch (e) {
|
|
1586
|
+
logError("isr", `background ISR population failed for ${isrStoreKey}`, e);
|
|
1587
|
+
} finally {
|
|
1588
|
+
isrInFlight.delete(isrStoreKey);
|
|
1589
|
+
}
|
|
1590
|
+
})();
|
|
1591
|
+
background(isrPopulate);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
return response;
|
|
1596
|
+
} catch (e: unknown) {
|
|
1597
|
+
if (isRedirectResponse(e)) {
|
|
1598
|
+
redirectCount++;
|
|
1599
|
+
if (redirectCount > MAX_REDIRECTS) {
|
|
1600
|
+
return fallbackHtmlResponse(FALLBACK_500, 500, secHeaders);
|
|
1601
|
+
}
|
|
1602
|
+
return new Response(null, {
|
|
1603
|
+
headers: { Location: e.url },
|
|
1604
|
+
status: e.status,
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
if (isUnauthenticatedError(e)) {
|
|
1609
|
+
return fallbackHtmlResponse(FALLBACK_401, 401, secHeaders);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
if (isUnauthorizedError(e)) {
|
|
1613
|
+
return fallbackHtmlResponse(FALLBACK_403, 403, secHeaders);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
if (isNotFoundError(e)) {
|
|
1617
|
+
return fallbackHtmlResponse(FALLBACK_404, 404, secHeaders);
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
if (isServerFnValidationError(e)) {
|
|
1621
|
+
return addSecurityHeaders(
|
|
1622
|
+
new Response(e.message, {
|
|
1623
|
+
headers: { "Content-Type": "text/plain" },
|
|
1624
|
+
status: 400,
|
|
1625
|
+
}),
|
|
1626
|
+
secHeaders,
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
logError("handler", "unhandled request error", e);
|
|
1631
|
+
const html = isDev ? buildDevErrorHtml(e) : FALLBACK_500;
|
|
1632
|
+
const errResponse = fallbackHtmlResponse(html, 500, secHeaders);
|
|
1633
|
+
if (request.headers.get("x-isr-bg") !== "1" && config.cache?.headers !== false) {
|
|
1634
|
+
errResponse.headers.set(FLARE_RENDER_HEADER, "SSR");
|
|
1635
|
+
}
|
|
1636
|
+
return errResponse;
|
|
1637
|
+
} finally {
|
|
1638
|
+
if (dedupeFetch) disableFetchDedupe();
|
|
1639
|
+
}
|
|
1640
|
+
},
|
|
1641
|
+
);
|
|
1642
|
+
|
|
1643
|
+
/* Inject Server-Timing header from timing tracer (covers all response paths) */
|
|
1644
|
+
if ("getEntries" in tracer) {
|
|
1645
|
+
const entries = (tracer as TimingTracer).getEntries();
|
|
1646
|
+
if (entries.length > 0) {
|
|
1647
|
+
response.headers.set("Server-Timing", buildServerTimingHeader(entries));
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
return response;
|
|
1652
|
+
},
|
|
1653
|
+
};
|
|
1654
|
+
|
|
1655
|
+
return Object.assign(handler, {
|
|
1656
|
+
authenticateFn: config.authenticateFn,
|
|
1657
|
+
getStaticParams: () => buildGetStaticParams(config.router),
|
|
1658
|
+
serverContext: config.serverContext,
|
|
1659
|
+
}) as typeof handler & {
|
|
1660
|
+
readonly authenticateFn?: ServerHandlerConfig<TAuth, TEnv, TServerContext>["authenticateFn"];
|
|
1661
|
+
readonly getStaticParams: () => Promise<StaticParamsMap>;
|
|
1662
|
+
readonly serverContext?: ServerHandlerConfig<TAuth, TEnv, TServerContext>["serverContext"];
|
|
1663
|
+
};
|
|
1664
|
+
}
|