@lovrozagar/flare 0.1.1 → 0.2.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.
Files changed (85) hide show
  1. package/README.md +44 -19
  2. package/package.json +10 -7
  3. package/src/broadcast/index.ts +1 -1
  4. package/src/broadcast/provider.tsx +2 -2
  5. package/src/caches/index.ts +26 -0
  6. package/src/components/await.tsx +36 -39
  7. package/src/components/dev-error-overlay.tsx +8 -9
  8. package/src/components/direction-script.tsx +2 -2
  9. package/src/components/locale-script.tsx +2 -2
  10. package/src/components/reset-css.tsx +1 -1
  11. package/src/components/ssr-context.tsx +7 -14
  12. package/src/components/theme-script.tsx +2 -2
  13. package/src/components/view-transition-css.tsx +1 -1
  14. package/src/defer/index.ts +22 -0
  15. package/src/direction/index.tsx +61 -36
  16. package/src/errors/index.ts +45 -11
  17. package/src/fonts/component.tsx +2 -1
  18. package/src/form/index.tsx +18 -12
  19. package/src/generators/index.ts +73 -30
  20. package/src/head-client/index.ts +7 -0
  21. package/src/history/index.ts +6 -0
  22. package/src/hydrate/index.tsx +90 -60
  23. package/src/hydration/index.ts +26 -6
  24. package/src/i18n/index.ts +11 -2
  25. package/src/image/index.tsx +7 -4
  26. package/src/intercept-outlet/index.tsx +6 -2
  27. package/src/lazy/index.tsx +50 -50
  28. package/src/link/index.tsx +97 -58
  29. package/src/loader-pipeline/index.ts +93 -35
  30. package/src/locale/cookie.ts +15 -0
  31. package/src/locale/index.tsx +24 -35
  32. package/src/locale.ts +1 -0
  33. package/src/middleware/builtins/api-proxy.ts +58 -4
  34. package/src/middleware/builtins/cdn-proxy.ts +24 -3
  35. package/src/middleware/builtins/i18n.ts +23 -25
  36. package/src/middleware/builtins/keepalive.ts +2 -3
  37. package/src/mount/index.ts +8 -1
  38. package/src/navigation/index.ts +575 -129
  39. package/src/navigation/types.ts +11 -1
  40. package/src/navigation-progress/index.tsx +17 -16
  41. package/src/ndjson-client/index.ts +42 -17
  42. package/src/ndjson-server/index.ts +78 -29
  43. package/src/outlet/index.tsx +192 -97
  44. package/src/outlet/types.ts +9 -1
  45. package/src/plugins/dev-cdn-cache.ts +18 -5
  46. package/src/plugins/dev-dashboard/client.tsx +130 -118
  47. package/src/plugins/dev-prerender.ts +6 -1
  48. package/src/plugins/dev-server.ts +12 -3
  49. package/src/plugins/image-plugin.ts +18 -4
  50. package/src/plugins/index.ts +10 -7
  51. package/src/plugins/prerender-plugin.ts +3 -3
  52. package/src/plugins/server-fn.ts +15 -20
  53. package/src/plugins/solid-jsx.d.ts +3 -3
  54. package/src/plugins/sx-ast/atomic.ts +5 -5
  55. package/src/plugins/sx-ast/index.ts +9 -0
  56. package/src/plugins/sx.d.ts +4 -5
  57. package/src/plugins/virtual.ts +11 -6
  58. package/src/prerender/index.ts +68 -32
  59. package/src/protocol.ts +65 -0
  60. package/src/query-client/index.tsx +73 -14
  61. package/src/rewrite/index.ts +10 -4
  62. package/src/route-builder/create-layout.ts +1 -1
  63. package/src/route-builder/create-root-layout.ts +1 -1
  64. package/src/route-builder/types.ts +5 -1
  65. package/src/router-primitives/types.ts +2 -0
  66. package/src/security/index.ts +41 -5
  67. package/src/server/index.ts +1 -2
  68. package/src/server-fn/get-input.ts +90 -0
  69. package/src/server-fn/http-error.ts +13 -0
  70. package/src/server-fn/index.ts +94 -70
  71. package/src/server-fn-query.ts +9 -17
  72. package/src/server-handler/etag.ts +4 -2
  73. package/src/server-handler/headers.ts +1 -2
  74. package/src/server-handler/index.ts +164 -56
  75. package/src/server-handler/types.ts +2 -0
  76. package/src/service-worker/template.ts +10 -42
  77. package/src/ssr/head-prefix.ts +2 -1
  78. package/src/ssr/hoist-head-markers.ts +25 -0
  79. package/src/ssr/index.tsx +109 -92
  80. package/src/state-parser/index.ts +38 -26
  81. package/src/styles/index.ts +89 -60
  82. package/src/testing/index.ts +8 -3
  83. package/src/theme/index.tsx +35 -32
  84. package/src/tracing/timing.ts +6 -1
  85. package/src/url/index.ts +9 -0
