@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.
- package/README.md +44 -19
- package/package.json +10 -7
- 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
|
@@ -149,6 +149,15 @@ function composeCss(
|
|
|
149
149
|
else appRules.push(rule);
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/* At-rules last so @media utilities beat earlier base utilities of equal
|
|
153
|
+
* specificity. classPool insertion order is first-seen across the whole
|
|
154
|
+
* build, so a later page's `fontSize: 12px` would otherwise override an
|
|
155
|
+
* earlier `@media (min-width: 1px) { fontSize: 24px }`. */
|
|
156
|
+
const atLast = (a: string, b: string) =>
|
|
157
|
+
Number(a.trimStart().startsWith("@")) - Number(b.trimStart().startsWith("@"));
|
|
158
|
+
sxRules.sort(atLast);
|
|
159
|
+
appRules.sort(atLast);
|
|
160
|
+
|
|
152
161
|
const parts: string[] = [];
|
|
153
162
|
if (twPrefaceCss) parts.push(twPrefaceCss);
|
|
154
163
|
parts.push(LAYER_PRELUDE);
|
package/src/plugins/sx.d.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
import
|
|
1
|
+
import "@solidjs/web";
|
|
2
|
+
import type { Sx } from "../styles/sx-types";
|
|
2
3
|
|
|
3
|
-
declare module "
|
|
4
|
+
declare module "@solidjs/web" {
|
|
4
5
|
namespace JSX {
|
|
5
6
|
interface HTMLAttributes<T> {
|
|
6
7
|
sx?: Sx;
|
|
7
|
-
class?: string | ClassValue[];
|
|
8
8
|
}
|
|
9
|
-
interface
|
|
9
|
+
interface SVGAttributes<T> {
|
|
10
10
|
sx?: Sx;
|
|
11
|
-
class?: string | ClassValue[];
|
|
12
11
|
}
|
|
13
12
|
}
|
|
14
13
|
}
|
package/src/plugins/virtual.ts
CHANGED
|
@@ -11,12 +11,17 @@ import type { ResolvedEntries, VitePlugin } from "./types.ts";
|
|
|
11
11
|
* when the SSR build loads this virtual module.
|
|
12
12
|
*/
|
|
13
13
|
function readClientManifest(root: string): ViteManifest | undefined {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
const candidates = [
|
|
15
|
+
join(root, "dist/client/.vite/manifest.json"),
|
|
16
|
+
/* Nitro writes the client build under `.output/public`. */
|
|
17
|
+
join(root, ".output/public/.vite/manifest.json"),
|
|
18
|
+
];
|
|
19
|
+
for (const manifestPath of candidates) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(readFileSync(manifestPath, "utf-8")) as ViteManifest;
|
|
22
|
+
} catch {
|
|
23
|
+
/* try next location */
|
|
24
|
+
}
|
|
20
25
|
}
|
|
21
26
|
return undefined;
|
|
22
27
|
}
|
package/src/prerender/index.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* For each route:
|
|
6
6
|
* 1. Create synthetic GET request
|
|
7
7
|
* 2. Call handler.fetch() → collect HTML response
|
|
8
|
-
* 3. Call handler.fetch() with
|
|
8
|
+
* 3. Call handler.fetch() with flare-data:1 header → collect NDJSON response
|
|
9
9
|
* 4. Replace CSP nonce with __FLARE_NONCE__ placeholder
|
|
10
10
|
* 5. Store entry with headers, html, ndjson
|
|
11
11
|
* 6. Generate manifest entry
|
|
@@ -15,13 +15,23 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import type { ExtractedStaticDeferMode, RouteDefinition } from "../generators/index.ts";
|
|
17
17
|
import type { StaticDeferMode } from "../route-builder/types.ts";
|
|
18
|
+
import { HEADER_DATA, HEADER_FLAG, HEADER_PRERENDER } from "../protocol.ts";
|
|
18
19
|
import type { ServerHandler } from "../server-handler/index.ts";
|
|
20
|
+
import type { FlareStore, FlareStoreEntry } from "../store/index.ts";
|
|
19
21
|
import { resolvePathParams } from "../url/index.ts";
|
|
20
22
|
|
|
21
23
|
export const NONCE_PLACEHOLDER = "__FLARE_NONCE__";
|
|
22
24
|
|
|
25
|
+
/** Parse `Surrogate-Key` into store tags. Empty / missing → undefined. */
|
|
26
|
+
export function tagsFromSurrogateKey(headers: Record<string, string>): string[] | undefined {
|
|
27
|
+
const raw = headers["surrogate-key"];
|
|
28
|
+
if (!raw) return undefined;
|
|
29
|
+
const tags = raw.split(" ").filter(Boolean);
|
|
30
|
+
return tags.length > 0 ? tags : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
/** Build-time prerender fetch — skips ISR `dynamicParams: false` store-miss 404. */
|
|
24
|
-
export const PRERENDER_HEADER =
|
|
34
|
+
export const PRERENDER_HEADER = HEADER_PRERENDER;
|
|
25
35
|
|
|
26
36
|
export interface PrerenderRoute {
|
|
27
37
|
defer?: StaticDeferMode;
|
|
@@ -82,11 +92,20 @@ export function replaceNonce(text: string, nonce: string): string {
|
|
|
82
92
|
/**
|
|
83
93
|
* Convert Response headers to a plain object, excluding hop-by-hop headers.
|
|
84
94
|
*/
|
|
95
|
+
const SKIP_PRERENDER_HEADERS = new Set([
|
|
96
|
+
"connection",
|
|
97
|
+
"content-encoding",
|
|
98
|
+
"content-length",
|
|
99
|
+
"keep-alive",
|
|
100
|
+
"set-cookie",
|
|
101
|
+
"set-cookie2",
|
|
102
|
+
"transfer-encoding",
|
|
103
|
+
]);
|
|
104
|
+
|
|
85
105
|
function headersToRecord(headers: Headers): Record<string, string> {
|
|
86
106
|
const result: Record<string, string> = {};
|
|
87
107
|
headers.forEach((value, key) => {
|
|
88
|
-
|
|
89
|
-
if (key === "transfer-encoding" || key === "connection") return;
|
|
108
|
+
if (SKIP_PRERENDER_HEADERS.has(key)) return;
|
|
90
109
|
result[key] = value;
|
|
91
110
|
});
|
|
92
111
|
return result;
|
|
@@ -106,7 +125,10 @@ async function renderRoute(
|
|
|
106
125
|
/* 1. Fetch HTML */
|
|
107
126
|
let htmlResponse: Response;
|
|
108
127
|
try {
|
|
109
|
-
htmlResponse = await handler.fetch(
|
|
128
|
+
htmlResponse = await handler.fetch(
|
|
129
|
+
new Request(url, { headers: { [PRERENDER_HEADER]: HEADER_FLAG }, method: "GET" }),
|
|
130
|
+
env,
|
|
131
|
+
);
|
|
110
132
|
} catch (e: unknown) {
|
|
111
133
|
const msg = e instanceof Error ? e.message : String(e);
|
|
112
134
|
return { error: { message: msg, pathname: route.pathname } };
|
|
@@ -129,7 +151,7 @@ async function renderRoute(
|
|
|
129
151
|
try {
|
|
130
152
|
const dataResponse = await handler.fetch(
|
|
131
153
|
new Request(url, {
|
|
132
|
-
headers: {
|
|
154
|
+
headers: { [HEADER_DATA]: HEADER_FLAG, [PRERENDER_HEADER]: HEADER_FLAG },
|
|
133
155
|
method: "GET",
|
|
134
156
|
}),
|
|
135
157
|
env,
|
|
@@ -163,7 +185,9 @@ async function renderRoute(
|
|
|
163
185
|
if (route.defer !== undefined) entry.defer = route.defer;
|
|
164
186
|
if (route.dynamicParams !== undefined) entry.dynamicParams = route.dynamicParams;
|
|
165
187
|
if (route.revalidate !== undefined) entry.revalidate = route.revalidate;
|
|
166
|
-
|
|
188
|
+
const headerTags = tagsFromSurrogateKey(htmlHeaders);
|
|
189
|
+
if (headerTags) entry.tags = headerTags;
|
|
190
|
+
else if (route.tags !== undefined) entry.tags = route.tags;
|
|
167
191
|
|
|
168
192
|
return { entry };
|
|
169
193
|
}
|
|
@@ -270,6 +294,19 @@ function mapDefer(extracted: ExtractedStaticDeferMode | undefined): StaticDeferM
|
|
|
270
294
|
return extracted;
|
|
271
295
|
}
|
|
272
296
|
|
|
297
|
+
function applyExtractedCache(route: PrerenderRoute, def: RouteDefinition): void {
|
|
298
|
+
if (def.cache.isrRevalidate !== undefined) {
|
|
299
|
+
route.revalidate = def.cache.isrRevalidate;
|
|
300
|
+
}
|
|
301
|
+
const defer = mapDefer(def.cache.isrDefer ?? def.cache.ssgDefer);
|
|
302
|
+
if (defer !== undefined) route.defer = defer;
|
|
303
|
+
const dp = mapDynamicParams(def.cache.isrDynamicParams);
|
|
304
|
+
if (dp !== undefined) route.dynamicParams = dp;
|
|
305
|
+
if (def.cache.cdnTags && def.cache.cdnTags.length > 0) {
|
|
306
|
+
route.tags = def.cache.cdnTags;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
273
310
|
/**
|
|
274
311
|
* Convert generator RouteDefinitions to PrerenderRoutes.
|
|
275
312
|
* Filters to pages with static config, validates auth constraints.
|
|
@@ -290,16 +327,24 @@ export function buildPrerenderRoutes(
|
|
|
290
327
|
const hasPrerenderConfig = def.cache.ssg || def.cache.isr;
|
|
291
328
|
if (!hasPrerenderConfig) continue;
|
|
292
329
|
|
|
293
|
-
/*
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
/* Auth + static = build error (authenticateOptional is allowed) */
|
|
330
|
+
/* Auth + static = build error (authenticateOptional is allowed).
|
|
331
|
+
Check before the on-demand-ISR skip so `.authenticate().isr()` without
|
|
332
|
+
revalidate cannot populate the store as a public page. */
|
|
297
333
|
if (def.authenticateMode === true) {
|
|
298
334
|
throw new Error(
|
|
299
335
|
`Route "${def.virtualPath}" has both authenticate and static config. ` +
|
|
300
336
|
"Authenticated routes cannot be pre-rendered.",
|
|
301
337
|
);
|
|
302
338
|
}
|
|
339
|
+
if (def.hasAuthorize) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`Route "${def.virtualPath}" has both authorize and static config. ` +
|
|
342
|
+
"Authorized routes cannot be pre-rendered.",
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* isr without revalidate = on-demand only, skip build-time prerender */
|
|
347
|
+
if (def.cache.isr && def.cache.isrRevalidate === undefined) continue;
|
|
303
348
|
|
|
304
349
|
const pathname = virtualPathToPathname(def.virtualPath);
|
|
305
350
|
const mode: "isr" | "static" = def.cache.isr ? "isr" : "static";
|
|
@@ -312,30 +357,14 @@ export function buildPrerenderRoutes(
|
|
|
312
357
|
for (const params of paramSets) {
|
|
313
358
|
const resolved = resolvePathParams(pathname, params);
|
|
314
359
|
const route: PrerenderRoute = { mode, pathname: resolved };
|
|
315
|
-
|
|
316
|
-
if (def.cache.isrRevalidate !== undefined) {
|
|
317
|
-
route.revalidate = def.cache.isrRevalidate;
|
|
318
|
-
}
|
|
319
|
-
const defer = mapDefer(def.cache.isrDefer ?? def.cache.ssgDefer);
|
|
320
|
-
if (defer !== undefined) route.defer = defer;
|
|
321
|
-
const dp = mapDynamicParams(def.cache.isrDynamicParams);
|
|
322
|
-
if (dp !== undefined) route.dynamicParams = dp;
|
|
323
|
-
|
|
360
|
+
applyExtractedCache(route, def);
|
|
324
361
|
routes.push(route);
|
|
325
362
|
}
|
|
326
363
|
continue;
|
|
327
364
|
}
|
|
328
365
|
|
|
329
366
|
const route: PrerenderRoute = { mode, pathname };
|
|
330
|
-
|
|
331
|
-
if (def.cache.isrRevalidate !== undefined) {
|
|
332
|
-
route.revalidate = def.cache.isrRevalidate;
|
|
333
|
-
}
|
|
334
|
-
const defer = mapDefer(def.cache.isrDefer ?? def.cache.ssgDefer);
|
|
335
|
-
if (defer !== undefined) route.defer = defer;
|
|
336
|
-
const dp2 = mapDynamicParams(def.cache.isrDynamicParams);
|
|
337
|
-
if (dp2 !== undefined) route.dynamicParams = dp2;
|
|
338
|
-
|
|
367
|
+
applyExtractedCache(route, def);
|
|
339
368
|
routes.push(route);
|
|
340
369
|
}
|
|
341
370
|
|
|
@@ -404,11 +433,12 @@ export function writePrerenderOutput(entries: PrerenderManifestEntry[]): Prerend
|
|
|
404
433
|
* Reads manifest.json + .html/.ndjson/.headers.json files from `staticDir`.
|
|
405
434
|
* Silently no-ops if manifest doesn't exist (e.g. dev mode without prerender).
|
|
406
435
|
*/
|
|
407
|
-
export function loadPrerenderArtifacts(staticDir: string, store:
|
|
436
|
+
export async function loadPrerenderArtifacts(staticDir: string, store: FlareStore): Promise<void> {
|
|
408
437
|
const manifestPath = join(staticDir, "manifest.json");
|
|
409
438
|
if (!existsSync(manifestPath)) return;
|
|
410
439
|
|
|
411
440
|
const manifest: PrerenderManifestRecord[] = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
441
|
+
const writes: Promise<void>[] = [];
|
|
412
442
|
|
|
413
443
|
for (const record of manifest) {
|
|
414
444
|
const base = record.pathname === "/" ? "/index" : record.pathname;
|
|
@@ -423,9 +453,15 @@ export function loadPrerenderArtifacts(staticDir: string, store: import("../stor
|
|
|
423
453
|
? JSON.parse(readFileSync(headersPath, "utf-8"))
|
|
424
454
|
: {};
|
|
425
455
|
|
|
426
|
-
|
|
456
|
+
const tags = record.tags ?? tagsFromSurrogateKey(headers);
|
|
457
|
+
const entry: FlareStoreEntry = {
|
|
427
458
|
data: { headers, html, ndjson },
|
|
428
459
|
storedAt: Date.now(),
|
|
429
|
-
}
|
|
460
|
+
};
|
|
461
|
+
if (tags && tags.length > 0) entry.tags = tags;
|
|
462
|
+
|
|
463
|
+
writes.push(store.set(`static:${record.pathname}`, entry));
|
|
430
464
|
}
|
|
465
|
+
|
|
466
|
+
await Promise.all(writes);
|
|
431
467
|
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flare wire protocol identifiers.
|
|
3
|
+
*
|
|
4
|
+
* Headers, URL prefixes, HTML attrs, and window queues are spelled out.
|
|
5
|
+
* NDJSON `t` codes and FlareState keys stay short (bandwidth).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Request: CSR data / NDJSON. Flag value is always `"1"`. */
|
|
9
|
+
export const HEADER_DATA = "flare-data";
|
|
10
|
+
/** Request: prefetch (no cookie commit, cause = prefetch). */
|
|
11
|
+
export const HEADER_PREFETCH = "flare-prefetch";
|
|
12
|
+
/** Request: comma-separated match ids the client already has. */
|
|
13
|
+
export const HEADER_STALE = "flare-stale";
|
|
14
|
+
/** Internal: ISR background re-render (prevents recursion). */
|
|
15
|
+
export const HEADER_ISR = "flare-isr";
|
|
16
|
+
/** Internal: build-time prerender fetch. */
|
|
17
|
+
export const HEADER_PRERENDER = "flare-prerender";
|
|
18
|
+
/** Shared flag for boolean protocol headers. */
|
|
19
|
+
export const HEADER_FLAG = "1";
|
|
20
|
+
|
|
21
|
+
/** Response diagnostic: HIT | MISS | STALE. Lowercase to match HTTP/2 / Node. */
|
|
22
|
+
export const FLARE_CACHE_HEADER = "flare-cache";
|
|
23
|
+
/** Response diagnostic: ISR | SSG | SSR. */
|
|
24
|
+
export const FLARE_RENDER_HEADER = "flare-render";
|
|
25
|
+
|
|
26
|
+
/** Reserved prefix for Flare-owned endpoints (keepalive, image, revalidate, server-fn). */
|
|
27
|
+
export const INTERNAL_PATH_PREFIX = "/_flare/";
|
|
28
|
+
/** Keepalive ping path. */
|
|
29
|
+
export const KEEPALIVE_PATH = `${INTERNAL_PATH_PREFIX}keepalive`;
|
|
30
|
+
/** Server function HTTP path prefix: `/_flare/server-fn/{id}/{name}`. */
|
|
31
|
+
export const SERVER_FN_PREFIX = "/_flare/server-fn";
|
|
32
|
+
|
|
33
|
+
/** Hidden `<form>` field for progressive-enhancement POST. */
|
|
34
|
+
export const FORM_FN_FIELD = "flare_fn";
|
|
35
|
+
/** Set on `<html>` after `solidHydrate` completes. */
|
|
36
|
+
export const ATTR_HYDRATED = "data-flare-hydrated";
|
|
37
|
+
|
|
38
|
+
/** Window queue: SSR deferred chunks. `__` so stream scripts survive `self.flare = {}`. */
|
|
39
|
+
export const GLOBAL_DEFER = "__flare_defer";
|
|
40
|
+
/** Window queue: SSR query-cache dehydrate. */
|
|
41
|
+
export const GLOBAL_QUERIES = "__flare_queries";
|
|
42
|
+
/** Test/runtime: in-flight lazy imports. */
|
|
43
|
+
export const GLOBAL_LAZY_PENDING = "__flare_lazy_pending";
|
|
44
|
+
/** Test/runtime: resolved lazy imports. */
|
|
45
|
+
export const GLOBAL_LAZY_LOADED = "__flare_lazy_loaded";
|
|
46
|
+
/** sessionStorage: chunk-load reload guard. */
|
|
47
|
+
export const STORAGE_CHUNK_RELOAD = "flare_chunk_reload";
|
|
48
|
+
|
|
49
|
+
export function isServerFnPathname(pathname: string): boolean {
|
|
50
|
+
return pathname === SERVER_FN_PREFIX || pathname.startsWith(`${SERVER_FN_PREFIX}/`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function serverFnPath(id: string, name: string): string {
|
|
54
|
+
return `${SERVER_FN_PREFIX}/${id}/${name}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseServerFnPathname(pathname: string): { id: string; name: string } | undefined {
|
|
58
|
+
if (!isServerFnPathname(pathname)) return undefined;
|
|
59
|
+
const rest = pathname.slice(SERVER_FN_PREFIX.length);
|
|
60
|
+
const segments = rest.split("/").filter(Boolean);
|
|
61
|
+
const id = segments[0];
|
|
62
|
+
const name = segments[1];
|
|
63
|
+
if (!id || !name) return undefined;
|
|
64
|
+
return { id, name };
|
|
65
|
+
}
|
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import { QueryClient,
|
|
2
|
-
import type
|
|
1
|
+
import { QueryClient, QueryClientContext, type QueryKey, useQuery } from "@tanstack/solid-query";
|
|
2
|
+
import { onCleanup, type Accessor } from "solid-js";
|
|
3
|
+
import type { JSX } from "@solidjs/web";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Same tree on server and client. TanStack's provider also mounts a Solid
|
|
7
|
+
* dehydration channel + HydrationCoordinator that waits for a channel `done`
|
|
8
|
+
* Flare never yields (we stream `flare.q` / `__flare_queries` instead). Without
|
|
9
|
+
* that coordinator, `useQuery` attaches its cache subscriber on a microtask
|
|
10
|
+
* after hydrate; `hydrate()` drains `__flare_queries` after that attach.
|
|
11
|
+
*/
|
|
12
|
+
export function QueryClientProvider(props: { children?: JSX.Element; client: QueryClient }): JSX.Element {
|
|
13
|
+
props.client.mount();
|
|
14
|
+
onCleanup(() => props.client.unmount());
|
|
15
|
+
return <QueryClientContext value={() => props.client}>{props.children}</QueryClientContext>;
|
|
16
|
+
}
|
|
5
17
|
|
|
6
18
|
/* ── useSuspenseQuery ────────────────────────────────────────────────── */
|
|
7
19
|
|
|
@@ -41,8 +53,7 @@ function resolveQueryKey<T extends QueryKey>(v: Accessor<T> | T): T {
|
|
|
41
53
|
|
|
42
54
|
/**
|
|
43
55
|
* Thin wrapper over TanStack's `useQuery` with suspense semantics.
|
|
44
|
-
* Delegates
|
|
45
|
-
* `createResource` for proper Solid SSR streaming integration.
|
|
56
|
+
* Delegates to TanStack's SSR-aware Solid Query implementation.
|
|
46
57
|
*/
|
|
47
58
|
export function useSuspenseQuery<TData = unknown, TError = Error, TQueryKey extends QueryKey = QueryKey>(
|
|
48
59
|
options: UseSuspenseQueryOptions<TData, TError, TQueryKey>,
|
|
@@ -104,7 +115,7 @@ export function createQueryClientGetter(options?: QueryClientGetterOptions): ()
|
|
|
104
115
|
.then(({ broadcastQueryClient }) => {
|
|
105
116
|
broadcastQueryClient({
|
|
106
117
|
broadcastChannel: channelName,
|
|
107
|
-
queryClient: clientInstance as
|
|
118
|
+
queryClient: clientInstance as never,
|
|
108
119
|
});
|
|
109
120
|
})
|
|
110
121
|
.catch(() => {});
|
|
@@ -126,16 +137,41 @@ export interface TrackedQueryClient {
|
|
|
126
137
|
client: QueryClient;
|
|
127
138
|
drain(): QueryState[];
|
|
128
139
|
getTrackedQueries(): QueryState[];
|
|
140
|
+
release(): void;
|
|
129
141
|
}
|
|
130
142
|
|
|
131
|
-
|
|
132
|
-
|
|
143
|
+
type SetQueryDataFn = QueryClient["setQueryData"];
|
|
144
|
+
|
|
145
|
+
interface QueryClientPatch {
|
|
146
|
+
listeners: Set<(args: Parameters<SetQueryDataFn>, result: unknown) => void>;
|
|
147
|
+
original: SetQueryDataFn;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const patches = new WeakMap<QueryClient, QueryClientPatch>();
|
|
151
|
+
|
|
152
|
+
function ensurePatch(client: QueryClient): QueryClientPatch {
|
|
153
|
+
const existing = patches.get(client);
|
|
154
|
+
if (existing) return existing;
|
|
133
155
|
|
|
134
|
-
const
|
|
156
|
+
const original = client.setQueryData.bind(client) as SetQueryDataFn;
|
|
157
|
+
const listeners = new Set<(args: Parameters<SetQueryDataFn>, result: unknown) => void>();
|
|
135
158
|
/* TS 7: QueryClient.setQueryData identities from query-core vs solid-query do not unify. */
|
|
136
|
-
client.setQueryData = ((...args: Parameters<
|
|
137
|
-
const result =
|
|
159
|
+
client.setQueryData = ((...args: Parameters<SetQueryDataFn>) => {
|
|
160
|
+
const result = original(...args);
|
|
161
|
+
for (const listener of listeners) listener(args, result);
|
|
162
|
+
return result;
|
|
163
|
+
}) as SetQueryDataFn;
|
|
164
|
+
|
|
165
|
+
const patch: QueryClientPatch = { listeners, original };
|
|
166
|
+
patches.set(client, patch);
|
|
167
|
+
return patch;
|
|
168
|
+
}
|
|
138
169
|
|
|
170
|
+
export function createTrackedQueryClient(client: QueryClient): TrackedQueryClient {
|
|
171
|
+
const tracked: QueryState[] = [];
|
|
172
|
+
const patch = ensurePatch(client);
|
|
173
|
+
|
|
174
|
+
const listener = (args: Parameters<SetQueryDataFn>, result: unknown) => {
|
|
139
175
|
const defaults = client.getQueryDefaults(args[0]);
|
|
140
176
|
const rawStaleTime = defaults?.staleTime;
|
|
141
177
|
tracked.push({
|
|
@@ -143,17 +179,40 @@ export function createTrackedQueryClient(client: QueryClient): TrackedQueryClien
|
|
|
143
179
|
key: [...args[0]],
|
|
144
180
|
staleTime: typeof rawStaleTime === "number" ? rawStaleTime : undefined,
|
|
145
181
|
});
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
182
|
+
};
|
|
183
|
+
patch.listeners.add(listener);
|
|
184
|
+
|
|
185
|
+
let released = false;
|
|
186
|
+
const release = () => {
|
|
187
|
+
if (released) return;
|
|
188
|
+
released = true;
|
|
189
|
+
patch.listeners.delete(listener);
|
|
190
|
+
if (patch.listeners.size === 0) {
|
|
191
|
+
client.setQueryData = patch.original;
|
|
192
|
+
patches.delete(client);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
149
195
|
|
|
150
196
|
return {
|
|
151
197
|
client,
|
|
152
198
|
drain: () => tracked.splice(0),
|
|
153
199
|
getTrackedQueries: () => tracked,
|
|
200
|
+
release,
|
|
154
201
|
};
|
|
155
202
|
}
|
|
156
203
|
|
|
204
|
+
export async function withTrackedQueryClient<T>(
|
|
205
|
+
client: QueryClient,
|
|
206
|
+
fn: (tracked: TrackedQueryClient) => Promise<T>,
|
|
207
|
+
): Promise<T> {
|
|
208
|
+
const tracked = createTrackedQueryClient(client);
|
|
209
|
+
try {
|
|
210
|
+
return await fn(tracked);
|
|
211
|
+
} finally {
|
|
212
|
+
tracked.release();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
157
216
|
/* ── hydrateQueryCache (client-side query hydration) ─────────────────── */
|
|
158
217
|
|
|
159
218
|
export function hydrateQueryCache(client: QueryClient, queries: QueryState[]): void {
|
package/src/rewrite/index.ts
CHANGED
|
@@ -5,12 +5,18 @@ export interface LocationRewrite {
|
|
|
5
5
|
output?: RewriteFn;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
function keepSameOrigin(next: URL, original: URL): URL {
|
|
9
|
+
if (next.origin !== original.origin) return original;
|
|
10
|
+
if (next.pathname.startsWith("//") || next.pathname.includes("\\")) return original;
|
|
11
|
+
return next;
|
|
12
|
+
}
|
|
13
|
+
|
|
8
14
|
export function executeRewriteInput(rewrite: LocationRewrite | undefined, url: URL): URL {
|
|
9
15
|
try {
|
|
10
16
|
const result = rewrite?.input?.({ url });
|
|
11
17
|
if (result === undefined) return url;
|
|
12
|
-
|
|
13
|
-
return
|
|
18
|
+
const next = typeof result === "string" ? new URL(result, url) : result;
|
|
19
|
+
return keepSameOrigin(next, url);
|
|
14
20
|
} catch {
|
|
15
21
|
return url;
|
|
16
22
|
}
|
|
@@ -20,8 +26,8 @@ export function executeRewriteOutput(rewrite: LocationRewrite | undefined, url:
|
|
|
20
26
|
try {
|
|
21
27
|
const result = rewrite?.output?.({ url });
|
|
22
28
|
if (result === undefined) return url;
|
|
23
|
-
|
|
24
|
-
return
|
|
29
|
+
const next = typeof result === "string" ? new URL(result, url) : result;
|
|
30
|
+
return keepSameOrigin(next, url);
|
|
25
31
|
} catch {
|
|
26
32
|
return url;
|
|
27
33
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { JSX } from "
|
|
1
|
+
import type { JSX } from "@solidjs/web";
|
|
2
2
|
import type { FlareRouter } from "../outlet/types.ts";
|
|
3
3
|
import type { Location } from "../router-primitives/index.ts";
|
|
4
4
|
import type { ResolvedAuth, ResolvedEnv, ResolvedQueryClient, ResolvedServerContext } from "./register.ts";
|
|
@@ -36,7 +36,11 @@ export interface ClientCacheConfig {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export interface SsrCacheConfig {
|
|
39
|
-
key?: (ctx: {
|
|
39
|
+
key?: (ctx: {
|
|
40
|
+
auth?: unknown;
|
|
41
|
+
params: Record<string, string | string[]>;
|
|
42
|
+
search: Record<string, string | string[]>;
|
|
43
|
+
}) => string;
|
|
40
44
|
staleTime?: Duration;
|
|
41
45
|
tags?: string[] | ((ctx: { params: Record<string, string | string[]> }) => string[]);
|
|
42
46
|
ttl?: Duration;
|
|
@@ -20,9 +20,11 @@ export type RouteMetaStatic = {
|
|
|
20
20
|
|
|
21
21
|
export type RouteMeta = {
|
|
22
22
|
authenticate?: boolean;
|
|
23
|
+
authorize?: boolean;
|
|
23
24
|
client?: RouteMetaClient;
|
|
24
25
|
intercept?: { from: readonly string[]; render: string };
|
|
25
26
|
static?: RouteMetaStatic;
|
|
27
|
+
tags?: readonly string[];
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
export type RouteData = {
|
package/src/security/index.ts
CHANGED
|
@@ -45,6 +45,12 @@ export const DEFAULT_CSP: Record<string, string[] | boolean> = {
|
|
|
45
45
|
"object-src": ["'none'"],
|
|
46
46
|
"script-src": ["'self'"],
|
|
47
47
|
"style-src": ["'self'"],
|
|
48
|
+
/* Solid 2 applies JSX `style={}` via CSSOM (`setProperty`). A nonce on
|
|
49
|
+
* `style-src` is the fallback for both elements and attributes, and Chrome
|
|
50
|
+
* then blocks CSSOM even when `style-src-attr` allows it. Put the nonce on
|
|
51
|
+
* `style-src-elem` only; keep `style-src` host-only. */
|
|
52
|
+
"style-src-attr": ["'unsafe-inline'"],
|
|
53
|
+
"style-src-elem": ["'self'"],
|
|
48
54
|
"upgrade-insecure-requests": true,
|
|
49
55
|
"worker-src": ["'self'"],
|
|
50
56
|
};
|
|
@@ -78,7 +84,16 @@ const DEV_SKIP_HEADERS = new Set(["Strict-Transport-Security", "Cross-Origin-Ope
|
|
|
78
84
|
|
|
79
85
|
/* ── CSP builder ───────────────────────────────────────────────────────── */
|
|
80
86
|
|
|
81
|
-
|
|
87
|
+
function requestIsHttp(url?: string): boolean {
|
|
88
|
+
if (!url) return false;
|
|
89
|
+
try {
|
|
90
|
+
return new URL(url).protocol === "http:";
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function buildCspHeader(nonce: string, overrides?: CspDirectives, isDev?: boolean, url?: string): string {
|
|
82
97
|
const directives: Record<string, string[] | boolean> = {};
|
|
83
98
|
|
|
84
99
|
for (const [key, value] of Object.entries(DEFAULT_CSP)) {
|
|
@@ -94,10 +109,18 @@ export function buildCspHeader(nonce: string, overrides?: CspDirectives, isDev?:
|
|
|
94
109
|
do not apply to attribute styles, so 'unsafe-inline' is the only knob for dev parity. */
|
|
95
110
|
const styleSrc = directives["style-src"] as string[];
|
|
96
111
|
styleSrc.push("'unsafe-inline'");
|
|
112
|
+
const styleSrcElem = directives["style-src-elem"] as string[];
|
|
113
|
+
styleSrcElem.push("'unsafe-inline'");
|
|
97
114
|
const workerSrc = directives["worker-src"] as string[];
|
|
98
115
|
workerSrc.push("blob:");
|
|
99
116
|
}
|
|
100
117
|
|
|
118
|
+
/* HTTP (localhost preview included) + this directive makes Chrome follow
|
|
119
|
+
redirects as https://. Dev is always HTTP. HTTPS production keeps it. */
|
|
120
|
+
if (isDev || requestIsHttp(url)) {
|
|
121
|
+
directives["upgrade-insecure-requests"] = false;
|
|
122
|
+
}
|
|
123
|
+
|
|
101
124
|
if (overrides) {
|
|
102
125
|
for (const [key, value] of Object.entries(overrides)) {
|
|
103
126
|
if (value === undefined) continue;
|
|
@@ -124,9 +147,19 @@ export function buildCspHeader(nonce: string, overrides?: CspDirectives, isDev?:
|
|
|
124
147
|
scriptSrc.push(`'nonce-${nonce}'`);
|
|
125
148
|
}
|
|
126
149
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
150
|
+
/* Never nonce `style-src`: Chromium treats that as the CSSOM policy and
|
|
151
|
+
* ignores `style-src-attr 'unsafe-inline'`. `<style nonce>` / `<link>` are
|
|
152
|
+
* `style-src-elem`. */
|
|
153
|
+
if (!directives["style-src-elem"]) {
|
|
154
|
+
directives["style-src-elem"] = ["'self'"];
|
|
155
|
+
}
|
|
156
|
+
const styleSrcElem = directives["style-src-elem"];
|
|
157
|
+
if (Array.isArray(styleSrcElem) && !styleSrcElem.includes("'unsafe-inline'")) {
|
|
158
|
+
styleSrcElem.push(`'nonce-${nonce}'`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!directives["style-src-attr"]) {
|
|
162
|
+
directives["style-src-attr"] = ["'unsafe-inline'"];
|
|
130
163
|
}
|
|
131
164
|
|
|
132
165
|
const parts: string[] = [];
|
|
@@ -177,10 +210,12 @@ export interface BuildSecurityHeadersOptions {
|
|
|
177
210
|
config?: SecurityConfig;
|
|
178
211
|
isDev?: boolean;
|
|
179
212
|
nonce: string;
|
|
213
|
+
/** Request URL. HTTP (e.g. localhost preview) must not send upgrade-insecure-requests. */
|
|
214
|
+
url?: string;
|
|
180
215
|
}
|
|
181
216
|
|
|
182
217
|
export function buildSecurityHeaders(options: BuildSecurityHeadersOptions): Record<string, string> {
|
|
183
|
-
const { config, isDev, nonce } = options;
|
|
218
|
+
const { config, isDev, nonce, url } = options;
|
|
184
219
|
const result: Record<string, string> = {};
|
|
185
220
|
|
|
186
221
|
/* CSP */
|
|
@@ -193,6 +228,7 @@ export function buildSecurityHeaders(options: BuildSecurityHeadersOptions): Reco
|
|
|
193
228
|
nonce,
|
|
194
229
|
typeof cspConfig === "object" ? cspConfig : undefined,
|
|
195
230
|
isDev,
|
|
231
|
+
url,
|
|
196
232
|
);
|
|
197
233
|
}
|
|
198
234
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type PathMatcher,
|
|
7
7
|
} from "../middleware/index.ts";
|
|
8
8
|
import type { MountConfig } from "../mount/index.ts";
|
|
9
|
+
import { KEEPALIVE_PATH } from "../protocol.ts";
|
|
9
10
|
import type { MarkedRouterConfig } from "../router-config/index.ts";
|
|
10
11
|
import type { SecurityConfig } from "../security/index.ts";
|
|
11
12
|
import type {
|
|
@@ -107,8 +108,6 @@ interface ServerBuilderImpl {
|
|
|
107
108
|
|
|
108
109
|
/* ── Builder ───────────────────────────────────────────────────────────── */
|
|
109
110
|
|
|
110
|
-
const KEEPALIVE_PATH = "/_flare/keepalive";
|
|
111
|
-
|
|
112
111
|
/*
|
|
113
112
|
* server-handler/index.ts imports virtual modules (virtual:flare-client-entry, etc.)
|
|
114
113
|
* that only exist inside Vite's module graph. Lazy-importing it in fetch() keeps the
|