@lovrozagar/flare 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -19
- package/package.json +8 -6
- package/src/broadcast/index.ts +1 -1
- package/src/broadcast/provider.tsx +2 -2
- package/src/caches/index.ts +26 -0
- package/src/components/await.tsx +36 -39
- package/src/components/dev-error-overlay.tsx +8 -9
- package/src/components/direction-script.tsx +2 -2
- package/src/components/locale-script.tsx +2 -2
- package/src/components/reset-css.tsx +1 -1
- package/src/components/ssr-context.tsx +7 -14
- package/src/components/theme-script.tsx +2 -2
- package/src/components/view-transition-css.tsx +1 -1
- package/src/defer/index.ts +22 -0
- package/src/direction/index.tsx +61 -36
- package/src/errors/index.ts +45 -11
- package/src/fonts/component.tsx +2 -1
- package/src/form/index.tsx +18 -12
- package/src/generators/index.ts +73 -30
- package/src/head-client/index.ts +86 -29
- package/src/history/index.ts +6 -0
- package/src/hydrate/index.tsx +90 -60
- package/src/hydration/index.ts +26 -6
- package/src/i18n/index.ts +11 -2
- package/src/image/index.tsx +7 -4
- package/src/intercept-outlet/index.tsx +6 -2
- package/src/lazy/index.tsx +50 -50
- package/src/link/index.tsx +97 -58
- package/src/loader-pipeline/index.ts +119 -38
- package/src/locale/cookie.ts +15 -0
- package/src/locale/index.tsx +24 -35
- package/src/locale.ts +1 -0
- package/src/middleware/builtins/api-proxy.ts +58 -4
- package/src/middleware/builtins/cdn-proxy.ts +24 -3
- package/src/middleware/builtins/i18n.ts +23 -25
- package/src/middleware/builtins/keepalive.ts +2 -3
- package/src/mount/index.ts +8 -1
- package/src/navigation/index.ts +575 -129
- package/src/navigation/types.ts +11 -1
- package/src/navigation-progress/index.tsx +17 -16
- package/src/ndjson-client/index.ts +42 -17
- package/src/ndjson-server/index.ts +78 -29
- package/src/outlet/index.tsx +192 -97
- package/src/outlet/types.ts +9 -1
- package/src/plugins/dev-cdn-cache.ts +18 -5
- package/src/plugins/dev-dashboard/client.tsx +130 -118
- package/src/plugins/dev-prerender.ts +6 -1
- package/src/plugins/dev-server.ts +12 -3
- package/src/plugins/image-plugin.ts +18 -4
- package/src/plugins/index.ts +10 -7
- package/src/plugins/prerender-plugin.ts +3 -3
- package/src/plugins/server-fn.ts +15 -20
- package/src/plugins/solid-jsx.d.ts +3 -3
- package/src/plugins/sx-ast/atomic.ts +5 -5
- package/src/plugins/sx-ast/index.ts +9 -0
- package/src/plugins/sx.d.ts +4 -5
- package/src/plugins/virtual.ts +11 -6
- package/src/prerender/index.ts +68 -32
- package/src/protocol.ts +65 -0
- package/src/query-client/index.tsx +73 -14
- package/src/rewrite/index.ts +10 -4
- package/src/route-builder/create-layout.ts +1 -1
- package/src/route-builder/create-root-layout.ts +1 -1
- package/src/route-builder/types.ts +5 -1
- package/src/router-primitives/types.ts +2 -0
- package/src/security/index.ts +41 -5
- package/src/server/index.ts +1 -2
- package/src/server-fn/get-input.ts +90 -0
- package/src/server-fn/http-error.ts +13 -0
- package/src/server-fn/index.ts +94 -70
- package/src/server-fn-query.ts +9 -17
- package/src/server-handler/etag.ts +4 -2
- package/src/server-handler/headers.ts +1 -2
- package/src/server-handler/index.ts +166 -58
- package/src/server-handler/types.ts +2 -0
- package/src/service-worker/template.ts +10 -42
- package/src/ssr/head-prefix.ts +2 -1
- package/src/ssr/hoist-head-markers.ts +25 -0
- package/src/ssr/index.tsx +109 -92
- package/src/state-parser/index.ts +38 -26
- package/src/styles/index.ts +89 -60
- package/src/testing/index.ts +8 -3
- package/src/theme/index.tsx +35 -32
- package/src/tracing/timing.ts +6 -1
- 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 {
|
|
70
|
-
|
|
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
|
|
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() ===
|
|
366
|
-
headers.set("Vary",
|
|
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",
|
|
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 = (
|
|
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
|
|
630
|
-
if (
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
.
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
|
988
|
+
} else if (isServerFnPathname(url.pathname)) {
|
|
925
989
|
requestType = "server-fn";
|
|
926
|
-
} else if (url.pathname.startsWith(
|
|
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
|
|
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
|
|
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
|
|
994
|
-
!url.pathname.startsWith(
|
|
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
|
-
|
|
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(
|
|
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}
|
|
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
|
|
1022
|
-
|
|
1023
|
-
|
|
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);
|
|
@@ -1138,10 +1214,10 @@ export function createServerHandler<
|
|
|
1138
1214
|
});
|
|
1139
1215
|
}
|
|
1140
1216
|
|
|
1141
|
-
const resolvedHead = pipelineResult.
|
|
1217
|
+
const resolvedHead = pipelineResult.mergedHead ?? {};
|
|
1142
1218
|
|
|
1143
1219
|
const fuzzyLogs = enableServerLogs ? getServerLogs() : undefined;
|
|
1144
|
-
if (request.headers.get(
|
|
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(
|
|
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(
|
|
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(
|
|
1208
|
-
const isPrerenderRequest = request.headers.get(
|
|
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 (
|
|
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(
|
|
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: {
|
|
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: {
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
1432
|
+
const isPrefetch = request.headers.get(HEADER_PREFETCH) === HEADER_FLAG;
|
|
1351
1433
|
const MAX_STALE_IDS = 100;
|
|
1352
|
-
const staleMatchIds = (request.headers.get(
|
|
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
|
|
|
@@ -1435,7 +1517,7 @@ export function createServerHandler<
|
|
|
1435
1517
|
const logsForTransport = collectedLogs && collectedLogs.length > 0 ? collectedLogs : undefined;
|
|
1436
1518
|
|
|
1437
1519
|
if (!isDataRequest) {
|
|
1438
|
-
const resolvedHead = pipelineResult.
|
|
1520
|
+
const resolvedHead = pipelineResult.mergedHead ?? {};
|
|
1439
1521
|
|
|
1440
1522
|
let ssrResult: ReturnType<typeof renderToStream>;
|
|
1441
1523
|
setRewrite(composedRewrite);
|
|
@@ -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
|
|
1620
|
+
* Skip if flare-isr header present (prevents recursion from
|
|
1535
1621
|
* the background re-render itself triggering another populate).
|
|
1536
1622
|
*/
|
|
1537
|
-
if (
|
|
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: {
|
|
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: {
|
|
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(
|
|
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 (
|
|
88
|
-
if (event.request.headers.has(
|
|
89
|
-
if (url.pathname.startsWith(
|
|
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
|
-
/*
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
}
|
package/src/ssr/head-prefix.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|