@@ -33,11 +33,13 @@ import { mergePreloads, resolveRoutePreloads, type ViteManifest } from "../modul
33
33
  import { dispatchMount, type MountConfig, matchMount } from "../mount/index.ts";
34
34
  import { setRewrite } from "../navigation/index.ts";
35
35
  import {
36
+ createErrorNDJSONResponse,
36
37
  createNDJSONResponse,
37
38
  createRedirectNDJSONResponse,
38
39
  createStreamingNDJSONResponse,
40
+ followAbortSignal,
39
41
  } from "../ndjson-server/index.ts";
40
- import { extractNonce, NONCE_PLACEHOLDER, replaceNonce } from "../prerender/index.ts";
42
+ import { extractNonce, NONCE_PLACEHOLDER, replaceNonce, tagsFromSurrogateKey } from "../prerender/index.ts";
41
43
  import { createRevalidateFn } from "../revalidation/index.ts";
42
44
  import { composeRewrites, executeRewriteInput, type LocationRewrite, rewriteBasePath } from "../rewrite/index.ts";
43
45
  import type { CacheConfig, ResponseHeaders } from "../route-builder/types.ts";
@@ -66,8 +68,32 @@ import {
66
68
  serverLog,
67
69
  setFormActionContext,
68
70
  } 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 {
72
+ FORM_FN_FIELD,
73
+ HEADER_DATA,
74
+ HEADER_FLAG,
75
+ HEADER_ISR,
76
+ HEADER_PRERENDER,
77
+ HEADER_PREFETCH,
78
+ HEADER_STALE,
79
+ INTERNAL_PATH_PREFIX,
80
+ isServerFnPathname,
81
+ serverFnPath,
82
+ } from "../protocol.ts";
83
+ import {
84
+ formDataToObject,
85
+ handleServerFnRequest,
86
+ readLimitedBody,
87
+ SERVER_FN_MAX_BODY_BYTES,
88
+ } from "../server-fn/index.ts";
89
+ import { sameOriginRedirectPath } from "../url/index.ts";
90
+ import {
91
+ applyResponseHeaders,
92
+ deriveStatus,
93
+ mergeResponseHeaders,
94
+ renderToStream,
95
+ type SSRConfig,
96
+ } from "../ssr/index.tsx";
71
97
  import type { StaticEntryData } from "../store/index.ts";
72
98
  import { noopTracer } from "../tracing/noop.ts";
73
99
  import { buildServerTimingHeader, createTimingTracer, type TimingTracer } from "../tracing/timing.ts";
@@ -152,7 +178,7 @@ const CSP_DEFAULTS: Record<string, string[] | boolean> = {
152
178
  "upgrade-insecure-requests": true,
153
179
  };
154
180
 
