@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.
Files changed (85) hide show
  1. package/README.md +42 -19
  2. package/package.json +8 -6
  3. package/src/broadcast/index.ts +1 -1
  4. package/src/broadcast/provider.tsx +2 -2
  5. package/src/caches/index.ts +26 -0
  6. package/src/components/await.tsx +36 -39
  7. package/src/components/dev-error-overlay.tsx +8 -9
  8. package/src/components/direction-script.tsx +2 -2
  9. package/src/components/locale-script.tsx +2 -2
  10. package/src/components/reset-css.tsx +1 -1
  11. package/src/components/ssr-context.tsx +7 -14
  12. package/src/components/theme-script.tsx +2 -2
  13. package/src/components/view-transition-css.tsx +1 -1
  14. package/src/defer/index.ts +22 -0
  15. package/src/direction/index.tsx +61 -36
  16. package/src/errors/index.ts +45 -11
  17. package/src/fonts/component.tsx +2 -1
  18. package/src/form/index.tsx +18 -12
  19. package/src/generators/index.ts +73 -30
  20. package/src/head-client/index.ts +7 -0
  21. package/src/history/index.ts +6 -0
  22. package/src/hydrate/index.tsx +90 -60
  23. package/src/hydration/index.ts +26 -6
  24. package/src/i18n/index.ts +11 -2
  25. package/src/image/index.tsx +7 -4
  26. package/src/intercept-outlet/index.tsx +6 -2
  27. package/src/lazy/index.tsx +50 -50
  28. package/src/link/index.tsx +97 -58
  29. package/src/loader-pipeline/index.ts +93 -35
  30. package/src/locale/cookie.ts +15 -0
  31. package/src/locale/index.tsx +24 -35
  32. package/src/locale.ts +1 -0
  33. package/src/middleware/builtins/api-proxy.ts +58 -4
  34. package/src/middleware/builtins/cdn-proxy.ts +24 -3
  35. package/src/middleware/builtins/i18n.ts +23 -25
  36. package/src/middleware/builtins/keepalive.ts +2 -3
  37. package/src/mount/index.ts +8 -1
  38. package/src/navigation/index.ts +575 -129
  39. package/src/navigation/types.ts +11 -1
  40. package/src/navigation-progress/index.tsx +17 -16
  41. package/src/ndjson-client/index.ts +42 -17
  42. package/src/ndjson-server/index.ts +78 -29
  43. package/src/outlet/index.tsx +192 -97
  44. package/src/outlet/types.ts +9 -1
  45. package/src/plugins/dev-cdn-cache.ts +18 -5
  46. package/src/plugins/dev-dashboard/client.tsx +130 -118
  47. package/src/plugins/dev-prerender.ts +6 -1
  48. package/src/plugins/dev-server.ts +12 -3
  49. package/src/plugins/image-plugin.ts +18 -4
  50. package/src/plugins/index.ts +10 -7
  51. package/src/plugins/prerender-plugin.ts +3 -3
  52. package/src/plugins/server-fn.ts +15 -20
  53. package/src/plugins/solid-jsx.d.ts +3 -3
  54. package/src/plugins/sx-ast/atomic.ts +5 -5
  55. package/src/plugins/sx-ast/index.ts +9 -0
  56. package/src/plugins/sx.d.ts +4 -5
  57. package/src/plugins/virtual.ts +11 -6
  58. package/src/prerender/index.ts +68 -32
  59. package/src/protocol.ts +65 -0
  60. package/src/query-client/index.tsx +73 -14
  61. package/src/rewrite/index.ts +10 -4
  62. package/src/route-builder/create-layout.ts +1 -1
  63. package/src/route-builder/create-root-layout.ts +1 -1
  64. package/src/route-builder/types.ts +5 -1
  65. package/src/router-primitives/types.ts +2 -0
  66. package/src/security/index.ts +41 -5
  67. package/src/server/index.ts +1 -2
  68. package/src/server-fn/get-input.ts +90 -0
  69. package/src/server-fn/http-error.ts +13 -0
  70. package/src/server-fn/index.ts +94 -70
  71. package/src/server-fn-query.ts +9 -17
  72. package/src/server-handler/etag.ts +4 -2
  73. package/src/server-handler/headers.ts +1 -2
  74. package/src/server-handler/index.ts +164 -56
  75. package/src/server-handler/types.ts +2 -0
  76. package/src/service-worker/template.ts +10 -42
  77. package/src/ssr/head-prefix.ts +2 -1
  78. package/src/ssr/hoist-head-markers.ts +25 -0
  79. package/src/ssr/index.tsx +109 -92
  80. package/src/state-parser/index.ts +38 -26
  81. package/src/styles/index.ts +89 -60
  82. package/src/testing/index.ts +8 -3
  83. package/src/theme/index.tsx +35 -32
  84. package/src/tracing/timing.ts +6 -1
  85. package/src/url/index.ts +9 -0
