@lovrozagar/flare 0.1.2 → 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.
- 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 +7 -0
- 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 +93 -35
- 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 +164 -56
- 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
package/src/errors/index.ts
CHANGED
|
@@ -46,28 +46,60 @@ export class UnauthorizedError extends Error {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
const SAFE_PROTOCOLS = new Set(["http:", "https:"]);
|
|
49
|
+
const SENTINEL_ORIGIN = "https://flare.invalid";
|
|
50
|
+
const HAS_SCHEME = /^[a-zA-Z][a-zA-Z+\-.]*:/;
|
|
49
51
|
|
|
50
52
|
/**
|
|
51
53
|
* Allowlist-based redirect URL validation using the URL constructor.
|
|
52
|
-
* Lets the platform's URL parser handle
|
|
53
|
-
* instead of a regex blocklist that can be bypassed.
|
|
54
|
+
* Lets the platform's URL parser handle encoding, backslash normalization,
|
|
55
|
+
* and unicode instead of a regex blocklist that can be bypassed.
|
|
56
|
+
*
|
|
57
|
+
* Protocol-relative (`//host`) is not a same-origin path — `startsWith("/")`
|
|
58
|
+
* would treat it as one. Internal `to` must stay on the sentinel origin.
|
|
54
59
|
*/
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
function validateRedirectUrl(raw: string, mode: "internal" | "external"): string {
|
|
61
|
+
if (/[\0\r\n]/.test(raw)) {
|
|
62
|
+
throw new Error(`Unsafe redirect URL: ${raw}`);
|
|
63
|
+
}
|
|
58
64
|
|
|
59
65
|
let parsed: URL;
|
|
60
66
|
try {
|
|
61
|
-
parsed = new URL(
|
|
67
|
+
parsed = new URL(raw, SENTINEL_ORIGIN);
|
|
62
68
|
} catch {
|
|
63
|
-
throw new Error(`Unsafe redirect URL: ${
|
|
69
|
+
throw new Error(`Unsafe redirect URL: ${raw}`);
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
if (!SAFE_PROTOCOLS.has(parsed.protocol)) {
|
|
67
|
-
throw new Error(`Unsafe redirect URL: ${
|
|
73
|
+
throw new Error(`Unsafe redirect URL: ${raw}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sameOrigin = parsed.origin === SENTINEL_ORIGIN;
|
|
77
|
+
if (mode === "internal") {
|
|
78
|
+
let decodedPath = parsed.pathname;
|
|
79
|
+
try {
|
|
80
|
+
decodedPath = decodeURIComponent(parsed.pathname);
|
|
81
|
+
} catch {
|
|
82
|
+
/* keep pathname */
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
!sameOrigin ||
|
|
86
|
+
!raw.startsWith("/") ||
|
|
87
|
+
raw.startsWith("//") ||
|
|
88
|
+
raw.includes("\\") ||
|
|
89
|
+
parsed.pathname.startsWith("//") ||
|
|
90
|
+
decodedPath.includes("\\") ||
|
|
91
|
+
decodedPath.startsWith("//")
|
|
92
|
+
) {
|
|
93
|
+
throw new Error(`Unsafe redirect URL: ${raw}`);
|
|
94
|
+
}
|
|
95
|
+
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!sameOrigin && !HAS_SCHEME.test(raw.trimStart())) {
|
|
99
|
+
throw new Error(`Unsafe redirect URL: ${raw}`);
|
|
68
100
|
}
|
|
69
101
|
|
|
70
|
-
return
|
|
102
|
+
return raw;
|
|
71
103
|
}
|
|
72
104
|
|
|
73
105
|
export class RedirectResponse extends Error {
|
|
@@ -80,10 +112,10 @@ export class RedirectResponse extends Error {
|
|
|
80
112
|
constructor(options: RawRedirectOptions) {
|
|
81
113
|
super("Redirect");
|
|
82
114
|
if ("href" in options) {
|
|
83
|
-
this.url =
|
|
115
|
+
this.url = validateRedirectUrl(options.href, "external");
|
|
84
116
|
this.external = true;
|
|
85
117
|
} else {
|
|
86
|
-
this.url = options.to;
|
|
118
|
+
this.url = validateRedirectUrl(options.to, "internal");
|
|
87
119
|
this.external = false;
|
|
88
120
|
}
|
|
89
121
|
this.status = options.status ?? 303;
|
|
@@ -136,6 +168,8 @@ export function redirect(options: {
|
|
|
136
168
|
if (options.to === undefined) {
|
|
137
169
|
throw new Error("redirect() requires either 'to' or 'href'");
|
|
138
170
|
}
|
|
171
|
+
/* Validate the raw path before buildUrl collapses leading slashes (`//host` → `/host`) */
|
|
172
|
+
validateRedirectUrl(options.to, "internal");
|
|
139
173
|
const url = buildUrl({ params: options.params, search: options.search, to: options.to });
|
|
140
174
|
throw new RedirectResponse({ replace: options.replace, status: options.status, to: url });
|
|
141
175
|
}
|
package/src/fonts/component.tsx
CHANGED
package/src/form/index.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { createMemo, createSignal, For,
|
|
2
|
-
import {
|
|
1
|
+
import { createMemo, createSignal, For, omit, Show } from "solid-js";
|
|
2
|
+
import type { JSX } from "@solidjs/web";
|
|
3
|
+
import { Dynamic } from "@solidjs/web";
|
|
3
4
|
import type { FlattenedError } from "../errors/index.ts";
|
|
4
5
|
import { ServerFnValidationError } from "../errors/index.ts";
|
|
6
|
+
import { FORM_FN_FIELD, serverFnPath } from "../protocol.ts";
|
|
5
7
|
import type { ServerFn } from "../server-fn/index.ts";
|
|
6
8
|
|
|
7
9
|
/* Duplicated here to avoid importing server-context (uses node:async_hooks)
|
|
@@ -51,16 +53,20 @@ interface FormOwnProps<TInput, TOutput> {
|
|
|
51
53
|
export type FormProps<TInput, TOutput> = FormOwnProps<TInput, TOutput> &
|
|
52
54
|
Omit<JSX.FormHTMLAttributes<HTMLFormElement>, "action" | "children" | "enctype" | "method">;
|
|
53
55
|
|
|
56
|
+
/** Seed `form.error()` after a no-JS PE POST with form-level validation errors. */
|
|
57
|
+
export function seedFormErrorFromSsr(ssrCtx: FormActionContext | undefined): Error | null {
|
|
58
|
+
if (!ssrCtx?.formErrors.length) return null;
|
|
59
|
+
return new ServerFnValidationError({
|
|
60
|
+
fieldErrors: ssrCtx.fieldErrors,
|
|
61
|
+
formErrors: ssrCtx.formErrors,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
/* ── Form component ────────────────────────────────────────────────── */
|
|
55
66
|
|
|
56
67
|
export function Form<TInput, TOutput>(props: FormProps<TInput, TOutput>): JSX.Element {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
"children",
|
|
60
|
-
"enctype",
|
|
61
|
-
"onError",
|
|
62
|
-
"onSuccess",
|
|
63
|
-
]);
|
|
68
|
+
const local = props as FormOwnProps<TInput, TOutput> & Record<string, unknown>;
|
|
69
|
+
const rest = omit(local, "action", "children", "enctype", "onError", "onSuccess");
|
|
64
70
|
|
|
65
71
|
const reg = createMemo(() => local.action._registration);
|
|
66
72
|
const fnId = createMemo(() => reg()?.id ?? "");
|
|
@@ -70,7 +76,7 @@ export function Form<TInput, TOutput>(props: FormProps<TInput, TOutput>): JSX.El
|
|
|
70
76
|
const ssrCtx = typeof window === "undefined" && _getFormActionContext ? _getFormActionContext(fnId()) : undefined;
|
|
71
77
|
|
|
72
78
|
const [pending, setPending] = createSignal(false);
|
|
73
|
-
const [error, setError] = createSignal<Error | null>(
|
|
79
|
+
const [error, setError] = createSignal<Error | null>(seedFormErrorFromSsr(ssrCtx));
|
|
74
80
|
const [fieldErrors, setFieldErrors] = createSignal<Record<string, string[]>>(ssrCtx?.fieldErrors ?? {});
|
|
75
81
|
const [result, setResult] = createSignal<TOutput | undefined>(undefined);
|
|
76
82
|
|
|
@@ -116,7 +122,7 @@ export function Form<TInput, TOutput>(props: FormProps<TInput, TOutput>): JSX.El
|
|
|
116
122
|
setFieldErrors({});
|
|
117
123
|
|
|
118
124
|
try {
|
|
119
|
-
const url =
|
|
125
|
+
const url = serverFnPath(id, name);
|
|
120
126
|
const res = await fetch(url, {
|
|
121
127
|
body: formData,
|
|
122
128
|
method: "POST",
|
|
@@ -165,7 +171,7 @@ export function Form<TInput, TOutput>(props: FormProps<TInput, TOutput>): JSX.El
|
|
|
165
171
|
method="post"
|
|
166
172
|
onSubmit={handleSubmit}
|
|
167
173
|
>
|
|
168
|
-
<input name=
|
|
174
|
+
<input name={FORM_FN_FIELD} type="hidden" value={fnId()} />
|
|
169
175
|
{local.children(formCtx)}
|
|
170
176
|
</form>
|
|
171
177
|
) as JSX.Element;
|
package/src/generators/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type PrefetchStrategy = false | "intent" | "render" | "viewport";
|
|
|
11
11
|
export type ExtractedStaticDeferMode = "resolve" | "stream";
|
|
12
12
|
|
|
13
13
|
export interface ExtractedCacheConfig {
|
|
14
|
+
cdnTags?: string[];
|
|
14
15
|
client?: {
|
|
15
16
|
cacheDeferred?: boolean;
|
|
16
17
|
gcTime?: number;
|
|
@@ -37,6 +38,7 @@ export interface RouteDefinition {
|
|
|
37
38
|
exportName: string;
|
|
38
39
|
filePath: string;
|
|
39
40
|
cache: ExtractedCacheConfig;
|
|
41
|
+
hasAuthorize?: boolean;
|
|
40
42
|
hasInput: boolean;
|
|
41
43
|
intercept?: ExtractedInterceptConfig;
|
|
42
44
|
responseRoute: boolean;
|
|
@@ -76,6 +78,23 @@ function writeGenFile(filePath: string, body: string, generator: string): void {
|
|
|
76
78
|
writeFileSync(filePath, fullContent, "utf-8");
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Recursive source listing, normalized to posix separators and sorted.
|
|
83
|
+
*
|
|
84
|
+
* `readdirSync` order is filesystem- and runtime-dependent — node, bun, and
|
|
85
|
+
* deno disagree — and generated files are checked in, so an unsorted scan
|
|
86
|
+
* makes `routes.gen.ts` churn based on who ran the build. Sorting by code
|
|
87
|
+
* unit (not `localeCompare`, which follows ICU locale data) pins the order.
|
|
88
|
+
* Returns [] when the directory is missing.
|
|
89
|
+
*/
|
|
90
|
+
function listSourceEntries(srcPath: string): string[] {
|
|
91
|
+
try {
|
|
92
|
+
return (readdirSync(srcPath, { recursive: true }) as string[]).map((entry) => entry.replace(/\\/g, "/")).sort();
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
79
98
|
const BUILDER_TO_TYPE: Record<string, RouteDefinition["type"]> = {
|
|
80
99
|
createLayout: "layout",
|
|
81
100
|
createPage: "page",
|
|
@@ -206,6 +225,41 @@ function extractNestedBlock(body: string, key: string): string | null {
|
|
|
206
225
|
return null;
|
|
207
226
|
}
|
|
208
227
|
|
|
228
|
+
function extractStringArray(text: string, key: string): string[] | undefined {
|
|
229
|
+
const re = new RegExp(`${key}\\s*:\\s*\\[`);
|
|
230
|
+
const match = re.exec(text);
|
|
231
|
+
if (!match) return undefined;
|
|
232
|
+
|
|
233
|
+
const start = (match.index ?? 0) + match[0].length;
|
|
234
|
+
let depth = 1;
|
|
235
|
+
let end = start;
|
|
236
|
+
for (let i = start; i < text.length; i++) {
|
|
237
|
+
const skipped = skipStringOrComment(text, i);
|
|
238
|
+
if (skipped !== i) {
|
|
239
|
+
i = skipped;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (text[i] === "[") depth++;
|
|
243
|
+
else if (text[i] === "]") {
|
|
244
|
+
depth--;
|
|
245
|
+
if (depth === 0) {
|
|
246
|
+
end = i;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const inner = text.slice(start, end);
|
|
252
|
+
const items: string[] = [];
|
|
253
|
+
const itemRe = /["'`]([^"'`]*)["'`]/g;
|
|
254
|
+
let itemMatch: RegExpExecArray | null = itemRe.exec(inner);
|
|
255
|
+
while (itemMatch) {
|
|
256
|
+
const value = itemMatch[1];
|
|
257
|
+
if (value) items.push(value);
|
|
258
|
+
itemMatch = itemRe.exec(inner);
|
|
259
|
+
}
|
|
260
|
+
return items;
|
|
261
|
+
}
|
|
262
|
+
|
|
209
263
|
function extractNumeric(text: string, key: string, unit?: "ms" | "s"): number | undefined {
|
|
210
264
|
/* try duration string first: key: "5m" */
|
|
211
265
|
const dm = DURATION_STR_RE(key).exec(text);
|
|
@@ -295,6 +349,13 @@ export function extractCacheFromChain(chainText: string): ExtractedCacheConfig {
|
|
|
295
349
|
config.isr = true;
|
|
296
350
|
}
|
|
297
351
|
|
|
352
|
+
/* cdn: { tags: ["a", "b"] } — static string arrays only; function tags are computed at render */
|
|
353
|
+
const cdnBlock = extractNestedBlock(body, "cdn");
|
|
354
|
+
if (cdnBlock) {
|
|
355
|
+
const tags = extractStringArray(cdnBlock, "tags");
|
|
356
|
+
if (tags && tags.length > 0) config.cdnTags = tags;
|
|
357
|
+
}
|
|
358
|
+
|
|
298
359
|
return config;
|
|
299
360
|
}
|
|
300
361
|
|
|
@@ -436,6 +497,7 @@ export function extractRouteDefinitions(src: string, filePath: string): RouteDef
|
|
|
436
497
|
const hasAuthenticateOptional = /\.authenticateOptional\s*\(/.test(chainText);
|
|
437
498
|
const hasAuthenticate = /\.authenticate\s*\(/.test(chainText);
|
|
438
499
|
const authenticateMode: AuthenticateMode = hasAuthenticateOptional ? "optional" : !!hasAuthenticate;
|
|
500
|
+
const hasAuthorize = /\.authorize\s*\(/.test(chainText);
|
|
439
501
|
const hasInput = /\.input\s*(?:<[^>]*>)?\s*\(/.test(chainText);
|
|
440
502
|
const responseRoute = /\.response\s*\(/.test(chainText);
|
|
441
503
|
const cache = extractCacheFromChain(chainText);
|
|
@@ -446,6 +508,7 @@ export function extractRouteDefinitions(src: string, filePath: string): RouteDef
|
|
|
446
508
|
cache,
|
|
447
509
|
exportName,
|
|
448
510
|
filePath,
|
|
511
|
+
hasAuthorize,
|
|
449
512
|
hasInput,
|
|
450
513
|
intercept,
|
|
451
514
|
responseRoute,
|
|
@@ -581,6 +644,11 @@ function formatRouteMeta(def: RouteDefinition): string {
|
|
|
581
644
|
const parts: string[] = [];
|
|
582
645
|
if (def.authenticateMode === true) parts.push("authenticate: true");
|
|
583
646
|
else if (def.authenticateMode === "optional") parts.push(`authenticate: "optional"`);
|
|
647
|
+
if (def.hasAuthorize) parts.push("authorize: true");
|
|
648
|
+
if (def.cache.cdnTags && def.cache.cdnTags.length > 0) {
|
|
649
|
+
const tagLit = def.cache.cdnTags.map((t) => `"${escapeCodegenStr(t)}"`).join(", ");
|
|
650
|
+
parts.push(`tags: [${tagLit}]`);
|
|
651
|
+
}
|
|
584
652
|
|
|
585
653
|
if (def.cache.client) {
|
|
586
654
|
const cp: string[] = [];
|
|
@@ -1106,17 +1174,7 @@ export function scanSourceFiles(options: ScanOptions): RouteDefinition[] {
|
|
|
1106
1174
|
const prefix = options.ignorePrefix ?? "_";
|
|
1107
1175
|
const defs: RouteDefinition[] = [];
|
|
1108
1176
|
|
|
1109
|
-
|
|
1110
|
-
try {
|
|
1111
|
-
entries = readdirSync(srcPath, { recursive: true }) as string[];
|
|
1112
|
-
} catch {
|
|
1113
|
-
return defs;
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
for (const entry of entries) {
|
|
1117
|
-
/* Normalize to posix separators */
|
|
1118
|
-
const rel = entry.replace(/\\/g, "/");
|
|
1119
|
-
|
|
1177
|
+
for (const rel of listSourceEntries(srcPath)) {
|
|
1120
1178
|
/* Skip non-ts files */
|
|
1121
1179
|
if (!rel.endsWith(".ts") && !rel.endsWith(".tsx")) continue;
|
|
1122
1180
|
|
|
@@ -1254,15 +1312,7 @@ export function inspectFsCodegenLayout(options: ScanOptions): FsLayoutIssue[] {
|
|
|
1254
1312
|
const prefix = options.ignorePrefix ?? "_";
|
|
1255
1313
|
const issues: FsLayoutIssue[] = [];
|
|
1256
1314
|
|
|
1257
|
-
|
|
1258
|
-
try {
|
|
1259
|
-
entries = readdirSync(srcPath, { recursive: true }) as string[];
|
|
1260
|
-
} catch {
|
|
1261
|
-
return issues;
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
for (const entry of entries) {
|
|
1265
|
-
const rel = entry.replace(/\\/g, "/");
|
|
1315
|
+
for (const rel of listSourceEntries(srcPath)) {
|
|
1266
1316
|
if (!rel.endsWith(".ts") && !rel.endsWith(".tsx") && !rel.endsWith(".js") && !rel.endsWith(".jsx")) continue;
|
|
1267
1317
|
if (rel.includes("_gen/") || rel.includes("node_modules")) continue;
|
|
1268
1318
|
if (rel.endsWith(".gen.ts") || rel.endsWith(".gen.tsx")) continue;
|
|
@@ -1421,16 +1471,7 @@ export function scanSourceFilesFsCodegen(options: ScanOptions): RouteDefinition[
|
|
|
1421
1471
|
const routesDir = "routes";
|
|
1422
1472
|
const defs: RouteDefinition[] = [];
|
|
1423
1473
|
|
|
1424
|
-
|
|
1425
|
-
try {
|
|
1426
|
-
entries = readdirSync(srcPath, { recursive: true }) as string[];
|
|
1427
|
-
} catch {
|
|
1428
|
-
return defs;
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
for (const entry of entries) {
|
|
1432
|
-
const rel = entry.replace(/\\/g, "/");
|
|
1433
|
-
|
|
1474
|
+
for (const rel of listSourceEntries(srcPath)) {
|
|
1434
1475
|
/* Only process files under routes/ */
|
|
1435
1476
|
if (!rel.startsWith(`${routesDir}/`)) continue;
|
|
1436
1477
|
|
|
@@ -1464,6 +1505,7 @@ export function scanSourceFilesFsCodegen(options: ScanOptions): RouteDefinition[
|
|
|
1464
1505
|
const hasAuthenticateOptional = /\.authenticateOptional\s*\(/.test(content);
|
|
1465
1506
|
const hasAuthenticate = /\.authenticate\s*\(/.test(content);
|
|
1466
1507
|
const authenticateMode: AuthenticateMode = hasAuthenticateOptional ? "optional" : !!hasAuthenticate;
|
|
1508
|
+
const hasAuthorize = /\.authorize\s*\(/.test(content);
|
|
1467
1509
|
const hasInput = /\.input\s*(?:<[^>]*>)?\s*\(/.test(content);
|
|
1468
1510
|
const responseRoute = /\.response\s*\(/.test(content);
|
|
1469
1511
|
const cache = extractCacheFromChain(content);
|
|
@@ -1474,6 +1516,7 @@ export function scanSourceFilesFsCodegen(options: ScanOptions): RouteDefinition[
|
|
|
1474
1516
|
cache,
|
|
1475
1517
|
exportName: "route",
|
|
1476
1518
|
filePath: posix.join(options.srcDir ?? "src", rel),
|
|
1519
|
+
hasAuthorize,
|
|
1477
1520
|
hasInput,
|
|
1478
1521
|
intercept,
|
|
1479
1522
|
responseRoute,
|
package/src/head-client/index.ts
CHANGED
|
@@ -564,6 +564,13 @@ function addCssLinkForRoute(matchId: string, href: string): void {
|
|
|
564
564
|
function addCssStyleForRoute(matchId: string, children: string): void {
|
|
565
565
|
if (typeof document === "undefined") return;
|
|
566
566
|
const el = document.createElement("style");
|
|
567
|
+
const meta = document.querySelector?.('meta[name="csp-nonce"]');
|
|
568
|
+
const tagged = document.querySelector?.("script[nonce], style[nonce]") as HTMLElement | null;
|
|
569
|
+
const nonce = meta?.getAttribute("content") || tagged?.nonce || tagged?.getAttribute("nonce") || "";
|
|
570
|
+
if (nonce) {
|
|
571
|
+
el.setAttribute("nonce", nonce);
|
|
572
|
+
el.nonce = nonce;
|
|
573
|
+
}
|
|
567
574
|
el.setAttribute("data-flare-route", matchId);
|
|
568
575
|
el.textContent = children;
|
|
569
576
|
document.head.appendChild(el);
|
package/src/history/index.ts
CHANGED
|
@@ -150,6 +150,12 @@ export function createScrollStore(maxSize = 200, ttl = DEFAULT_SCROLL_TTL): Scro
|
|
|
150
150
|
|
|
151
151
|
return {
|
|
152
152
|
get(key: string): ScrollPosition | null {
|
|
153
|
+
const ts = timestamps.get(key);
|
|
154
|
+
if (ts !== undefined && Date.now() - ts > ttl) {
|
|
155
|
+
store.delete(key);
|
|
156
|
+
timestamps.delete(key);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
153
159
|
return store.get(key) ?? null;
|
|
154
160
|
},
|
|
155
161
|
|
package/src/hydrate/index.tsx
CHANGED
|
@@ -3,8 +3,10 @@ import swConfig from "virtual:flare-sw-config";
|
|
|
3
3
|
|
|
4
4
|
declare const __FLARE_IS_DEV__: boolean | undefined;
|
|
5
5
|
const isDevDefault = typeof __FLARE_IS_DEV__ === "boolean" ? __FLARE_IS_DEV__ : false;
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
6
|
+
import { flush } from "solid-js";
|
|
7
|
+
import type { JSX } from "@solidjs/web";
|
|
8
|
+
import { createComponent, Hydration, render, hydrate as solidHydrate } from "@solidjs/web";
|
|
9
|
+
import { SSRContextProvider } from "../components/ssr-context.tsx";
|
|
8
10
|
import { createChannel } from "../broadcast/channel.ts";
|
|
9
11
|
import { BroadcastProvider } from "../broadcast/provider.tsx";
|
|
10
12
|
import type { MatchCache } from "../caches/index.ts";
|
|
@@ -17,9 +19,11 @@ import {
|
|
|
17
19
|
extractRootBoundaries,
|
|
18
20
|
hydrateHeadState,
|
|
19
21
|
loadRouteModules,
|
|
22
|
+
applyMatchCacheTags,
|
|
20
23
|
populateMatchCache,
|
|
21
24
|
} from "../hydration/index.ts";
|
|
22
25
|
import { onceIdle } from "../internal/once-idle.ts";
|
|
26
|
+
import { ATTR_HYDRATED } from "../protocol.ts";
|
|
23
27
|
import { setupNavigation } from "../navigation/index.ts";
|
|
24
28
|
import type { LoadedRouteModule } from "../navigation/types.ts";
|
|
25
29
|
import { FlareProvider, Outlet, useRouter } from "../outlet/index.tsx";
|
|
@@ -55,13 +59,10 @@ export interface HydrateOptions {
|
|
|
55
59
|
onInteraction?: () => void;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
*/
|
|
63
|
-
function Dummy(props: { children: JSX.Element }): JSX.Element {
|
|
64
|
-
return props.children;
|
|
62
|
+
function readDocumentNonce(): string {
|
|
63
|
+
if (typeof document === "undefined") return "";
|
|
64
|
+
const meta = document.querySelector('meta[name="csp-nonce"]');
|
|
65
|
+
return meta?.getAttribute("content") || meta?.getAttribute("nonce") || "";
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
function RootRenderer(props: {
|
|
@@ -71,8 +72,11 @@ function RootRenderer(props: {
|
|
|
71
72
|
renderFn: (p: RenderProps) => JSX.Element;
|
|
72
73
|
}): JSX.Element {
|
|
73
74
|
const router = useRouter();
|
|
75
|
+
/* Getter so <Outlet> does not consume hydration child ids before <html>. */
|
|
74
76
|
return props.renderFn({
|
|
75
|
-
children
|
|
77
|
+
get children() {
|
|
78
|
+
return <Outlet />;
|
|
79
|
+
},
|
|
76
80
|
loaderData: props.data,
|
|
77
81
|
location: props.location,
|
|
78
82
|
preloaderContext: props.preloaderContext,
|
|
@@ -156,7 +160,7 @@ export async function hydrate(router: RouterArg, options?: HydrateOptions): Prom
|
|
|
156
160
|
}
|
|
157
161
|
const { QueryClientProvider } = await import("../query-client");
|
|
158
162
|
QCP = QueryClientProvider as typeof QCP;
|
|
159
|
-
|
|
163
|
+
/* `__flare_queries` is drained after solidHydrate — observers must attach first. */
|
|
160
164
|
}
|
|
161
165
|
|
|
162
166
|
/* Build composed rewrite: basePath (if any) + user rewrite */
|
|
@@ -178,6 +182,7 @@ export async function hydrate(router: RouterArg, options?: HydrateOptions): Prom
|
|
|
178
182
|
const rootLayout = modules.layouts.find((m) => m._type === "root-layout");
|
|
179
183
|
const nonRootLayouts = modules.layouts.filter((m) => m._type !== "root-layout");
|
|
180
184
|
const allModules: LoadedRouteModule[] = [...nonRootLayouts, modules.page];
|
|
185
|
+
applyMatchCacheTags(matchCache, allModules, modules.params, search);
|
|
181
186
|
|
|
182
187
|
/* Build error map from per-match error names (survives serialization) */
|
|
183
188
|
const errorMap = new Map<string, Error>();
|
|
@@ -199,61 +204,71 @@ export async function hydrate(router: RouterArg, options?: HydrateOptions): Prom
|
|
|
199
204
|
const rootMatchState = rootLayout ? state.matches.find((m) => m.virtualPath === rootLayout.virtualPath) : undefined;
|
|
200
205
|
|
|
201
206
|
/*
|
|
202
|
-
* Full-document hydration
|
|
203
|
-
*
|
|
207
|
+
* Full-document hydration. SSR and client wrappers must match —
|
|
208
|
+
* Solid 2 `_hk` ids follow the owner tree.
|
|
204
209
|
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
210
|
+
* Both: Hydration > QCP? > SSRContextProvider > Theme > Direction >
|
|
211
|
+
* Broadcast > FlareProvider > rootRenderFn({children: Outlet})
|
|
207
212
|
*/
|
|
208
213
|
const channel = createChannel();
|
|
209
214
|
const renderInner = () => (
|
|
210
|
-
<
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
215
|
+
<SSRContextProvider
|
|
216
|
+
value={{
|
|
217
|
+
direction: r.direction,
|
|
218
|
+
flareStateScript: "",
|
|
219
|
+
isServer: false,
|
|
220
|
+
nonce: readDocumentNonce(),
|
|
221
|
+
theme: r.theme,
|
|
222
|
+
}}
|
|
223
|
+
>
|
|
224
|
+
<ThemeProvider config={r.theme}>
|
|
225
|
+
<DirectionProvider config={r.direction}>
|
|
226
|
+
<BroadcastProvider value={channel}>
|
|
227
|
+
<FlareProvider
|
|
228
|
+
boundaries={rootBoundaries}
|
|
229
|
+
caseSensitive={r.caseSensitive}
|
|
230
|
+
layouts={r.layouts}
|
|
231
|
+
localeConfig={r.locale}
|
|
232
|
+
matchCache={matchCache}
|
|
233
|
+
matches={initialMatches}
|
|
234
|
+
onContextReady={(ctx) => {
|
|
235
|
+
resolveCtx(ctx);
|
|
236
|
+
}}
|
|
237
|
+
params={state.params}
|
|
238
|
+
prefetchCache={prefetchCache}
|
|
239
|
+
resolvers={state.resolvers}
|
|
240
|
+
routerCacheDefaults={r.cache?.client || undefined}
|
|
241
|
+
routeTree={r.routeTree}
|
|
242
|
+
search={search}
|
|
243
|
+
>
|
|
244
|
+
{rootLayout?.render ? (
|
|
245
|
+
RootRenderer({
|
|
246
|
+
data: rootMatchState?.loaderData,
|
|
247
|
+
location: {
|
|
248
|
+
hash: window.location.hash,
|
|
249
|
+
params: state.params,
|
|
250
|
+
pathname: state.pathname,
|
|
251
|
+
search,
|
|
252
|
+
url: new URL(window.location.href),
|
|
253
|
+
variablePath: "",
|
|
254
|
+
virtualPath: rootLayout.virtualPath,
|
|
255
|
+
},
|
|
256
|
+
preloaderContext: rootMatchState?.preloaderContext,
|
|
257
|
+
renderFn: rootLayout.render,
|
|
258
|
+
})
|
|
259
|
+
) : (
|
|
260
|
+
<Outlet />
|
|
261
|
+
)}
|
|
262
|
+
</FlareProvider>
|
|
263
|
+
</BroadcastProvider>
|
|
264
|
+
</DirectionProvider>
|
|
265
|
+
</ThemeProvider>
|
|
266
|
+
</SSRContextProvider>
|
|
252
267
|
);
|
|
253
268
|
|
|
254
269
|
solidHydrate(
|
|
255
270
|
() => (
|
|
256
|
-
<
|
|
271
|
+
<Hydration>
|
|
257
272
|
{QCP && queryClientInstance
|
|
258
273
|
? createComponent(QCP, {
|
|
259
274
|
get children() {
|
|
@@ -262,12 +277,27 @@ export async function hydrate(router: RouterArg, options?: HydrateOptions): Prom
|
|
|
262
277
|
client: queryClientInstance,
|
|
263
278
|
})
|
|
264
279
|
: renderInner()}
|
|
265
|
-
</
|
|
280
|
+
</Hydration>
|
|
266
281
|
),
|
|
267
282
|
document,
|
|
268
283
|
);
|
|
284
|
+
flush();
|
|
269
285
|
finishHydration();
|
|
270
286
|
|
|
287
|
+
if (queryClientInstance) {
|
|
288
|
+
/*
|
|
289
|
+
* TanStack `useQuery` (no HydrationCoordinator) attaches its cache
|
|
290
|
+
* subscriber on a microtask after the hydration replay. Streamed
|
|
291
|
+
* `__flare_queries` must land after that attach — applying it earlier
|
|
292
|
+
* writes deferred data into a cache with no observers, and the
|
|
293
|
+
* SSR-serialized fallback stays on screen.
|
|
294
|
+
*/
|
|
295
|
+
await Promise.resolve();
|
|
296
|
+
await Promise.resolve();
|
|
297
|
+
installQueryCacheResolver(queryClientInstance);
|
|
298
|
+
flush();
|
|
299
|
+
}
|
|
300
|
+
|
|
271
301
|
replaceHistoryState(window.location.pathname, state.params, window.location.search, {
|
|
272
302
|
hash: window.location.hash,
|
|
273
303
|
historyIndex: 0,
|
|
@@ -352,7 +382,7 @@ export async function hydrate(router: RouterArg, options?: HydrateOptions): Prom
|
|
|
352
382
|
render(() => <DevErrorOverlay />, overlayRoot);
|
|
353
383
|
}
|
|
354
384
|
|
|
355
|
-
document.documentElement.setAttribute(
|
|
385
|
+
document.documentElement.setAttribute(ATTR_HYDRATED, "");
|
|
356
386
|
}
|
|
357
387
|
|
|
358
388
|
/* Service worker registration / unregistration */
|
package/src/hydration/index.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { batch } from "solid-js";
|
|
2
1
|
import type { GlobalBoundaries } from "../boundaries/index.ts";
|
|
3
|
-
import type
|
|
2
|
+
import { resolveCacheTags, type CachedMatch, type MatchCache } from "../caches/index.ts";
|
|
4
3
|
import { applyPerRouteHeads, initRouteHierarchy } from "../head-client/index.ts";
|
|
5
4
|
import { isRenderFn, retryImport } from "../internal.ts";
|
|
6
5
|
import type { LoadedRouteModule, LoadedRouteModules } from "../navigation/types.ts";
|
|
@@ -80,10 +79,8 @@ export function buildMatches(ctx: FlareProviderContext, modules: LoadedRouteModu
|
|
|
80
79
|
};
|
|
81
80
|
});
|
|
82
81
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
ctx.setParams(modules.params);
|
|
86
|
-
});
|
|
82
|
+
ctx.setMatches(clientMatches);
|
|
83
|
+
ctx.setParams(modules.params);
|
|
87
84
|
}
|
|
88
85
|
|
|
89
86
|
export function extractRootBoundaries(rootLayout: LoadedRouteModule | undefined): GlobalBoundaries {
|
|
@@ -171,6 +168,29 @@ export function populateMatchCache(
|
|
|
171
168
|
}
|
|
172
169
|
}
|
|
173
170
|
|
|
171
|
+
export function applyMatchCacheTags(
|
|
172
|
+
matchCache: MatchCache,
|
|
173
|
+
modules: LoadedRouteModule[],
|
|
174
|
+
params: Record<string, string | string[]>,
|
|
175
|
+
search: SearchParams,
|
|
176
|
+
): void {
|
|
177
|
+
for (const mod of modules) {
|
|
178
|
+
const deps = mod.effectsConfig?.loaderDeps?.({ search }) ?? [];
|
|
179
|
+
const matchId = computeMatchId({
|
|
180
|
+
loaderDeps: () => deps,
|
|
181
|
+
params,
|
|
182
|
+
routeId: mod.virtualPath,
|
|
183
|
+
search,
|
|
184
|
+
});
|
|
185
|
+
const entry = matchCache.get(matchId);
|
|
186
|
+
if (!entry) continue;
|
|
187
|
+
const tags = resolveCacheTags(mod.cache, params);
|
|
188
|
+
if (!tags) continue;
|
|
189
|
+
entry.tags = tags;
|
|
190
|
+
matchCache.set(entry);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
174
194
|
export function hydrateHeadState(state: Pick<HydrationState, "ph">): void {
|
|
175
195
|
if (state.ph) {
|
|
176
196
|
/* First entry is root layout — its title is the fallback when pages don't define .head() */
|