155
- export function buildCspHeader(nonce: string, overrides?: CspDirectives, isDev?: boolean): string {
181
+ export function buildCspHeader(nonce: string, overrides?: CspDirectives, isDev?: boolean, url?: string): string {
156
182
  const directives: Record<string, string[] | boolean> = {};
157
183
 
158
184
  for (const [key, value] of Object.entries(CSP_DEFAULTS)) {
@@ -167,6 +193,10 @@ export function buildCspHeader(nonce: string, overrides?: CspDirectives, isDev?:
167
193
  scriptSrc.push("'unsafe-inline'", "'unsafe-eval'");
168
194
  }
169
195
 
196
+ if (isDev || (url !== undefined && url.startsWith("http:"))) {
197
+ directives["upgrade-insecure-requests"] = false;
198
+ }
199
+
170
200
  if (overrides) {
171
201
  for (const [key, value] of Object.entries(overrides)) {
172
202
  if (value === undefined) continue;
@@ -205,6 +235,8 @@ const SKIP_STATIC_HEADERS = new Set([
205
235
  "content-encoding",
206
236
  "content-length",
207
237
  "keep-alive",
238
+ "set-cookie",
239
+ "set-cookie2",
208
240
  "transfer-encoding",
209
241
  ]);
210
242
 
@@ -358,15 +390,15 @@ function addSecurityHeaders(response: Response, secHeaders: Record<string, strin
358
390
  }
359
391
  }
360
392
 
361
- /* Ensure Vary includes x-d — merge with any existing Vary from cdn.vary config */
393
+ /* Ensure Vary includes flare-data — merge with any existing Vary from cdn.vary config */
362
394
  const existing = headers.get("Vary");
363
395
  if (existing) {
364
396
  const parts = existing.split(",").map((v) => v.trim());
365
- if (!parts.some((v) => v.toLowerCase() === "x-d")) {
366
- headers.set("Vary", `x-d, ${existing}`);
397
+ if (!parts.some((v) => v.toLowerCase() === HEADER_DATA)) {
398
+ headers.set("Vary", `${HEADER_DATA}, ${existing}`);
367
399
  }
368
400
  } else {
369
- headers.set("Vary", "x-d");
401
+ headers.set("Vary", HEADER_DATA);
370
402
  }
371
403
 
372
404
  return new Response(response.body, {
@@ -544,9 +576,11 @@ async function handleRevalidateRequest<TEnv>(
544
576
  return jsonResponse({ error: "Unauthorized" }, 401);
545
577
  }
546
578
 
579
+ const limited = await readLimitedBody(request, SERVER_FN_MAX_BODY_BYTES);
580
+ if ("error" in limited) return limited.error;
547
581
  let body: Record<string, unknown>;
548
582
  try {
549
- body = (await request.json()) as Record<string, unknown>;
583
+ body = JSON.parse(new TextDecoder().decode(limited.bytes)) as Record<string, unknown>;
550
584
  } catch {
551
585
  return jsonResponse({ error: "Invalid JSON body" }, 400);
552
586
  }
@@ -626,18 +660,22 @@ async function handleSitemapSubmitRequest<TEnv>(
626
660
  let urls: string[] | undefined;
627
661
  if (request.method === "POST") {
628
662
  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
- });
663
+ const limited = await readLimitedBody(request, SERVER_FN_MAX_BODY_BYTES);
664
+ if ("error" in limited) return limited.error;
665
+ if (limited.bytes.byteLength > 0) {
666
+ const body = JSON.parse(new TextDecoder().decode(limited.bytes)) as Record<string, unknown>;
667
+ if (Array.isArray(body.urls)) {
668
+ const allowedOrigin = new URL(sitemapConfig.sitemapUrl).origin;
669
+ urls = body.urls
670
+ .filter((u): u is string => typeof u === "string")
671
+ .filter((u) => {
672
+ try {
673
+ return new URL(u).origin === allowedOrigin;
674
+ } catch {
675
+ return false;
676
+ }
677
+ });
678
+ }
641
679
  }
642
680
  } catch {
643
681
  /* no body or invalid JSON — fine, sitemap-only submission */
@@ -718,6 +756,29 @@ function collectDynamicRoutes(node: import("../router-primitives").TreeNode): Ro
718
756
  return results;
719
757
  }
720
758
 
759
+ function expandLocaleParam(
760
+ virtualPath: string,
761
+ current: Record<string, string | string[]>[],
762
+ locale: { locales: readonly string[]; paramName?: string } | undefined,
763
+ ): Record<string, string | string[]>[] {
764
+ if (!locale?.locales.length || current.length === 0) return current;
765
+ const name = locale.paramName ?? "locale";
766
+ if (!virtualPath.includes(`[${name}]`)) return current;
767
+ if (current.every((p) => p[name] != null && p[name] !== "")) return current;
768
+
769
+ const out: Record<string, string | string[]>[] = [];
770
+ for (const parent of current) {
771
+ if (parent[name] != null && parent[name] !== "") {
772
+ out.push(parent);
773
+ continue;
774
+ }
775
+ for (const loc of locale.locales) {
776
+ out.push({ ...parent, [name]: loc });
777
+ }
778
+ }
779
+ return out;
780
+ }
781
+
721
782
  async function buildGetStaticParams(router: MarkedRouterConfig): Promise<StaticParamsMap> {
722
783
  const dynamicRoutes = collectDynamicRoutes(router.routeTree);
723
784
  if (dynamicRoutes.length === 0) return new Map();
@@ -785,6 +846,8 @@ async function buildGetStaticParams(router: MarkedRouterConfig): Promise<StaticP
785
846
  currentParams = nextParams;
786
847
  }
787
848
 
849
+ currentParams = expandLocaleParam(route.x, currentParams, router.locale);
850
+
788
851
  if (currentParams.length > 0 && Object.keys(currentParams[0] ?? {}).length > 0) {
789
852
  resultMap.set(route.x, currentParams);
790
853
  }
@@ -869,6 +932,7 @@ export function createServerHandler<
869
932
  async fetch(request: Request, env: TEnv): Promise<Response> {
870
933
  const nonce = generateNonce();
871
934
  const abortController = new AbortController();
935
+ followAbortSignal(abortController, request.signal);
872
936
  const url = new URL(request.url);
873
937
 
874
938
  const matchedMount = matchMount(mounts, url.pathname);
@@ -889,7 +953,7 @@ export function createServerHandler<
889
953
  config.csp && !resolvedSecCfg?.["Content-Security-Policy"]
890
954
  ? { ...resolvedSecCfg, "Content-Security-Policy": config.csp }
891
955
  : resolvedSecCfg;
892
- const secHeaders = buildSecurityHeaders({ config: mergedSecCfg, isDev, nonce });
956
+ const secHeaders = buildSecurityHeaders({ config: mergedSecCfg, isDev, nonce, url: request.url });
893
957
  const tracer = resolveTracer(config.tracing);
894
958
 
895
959
  let resolvedStore = typeof config.cache?.store === "function" ? config.cache.store(env) : config.cache?.store;
@@ -921,9 +985,9 @@ export function createServerHandler<
921
985
  let requestType: RequestType = "page";
922
986
  if (matchedMount) {
923
987
  requestType = "mount";
924
- } else if (url.pathname.startsWith("/_fn/")) {
988
+ } else if (isServerFnPathname(url.pathname)) {
925
989
  requestType = "server-fn";
926
- } else if (url.pathname.startsWith("/_flare/")) {
990
+ } else if (url.pathname.startsWith(INTERNAL_PATH_PREFIX)) {
927
991
  requestType = "internal";
928
992
  }
929
993
 
@@ -980,32 +1044,41 @@ export function createServerHandler<
980
1044
  })
981
1045
  : undefined;
982
1046
 
983
- if (url.pathname.startsWith("/_fn/")) {
1047
+ if (isServerFnPathname(url.pathname)) {
984
1048
  const fns = serverFns;
985
1049
  let response = await handleServerFnRequest(request, env, fns, serverFnAuthFn);
986
1050
  response = await applyResponseHandlers(response, responseHandlers);
987
1051
  return addSecurityHeaders(response, secHeaders);
988
1052
  }
989
1053
 
990
- /* PE POST: no-JS <Form> submits to page URL with __flare_fn hidden field */
1054
+ /* PE POST: no-JS <Form> submits to page URL with flare_fn hidden field */
991
1055
  if (
992
1056
  request.method === "POST" &&
993
- !url.pathname.startsWith("/_fn/") &&
994
- !url.pathname.startsWith("/_flare/")
1057
+ !isServerFnPathname(url.pathname) &&
1058
+ !url.pathname.startsWith(INTERNAL_PATH_PREFIX)
995
1059
  ) {
996
1060
  const ct = request.headers.get("content-type") ?? "";
997
1061
  if (ct.includes("form")) {
998
1062
  let formData: FormData;
999
1063
  try {
1000
- formData = await request.formData();
1064
+ const limited = await readLimitedBody(request, SERVER_FN_MAX_BODY_BYTES);
1065
+ if ("error" in limited) {
1066
+ return addSecurityHeaders(limited.error, secHeaders);
1067
+ }
1068
+ const formRequest = new Request(request.url, {
1069
+ body: limited.bytes,
1070
+ headers: { "content-type": ct },
1071
+ method: "POST",
1072
+ });
1073
+ formData = await formRequest.formData();
1001
1074
  } catch {
1002
1075
  return addSecurityHeaders(new Response("Bad Request", { status: 400 }), secHeaders);
1003
1076
  }
1004
- const flareFnId = formData.get("__flare_fn");
1077
+ const flareFnId = formData.get(FORM_FN_FIELD);
1005
1078
  if (typeof flareFnId === "string" && serverFns.has(flareFnId)) {
1006
1079
  const reg = serverFns.get(flareFnId);
1007
1080
  if (reg) {
1008
- const fnUrl = `${url.origin}/_fn/${reg.id}/${reg.name}`;
1081
+ const fnUrl = `${url.origin}${serverFnPath(reg.id, reg.name)}`;
1009
1082
  /* Forward headers but drop Content-Type so the runtime
1010
1083
  * auto-sets the correct multipart boundary for FormData body. */
1011
1084
  const forwardHeaders = new Headers(request.headers);
@@ -1018,14 +1091,17 @@ export function createServerHandler<
1018
1091
  try {
1019
1092
  const fnResponse = await handleServerFnRequest(fnRequest, env, serverFns, serverFnAuthFn);
1020
1093
  if (fnResponse.ok) {
1021
- return new Response(null, {
1022
- headers: { Location: url.pathname + url.search },
1023
- status: 303,
1024
- });
1094
+ return addSecurityHeaders(
1095
+ new Response(null, {
1096
+ headers: { Location: sameOriginRedirectPath(url) },
1097
+ status: 303,
1098
+ }),
1099
+ secHeaders,
1100
+ );
1025
1101
  }
1026
1102
  /* CSRF rejection — hard 403, not a form error */
1027
1103
  if (fnResponse.status === 403) {
1028
- return fnResponse;
1104
+ return addSecurityHeaders(fnResponse, secHeaders);
1029
1105
  }
1030
1106
  /* Validation/auth error — store context for SSR re-render */
1031
1107
  const body: unknown = await fnResponse.json().catch(() => null);
@@ -1141,7 +1217,7 @@ export function createServerHandler<
1141
1217
  const resolvedHead = pipelineResult.matches.findLast((m) => m.headConfig)?.headConfig ?? {};
1142
1218
 
1143
1219
  const fuzzyLogs = enableServerLogs ? getServerLogs() : undefined;
1144
- if (request.headers.get("x-d") === "1") {
1220
+ if (request.headers.get(HEADER_DATA) === HEADER_FLAG) {
1145
1221
  const ndjson = createNDJSONResponse({
1146
1222
  deferContexts: pipelineResult.deferContexts,
1147
1223
  matches: pipelineResult.matches,
@@ -1192,7 +1268,7 @@ export function createServerHandler<
1192
1268
  return addSecurityHeaders(response, secHeaders);
1193
1269
  }
1194
1270
  }
1195
- if (request.headers.get("x-d") === "1") {
1271
+ if (request.headers.get(HEADER_DATA) === HEADER_FLAG) {
1196
1272
  let data404 = unmatchedNdjson404();
1197
1273
  data404 = await applyResponseHandlers(data404, responseHandlers);
1198
1274
  return addSecurityHeaders(data404, secHeaders);
@@ -1200,17 +1276,24 @@ export function createServerHandler<
1200
1276
  return fallbackHtmlResponse(FALLBACK_404, 404, secHeaders);
1201
1277
  }
1202
1278
 
1203
- const isDataRequest = request.headers.get("x-d") === "1";
1279
+ const isDataRequest = request.headers.get(HEADER_DATA) === HEADER_FLAG;
1204
1280
 
1205
1281
  /* ── ISR / Static Store serving ─────────────────────── */
1206
1282
  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";
1283
+ const isISRBgRequest = request.headers.get(HEADER_ISR) === HEADER_FLAG;
1284
+ const isPrerenderRequest = request.headers.get(HEADER_PRERENDER) === HEADER_FLAG;
1209
1285
  /* Skip the store on prerender: loadPrerenderArtifacts() rehydrates
1210
1286
  the previous build's HTML into the same in-memory store the
1211
1287
  prerender plugin then fetch()es, which would freeze stale
1212
1288
  client hashes into the new artifacts. */
1213
- if (staticMeta && resolvedStore && !isISRBgRequest && !isPrerenderRequest) {
1289
+ if (
1290
+ staticMeta &&
1291
+ match.route.o.authenticate !== true &&
1292
+ match.route.o.authorize !== true &&
1293
+ resolvedStore &&
1294
+ !isISRBgRequest &&
1295
+ !isPrerenderRequest
1296
+ ) {
1214
1297
  const storeKey = `static:${url.pathname}`;
1215
1298
  const entry = await resolvedStore.get(storeKey);
1216
1299
 
@@ -1223,14 +1306,14 @@ export function createServerHandler<
1223
1306
  isrRevalidate !== undefined &&
1224
1307
  Date.now() - entry.storedAt > isrRevalidate * 1000;
1225
1308
 
1226
- if (isStale && !request.headers.get("x-isr-bg") && !isrInFlight.has(storeKey)) {
1309
+ if (isStale && !request.headers.get(HEADER_ISR) && !isrInFlight.has(storeKey)) {
1227
1310
  isrInFlight.add(storeKey);
1228
1311
  const revalidatePromise = (async () => {
1229
1312
  try {
1230
1313
  const reRenderResponse = await withTimeout(
1231
1314
  handler.fetch(
1232
1315
  new Request(request.url, {
1233
- headers: { "x-isr-bg": "1" },
1316
+ headers: { [HEADER_ISR]: HEADER_FLAG },
1234
1317
  method: "GET",
1235
1318
  }),
1236
1319
  env,
@@ -1241,7 +1324,7 @@ export function createServerHandler<
1241
1324
  const dataResponse = await withTimeout(
1242
1325
  handler.fetch(
1243
1326
  new Request(request.url, {
1244
- headers: { "x-d": "1", "x-isr-bg": "1" },
1327
+ headers: { [HEADER_DATA]: HEADER_FLAG, [HEADER_ISR]: HEADER_FLAG },
1245
1328
  method: "GET",
1246
1329
  }),
1247
1330
  env,
@@ -1264,8 +1347,7 @@ export function createServerHandler<
1264
1347
  }
1265
1348
 
1266
1349
  /* Extract fresh tags from re-render response */
1267
- const surrogateKey = headers["surrogate-key"];
1268
- const freshTags = surrogateKey ? surrogateKey.split(" ").filter(Boolean) : entry.tags;
1350
+ const freshTags = tagsFromSurrogateKey(headers) ?? entry.tags;
1269
1351
 
1270
1352
  const etag = await computeEtag(storedHtml);
1271
1353
  await resolvedStore.set(storeKey, {
@@ -1331,11 +1413,11 @@ export function createServerHandler<
1331
1413
  }
1332
1414
 
1333
1415
  /* Cache miss — dynamicParams: false → 404, otherwise SSR.
1334
- * Prerender fetches set x-flare-prerender so listed slugs can
1416
+ * Prerender fetches set flare-prerender so listed slugs can
1335
1417
  * be generated at build time before the store is populated. */
1336
1418
  if (staticMeta.mode === "isr") {
1337
1419
  const dynamicParams = staticMeta.dynamicParams ?? true;
1338
- if (dynamicParams === false && request.headers.get("x-flare-prerender") !== "1") {
1420
+ if (dynamicParams === false && request.headers.get(HEADER_PRERENDER) !== HEADER_FLAG) {
1339
1421
  const missResponse = fallbackHtmlResponse(FALLBACK_404, 404, secHeaders);
1340
1422
  if (config.cache?.headers !== false) {
1341
1423
  missResponse.headers.set(FLARE_CACHE_HEADER, "MISS");
@@ -1347,9 +1429,9 @@ export function createServerHandler<
1347
1429
  }
1348
1430
  }
1349
1431
 
1350
- const isPrefetch = request.headers.get("x-p") === "1";
1432
+ const isPrefetch = request.headers.get(HEADER_PREFETCH) === HEADER_FLAG;
1351
1433
  const MAX_STALE_IDS = 100;
1352
- const staleMatchIds = (request.headers.get("x-m")?.split(",") ?? []).slice(0, MAX_STALE_IDS);
1434
+ const staleMatchIds = (request.headers.get(HEADER_STALE)?.split(",") ?? []).slice(0, MAX_STALE_IDS);
1353
1435
 
1354
1436
  const cause = isPrefetch ? "prefetch" : ("enter" as const);
1355
1437
 
@@ -1478,18 +1560,22 @@ export function createServerHandler<
1478
1560
  }
1479
1561
  }
1480
1562
 
1563
+ const ndjsonStatus = deriveStatus(pipelineResult.matches);
1481
1564
  if (hasDeferreds) {
1482
1565
  response = createStreamingNDJSONResponse({
1566
+ abortController,
1483
1567
  deferContexts: pipelineResult.deferContexts,
1484
1568
  matches: pipelineResult.matches,
1485
1569
  queryClient: pageQC?.queryClient,
1486
1570
  serverLogs: logsForTransport,
1571
+ status: ndjsonStatus,
1487
1572
  });
1488
1573
  } else {
1489
1574
  response = createNDJSONResponse({
1490
1575
  deferContexts: pipelineResult.deferContexts,
1491
1576
  matches: pipelineResult.matches,
1492
1577
  serverLogs: logsForTransport,
1578
+ status: ndjsonStatus,
1493
1579
  });
1494
1580
  }
1495
1581
 
@@ -1531,10 +1617,17 @@ export function createServerHandler<
1531
1617
 
1532
1618
  /*
1533
1619
  * ISR on-demand: populate store after first SSR miss.
1534
- * Skip if x-isr-bg header present (prevents recursion from
1620
+ * Skip if flare-isr header present (prevents recursion from
1535
1621
  * the background re-render itself triggering another populate).
1536
1622
  */
1537
- if (staticMeta?.mode === "isr" && resolvedStore && !isDataRequest && !request.headers.get("x-isr-bg")) {
1623
+ if (
1624
+ staticMeta?.mode === "isr" &&
1625
+ match.route.o.authenticate !== true &&
1626
+ match.route.o.authorize !== true &&
1627
+ resolvedStore &&
1628
+ !isDataRequest &&
1629
+ !request.headers.get(HEADER_ISR)
1630
+ ) {
1538
1631
  const isrStoreKey = `static:${url.pathname}`;
1539
1632
  if (!isrInFlight.has(isrStoreKey)) {
1540
1633
  isrInFlight.add(isrStoreKey);
@@ -1543,7 +1636,7 @@ export function createServerHandler<
1543
1636
  const htmlRes = await withTimeout(
1544
1637
  handler.fetch(
1545
1638
  new Request(request.url, {
1546
- headers: { "x-isr-bg": "1" },
1639
+ headers: { [HEADER_ISR]: HEADER_FLAG },
1547
1640
  method: "GET",
1548
1641
  }),
1549
1642
  env,
@@ -1554,7 +1647,7 @@ export function createServerHandler<
1554
1647
  const dataRes = await withTimeout(
1555
1648
  handler.fetch(
1556
1649
  new Request(request.url, {
1557
- headers: { "x-d": "1", "x-isr-bg": "1" },
1650
+ headers: { [HEADER_DATA]: HEADER_FLAG, [HEADER_ISR]: HEADER_FLAG },
1558
1651
  method: "GET",
1559
1652
  }),
1560
1653
  env,
@@ -1581,6 +1674,7 @@ export function createServerHandler<
1581
1674
  await resolvedStore.set(isrStoreKey, {
1582
1675
  data: { etag, headers: resHeaders, html: storedHtml, ndjson: ndjsonBody },
1583
1676
  storedAt: Date.now(),
1677
+ tags: tagsFromSurrogateKey(resHeaders),
1584
1678
  });
1585
1679
  } catch (e) {
1586
1680
  logError("isr", `background ISR population failed for ${isrStoreKey}`, e);
@@ -1599,6 +1693,11 @@ export function createServerHandler<
1599
1693
  if (redirectCount > MAX_REDIRECTS) {
1600
1694
  return fallbackHtmlResponse(FALLBACK_500, 500, secHeaders);
1601
1695
  }
1696
+ /* Preloader `throw redirect()` never lands in match.error — same NDJSON
1697
+ as loader redirects so fetch() does not follow a raw 3xx. */
1698
+ if (request.headers.get(HEADER_DATA) === HEADER_FLAG) {
1699
+ return addSecurityHeaders(createRedirectNDJSONResponse(e), secHeaders);
1700
+ }
1602
1701
  return new Response(null, {
1603
1702
  headers: { Location: e.url },
1604
1703
  status: e.status,
@@ -1606,14 +1705,23 @@ export function createServerHandler<
1606
1705
  }
1607
1706
 
1608
1707
  if (isUnauthenticatedError(e)) {
1708
+ if (request.headers.get(HEADER_DATA) === HEADER_FLAG) {
1709
+ return addSecurityHeaders(createErrorNDJSONResponse(e), secHeaders);
1710
+ }
1609
1711
  return fallbackHtmlResponse(FALLBACK_401, 401, secHeaders);
1610
1712
  }
1611
1713
 
1612
1714
  if (isUnauthorizedError(e)) {
1715
+ if (request.headers.get(HEADER_DATA) === HEADER_FLAG) {
1716
+ return addSecurityHeaders(createErrorNDJSONResponse(e), secHeaders);
1717
+ }
1613
1718
  return fallbackHtmlResponse(FALLBACK_403, 403, secHeaders);
1614
1719
  }
1615
1720
 
1616
1721
  if (isNotFoundError(e)) {
1722
+ if (request.headers.get(HEADER_DATA) === HEADER_FLAG) {
1723
+ return addSecurityHeaders(createErrorNDJSONResponse(e), secHeaders);
1724
+ }
1617
1725
  return fallbackHtmlResponse(FALLBACK_404, 404, secHeaders);
1618
1726
  }
1619
1727
 
@@ -1630,7 +1738,7 @@ export function createServerHandler<
1630
1738
  logError("handler", "unhandled request error", e);
1631
1739
  const html = isDev ? buildDevErrorHtml(e) : FALLBACK_500;
1632
1740
  const errResponse = fallbackHtmlResponse(html, 500, secHeaders);
1633
- if (request.headers.get("x-isr-bg") !== "1" && config.cache?.headers !== false) {
1741
+ if (request.headers.get(HEADER_ISR) !== HEADER_FLAG && config.cache?.headers !== false) {
1634
1742
  errResponse.headers.set(FLARE_RENDER_HEADER, "SSR");
1635
1743
  }
1636
1744
  return errResponse;
@@ -18,6 +18,8 @@ export interface CspDirectives {
18
18
  "object-src"?: string[];
19
19
  "script-src"?: string[];
20
20
  "style-src"?: string[];
21
+ "style-src-attr"?: string[];
22
+ "style-src-elem"?: string[];
21
23
  "block-all-mixed-content"?: boolean;
22
24
  "upgrade-insecure-requests"?: boolean;
23
25
  "worker-src"?: string[];
@@ -1,3 +1,5 @@
1
+ import { HEADER_DATA, INTERNAL_PATH_PREFIX } from "../protocol.ts";
2
+
1
3
  export interface SwTemplateConfig {
2
4
  /** URL prefix for cache-first asset matching. Defaults to `"/assets"`. */
3
5
  assetsBase?: string;
@@ -53,7 +55,6 @@ self.addEventListener("install", function (event) {
53
55
  self.addEventListener("activate", function (event) {
54
56
  event.waitUntil(
55
57
  Promise.all([
56
- self.registration.navigationPreload && self.registration.navigationPreload.enable(),
57
58
  caches.keys().then(function (names) {
58
59
  return Promise.all(
59
60
  names.filter(function (n) {
@@ -84,10 +85,9 @@ self.addEventListener("fetch", function (event) {
84
85
  /* Only handle same-origin */
85
86
  if (url.origin !== self.location.origin) return
86
87
 
87
- /* Passthrough: NDJSON (x-d header), server functions, keepalive */
88
- if (event.request.headers.has("x-d")) return
89
- if (url.pathname.startsWith("/_fn/")) return
90
- if (url.pathname === "/_flare/keepalive") return
88
+ /* Passthrough: NDJSON (flare-data header), Flare internals */
89
+ if (event.request.headers.has(${JSON.stringify(HEADER_DATA)})) return
90
+ if (url.pathname.startsWith(${JSON.stringify(INTERNAL_PATH_PREFIX)})) return
91
91
 
92
92
  /* Cache-first for hashed assets under the configured assetsBase */
93
93
  if (url.pathname.startsWith(${JSON.stringify(`${assetsBase}/`)})) {
@@ -111,43 +111,11 @@ self.addEventListener("fetch", function (event) {
111
111
  return
112
112
  }
113
113
 
114
- /* Network-first for navigations */
115
- if (event.request.mode === "navigate") {
116
- event.respondWith(
117
- (function () {
118
- var preload = event.preloadResponse || Promise.resolve(undefined)
119
- return preload.then(function (preloaded) {
120
- if (preloaded) return preloaded
121
- return fetch(event.request)
122
- }).then(function (response) {
123
- if (response.ok) {
124
- var clone = response.clone()
125
- caches.open(RUNTIME_CACHE).then(function (cache) {
126
- cache.put(event.request, clone).catch(function () {})
127
- /* LRU eviction — batch delete to stay under limit */
128
- cache.keys().then(function (keys) {
129
- var i = 0
130
- while (keys.length - i > SW_CONFIG.runtimeCacheMax) {
131
- cache.delete(keys[i])
132
- i++
133
- }
134
- })
135
- }).catch(function () {})
136
- }
137
- return response
138
- }).catch(function () {
139
- return caches.match(event.request).then(function (cached) {
140
- if (cached) return cached
141
- if (SW_CONFIG.offlineFallback) {
142
- return caches.match(SW_CONFIG.offlineFallback)
143
- }
144
- return undefined
145
- })
146
- })
147
- })()
148
- )
149
- return
150
- }
114
+ /* Do not intercept navigations. A SW fetch() either follows 3xx
115
+ (document never sees Location / Set-Cookie) or returns a Response
116
+ whose Set-Cookie the browser ignores — locale cookies stay stale
117
+ (hr→fr still cookie=hr while the HTML is French). */
118
+ if (event.request.mode === "navigate") return
151
119
  })
152
120
  `;
153
121
  }
@@ -35,7 +35,8 @@ export interface HeadPrefixOptions {
35
35
  */
36
36
  export function buildHeadPrefix(options: HeadPrefixOptions): string {
37
37
  const escapedNonce = escapeAttr(options.nonce);
38
- let prefix = `<meta name="csp-nonce" nonce="${escapedNonce}">`;
38
+ /* `content` is the JS-readable copy. Browsers hide `getAttribute("nonce")` after parse. */
39
+ let prefix = `<meta name="csp-nonce" nonce="${escapedNonce}" content="${escapedNonce}">`;
39
40
  if (options.resolvedHead.meta?.viewport === undefined) {
40
41
  prefix += `<meta name="viewport" content="width=device-width, initial-scale=1">`;
41
42
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Solid 2 hydratable `<head>` walks `head.firstChild` as `<!--$-->`.
3
+ * Vite (`/@vite/client`) and Flare headPrefix used to prepend nodes, so
4
+ * getNextMarker started on the wrong child and hydration threw
5
+ * `Cannot read properties of null (reading 'firstChild'|'nextSibling')`.
6
+ *
7
+ * Move the Solid `<!--$-->…<!--/-->` region to the start of `<head>`.
8
+ * Injected scripts/meta stay in `<head>` (still before `</head>` / body).
9
+ */
10
+ export function hoistHydrationHeadMarkers(html: string): string {
11
+ const open = /<head[^>]*>/i.exec(html);
12
+ if (!open) return html;
13
+ const start = open.index + open[0].length;
14
+ const end = html.indexOf("</head>", start);
15
+ if (end === -1) return html;
16
+ const inner = html.slice(start, end);
17
+ const first = inner.indexOf("<!--$-->");
18
+ if (first <= 0) return html;
19
+ const last = inner.lastIndexOf("<!--/-->");
20
+ if (last === -1 || last < first) return html;
21
+ const closeAt = last + "<!--/-->".length;
22
+ const solid = inner.slice(first, closeAt);
23
+ const rest = inner.slice(0, first) + inner.slice(closeAt);
24
+ return html.slice(0, start) + solid + rest + html.slice(end);
25
+ }