@@ -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);
@@ -1,14 +1,13 @@
1
- import type { ClassValue, Sx } from "../styles/sx-types";
1
+ import "@solidjs/web";
2
+ import type { Sx } from "../styles/sx-types";
2
3
 
3
- declare module "solid-js" {
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 CoreSVGAttributes<T> {
9
+ interface SVGAttributes<T> {
10
10
  sx?: Sx;
11
- class?: string | ClassValue[];
12
11
  }
13
12
  }
14
13
  }
@@ -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 manifestPath = join(root, "dist/client/.vite/manifest.json");
15
- try {
16
- const raw = readFileSync(manifestPath, "utf-8");
17
- return JSON.parse(raw) as ViteManifest;
18
- } catch {
19
- /* manifest doesn't exist yet (dev mode) — caller uses dev fallback */
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
  }
@@ -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 x-d:1 header → collect NDJSON response
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 = "x-flare-prerender";
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
- /* Skip transfer-encoding and connection headers */
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(new Request(url, { headers: { [PRERENDER_HEADER]: "1" }, method: "GET" }), env);
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: { "x-d": "1", [PRERENDER_HEADER]: "1" },
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
- if (route.tags !== undefined) entry.tags = route.tags;
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
- /* isr without revalidate = on-demand only, skip build-time prerender */
294
- if (def.cache.isr && def.cache.isrRevalidate === undefined) continue;
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: import("../store").FlareStore): void {
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
- store.set(`static:${record.pathname}`, {
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
  }
@@ -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, QueryClientProvider, type QueryKey, useQuery } from "@tanstack/solid-query";
2
- import type { Accessor } from "solid-js";
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
- export { QueryClientProvider };
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 entirely to TanStack's SSR-aware implementation which uses
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 QueryClient,
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
- export function createTrackedQueryClient(client: QueryClient): TrackedQueryClient {
132
- const tracked: QueryState[] = [];
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 originalSetQueryData = client.setQueryData.bind(client);
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<QueryClient["setQueryData"]>) => {
137
- const result = originalSetQueryData(...args);
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
- return result;
148
- }) as QueryClient["setQueryData"];
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 {
@@ -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
- if (typeof result === "string") return new URL(result, url);
13
- return result;
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
- if (typeof result === "string") return new URL(result, url);
24
- return result;
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 "solid-js";
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 {
@@ -1,4 +1,4 @@
1
- import type { JSX } from "solid-js";
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: { params: Record<string, string | string[]> }) => string;
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 = {
@@ -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
- export function buildCspHeader(nonce: string, overrides?: CspDirectives, isDev?: boolean): string {
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
- const styleSrc = directives["style-src"];
128
- if (Array.isArray(styleSrc) && !styleSrc.includes("'unsafe-inline'")) {
129
- styleSrc.push(`'nonce-${nonce}'`);
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
  }
@@ -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