@lovrozagar/flare 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +44 -19
  2. package/package.json +10 -7
  3. package/src/broadcast/index.ts +1 -1
  4. package/src/broadcast/provider.tsx +2 -2
  5. package/src/caches/index.ts +26 -0
  6. package/src/components/await.tsx +36 -39
  7. package/src/components/dev-error-overlay.tsx +8 -9
  8. package/src/components/direction-script.tsx +2 -2
  9. package/src/components/locale-script.tsx +2 -2
  10. package/src/components/reset-css.tsx +1 -1
  11. package/src/components/ssr-context.tsx +7 -14
  12. package/src/components/theme-script.tsx +2 -2
  13. package/src/components/view-transition-css.tsx +1 -1
  14. package/src/defer/index.ts +22 -0
  15. package/src/direction/index.tsx +61 -36
  16. package/src/errors/index.ts +45 -11
  17. package/src/fonts/component.tsx +2 -1
  18. package/src/form/index.tsx +18 -12
  19. package/src/generators/index.ts +73 -30
  20. package/src/head-client/index.ts +7 -0
  21. package/src/history/index.ts +6 -0
  22. package/src/hydrate/index.tsx +90 -60
  23. package/src/hydration/index.ts +26 -6
  24. package/src/i18n/index.ts +11 -2
  25. package/src/image/index.tsx +7 -4
  26. package/src/intercept-outlet/index.tsx +6 -2
  27. package/src/lazy/index.tsx +50 -50
  28. package/src/link/index.tsx +97 -58
  29. package/src/loader-pipeline/index.ts +93 -35
  30. package/src/locale/cookie.ts +15 -0
  31. package/src/locale/index.tsx +24 -35
  32. package/src/locale.ts +1 -0
  33. package/src/middleware/builtins/api-proxy.ts +58 -4
  34. package/src/middleware/builtins/cdn-proxy.ts +24 -3
  35. package/src/middleware/builtins/i18n.ts +23 -25
  36. package/src/middleware/builtins/keepalive.ts +2 -3
  37. package/src/mount/index.ts +8 -1
  38. package/src/navigation/index.ts +575 -129
  39. package/src/navigation/types.ts +11 -1
  40. package/src/navigation-progress/index.tsx +17 -16
  41. package/src/ndjson-client/index.ts +42 -17
  42. package/src/ndjson-server/index.ts +78 -29
  43. package/src/outlet/index.tsx +192 -97
  44. package/src/outlet/types.ts +9 -1
  45. package/src/plugins/dev-cdn-cache.ts +18 -5
  46. package/src/plugins/dev-dashboard/client.tsx +130 -118
  47. package/src/plugins/dev-prerender.ts +6 -1
  48. package/src/plugins/dev-server.ts +12 -3
  49. package/src/plugins/image-plugin.ts +18 -4
  50. package/src/plugins/index.ts +10 -7
  51. package/src/plugins/prerender-plugin.ts +3 -3
  52. package/src/plugins/server-fn.ts +15 -20
  53. package/src/plugins/solid-jsx.d.ts +3 -3
  54. package/src/plugins/sx-ast/atomic.ts +5 -5
  55. package/src/plugins/sx-ast/index.ts +9 -0
  56. package/src/plugins/sx.d.ts +4 -5
  57. package/src/plugins/virtual.ts +11 -6
  58. package/src/prerender/index.ts +68 -32
  59. package/src/protocol.ts +65 -0
  60. package/src/query-client/index.tsx +73 -14
  61. package/src/rewrite/index.ts +10 -4
  62. package/src/route-builder/create-layout.ts +1 -1
  63. package/src/route-builder/create-root-layout.ts +1 -1
  64. package/src/route-builder/types.ts +5 -1
  65. package/src/router-primitives/types.ts +2 -0
  66. package/src/security/index.ts +41 -5
  67. package/src/server/index.ts +1 -2
  68. package/src/server-fn/get-input.ts +90 -0
  69. package/src/server-fn/http-error.ts +13 -0
  70. package/src/server-fn/index.ts +94 -70
  71. package/src/server-fn-query.ts +9 -17
  72. package/src/server-handler/etag.ts +4 -2
  73. package/src/server-handler/headers.ts +1 -2
  74. package/src/server-handler/index.ts +164 -56
  75. package/src/server-handler/types.ts +2 -0
  76. package/src/service-worker/template.ts +10 -42
  77. package/src/ssr/head-prefix.ts +2 -1
  78. package/src/ssr/hoist-head-markers.ts +25 -0
  79. package/src/ssr/index.tsx +109 -92
  80. package/src/state-parser/index.ts +38 -26
  81. package/src/styles/index.ts +89 -60
  82. package/src/testing/index.ts +8 -3
  83. package/src/theme/index.tsx +35 -32
  84. package/src/tracing/timing.ts +6 -1
  85. package/src/url/index.ts +9 -0
@@ -1,4 +1,4 @@
1
- import type { JSX } from "solid-js";
1
+ import type { JSX } from "@solidjs/web";
2
2
  import type {
3
3
  ClientErrorRenderProps,
4
4
  ClientNotFoundRenderProps,
@@ -29,6 +29,11 @@ export interface LoadedRouteModule {
29
29
  errorRender?: (props: ClientErrorRenderProps) => JSX.Element;
30
30
  notFoundRender?: (props: ClientNotFoundRenderProps) => JSX.Element;
31
31
  cache?: {
32
+ cdn?:
33
+ | {
34
+ tags?: string[] | ((ctx: { params: Record<string, string | string[]> }) => string[]);
35
+ }
36
+ | false;
32
37
  client?:
33
38
  | {
34
39
  cacheDeferred?: boolean;
@@ -38,6 +43,11 @@ export interface LoadedRouteModule {
38
43
  staleTime?: number;
39
44
  }
40
45
  | false;
46
+ ssr?:
47
+ | {
48
+ tags?: string[] | ((ctx: { params: Record<string, string | string[]> }) => string[]);
49
+ }
50
+ | false;
41
51
  };
42
52
  render: (props: RenderProps) => JSX.Element;
43
53
  unauthenticatedRender?: (props: ClientUnauthenticatedRenderProps) => JSX.Element;
@@ -1,5 +1,6 @@
1
- import { createEffect, type JSX, onCleanup, useContext } from "solid-js";
2
- import { isServer } from "solid-js/web";
1
+ import { createEffect, useContext } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
3
+ import { isServer } from "@solidjs/web";
3
4
  import { RouterContext } from "../outlet/index.tsx";
4
5
 
5
6
  export interface NavigationProgressProps {
@@ -179,20 +180,20 @@ export function NavigationProgress(props: NavigationProgressProps): JSX.Element
179
180
  const ctx = useContext(RouterContext);
180
181
 
181
182
  if (!isServer && ctx) {
182
- createEffect(() => {
183
- const phase = ctx.navigationPhase();
184
-
185
- if (phase === "loading") {
186
- clearTimers();
187
- start();
188
- } else if (phase === "idle" && status !== null) {
189
- handleComplete();
190
- } else if (phase === "transitioning" && status !== null) {
191
- handleComplete();
192
- }
193
- });
194
-
195
- onCleanup(clearTimers);
183
+ createEffect(
184
+ () => ctx.navigationPhase(),
185
+ (phase) => {
186
+ if (phase === "loading") {
187
+ clearTimers();
188
+ start();
189
+ } else if (phase === "idle" && status !== null) {
190
+ handleComplete();
191
+ } else if (phase === "transitioning" && status !== null) {
192
+ handleComplete();
193
+ }
194
+ return clearTimers;
195
+ },
196
+ );
196
197
  }
197
198
 
198
199
  const barColor = (): string => props.color ?? "currentColor";
@@ -1,5 +1,6 @@
1
1
  import { NotFoundError, RedirectResponse, UnauthenticatedError, UnauthorizedError } from "../errors/index.ts";
2
2
  import { warn } from "../logger.ts";
3
+ import { HEADER_DATA, HEADER_FLAG, HEADER_PREFETCH, HEADER_STALE } from "../protocol.ts";
3
4
  import type { HeadConfig } from "../route-builder/types.ts";
4
5
  import type { DeferredResolver } from "../state-parser/index.ts";
5
6
  import { hydrateLoaderData } from "../state-parser/index.ts";
@@ -8,9 +9,13 @@ const MAX_LINE_BYTES = 5 * 1024 * 1024;
8
9
  const MAX_MESSAGES = 10_000;
9
10
 
10
11
  export interface NDJSONFetchOptions {
12
+ /** Match ids whose prefetch `l` shell is already committed — skip hydrate, stream `c` into `resolvers`. */
13
+ keepMatchIds?: Iterable<string>;
11
14
  matchIds?: string[];
12
15
  prefetch?: boolean;
13
16
  queryClient?: unknown;
17
+ /** Pre-seeded deferred resolvers (hydrated prefetch shell). */
18
+ resolvers?: Map<string, DeferredResolver>;
14
19
  signal?: AbortSignal;
15
20
  url: string;
16
21
  }
@@ -18,12 +23,15 @@ export interface NDJSONFetchOptions {
18
23
  export interface NDJSONFetchResult {
19
24
  matches: FetchedMatch[];
20
25
  perRouteHeads: PerRouteHead[];
26
+ status?: number;
21
27
  success: boolean;
22
28
  }
23
29
 
24
30
  export interface FetchedMatch {
25
31
  error?: Error;
26
32
  hasDeferredMarkers?: boolean;
33
+ /** Enter `l` for a match whose prefetch shell is already on screen — do not replace loaderData. */
34
+ keepShell?: boolean;
27
35
  loaderData: unknown;
28
36
  matchId: string;
29
37
  preloaderContext?: Record<string, unknown>;
@@ -94,15 +102,29 @@ function forwardServerLog(msg: NDJSONMessage): void {
94
102
  }
95
103
  }
96
104
 
105
+ /** Hydrate `t:"q"` entries unless the navigation that started this hop has aborted. */
106
+ export function applyQueryCacheHydration(queryClient: unknown, entries: unknown, signal?: AbortSignal): void {
107
+ if (signal?.aborted) return;
108
+ void import("../query-client")
109
+ .then(({ hydrateQueryCache }) => {
110
+ if (signal?.aborted) return;
111
+ hydrateQueryCache(
112
+ queryClient as Parameters<typeof hydrateQueryCache>[0],
113
+ entries as Parameters<typeof hydrateQueryCache>[1],
114
+ );
115
+ })
116
+ .catch(() => {});
117
+ }
118
+
97
119
  export async function fetchNDJSON(options: NDJSONFetchOptions): Promise<NDJSONFetchResult> {
98
- const headers: Record<string, string> = { "x-d": "1" };
120
+ const headers: Record<string, string> = { [HEADER_DATA]: HEADER_FLAG };
99
121
 
100
122
  if (options.matchIds) {
101
- headers["x-m"] = options.matchIds.join(",");
123
+ headers[HEADER_STALE] = options.matchIds.join(",");
102
124
  }
103
125
 
104
126
  if (options.prefetch) {
105
- headers["x-p"] = "1";
127
+ headers[HEADER_PREFETCH] = HEADER_FLAG;
106
128
  }
107
129
 
108
130
  const response = await fetch(options.url, {
@@ -116,12 +138,13 @@ export async function fetchNDJSON(options: NDJSONFetchOptions): Promise<NDJSONFe
116
138
  * body makes retry() rebuild matches from a cache that never stored the
117
139
  * pipeline error, so the error UI cannot recover. */
118
140
  if (!response.ok && !isNdjson) {
119
- return { matches: [], perRouteHeads: [], success: false };
141
+ return { matches: [], perRouteHeads: [], status: response.status, success: false };
120
142
  }
121
143
 
122
144
  const matches: FetchedMatch[] = [];
123
145
  const perRouteHeads: PerRouteHead[] = [];
124
- const deferredResolvers = new Map<string, DeferredResolver>();
146
+ const deferredResolvers = options.resolvers ?? new Map<string, DeferredResolver>();
147
+ const keepMatchIds = new Set(options.keepMatchIds ?? []);
125
148
 
126
149
  let loadersReadyResolve: () => void = () => {};
127
150
  let loadersReadyResolved = false;
@@ -168,20 +191,29 @@ export async function fetchNDJSON(options: NDJSONFetchOptions): Promise<NDJSONFe
168
191
  }
169
192
 
170
193
  case "l": {
171
- if (options.prefetch) {
194
+ const matchId = msg.m ?? "";
195
+ if (keepMatchIds.has(matchId)) {
196
+ matches.push({
197
+ hasDeferredMarkers: true,
198
+ keepShell: true,
199
+ loaderData: msg.d,
200
+ matchId,
201
+ preloaderContext: msg.p,
202
+ });
203
+ } else if (options.prefetch) {
172
204
  matches.push({
173
205
  hasDeferredMarkers: containsDeferredMarkers(msg.d) || undefined,
174
206
  loaderData: msg.d,
175
- matchId: msg.m ?? "",
207
+ matchId,
176
208
  preloaderContext: msg.p,
177
209
  });
178
210
  } else {
179
211
  const sizeBefore = deferredResolvers.size;
180
- const hydrated = hydrateLoaderData(msg.m ?? "", msg.d, deferredResolvers);
212
+ const hydrated = hydrateLoaderData(matchId, msg.d, deferredResolvers);
181
213
  matches.push({
182
214
  hasDeferredMarkers: deferredResolvers.size > sizeBefore || undefined,
183
215
  loaderData: hydrated,
184
- matchId: msg.m ?? "",
216
+ matchId,
185
217
  preloaderContext: msg.p,
186
218
  });
187
219
  }
@@ -246,14 +278,7 @@ export async function fetchNDJSON(options: NDJSONFetchOptions): Promise<NDJSONFe
246
278
 
247
279
  case "q": {
248
280
  if (options.queryClient && Array.isArray(msg.d)) {
249
- import("../query-client")
250
- .then(({ hydrateQueryCache }) => {
251
- hydrateQueryCache(
252
- options.queryClient as Parameters<typeof hydrateQueryCache>[0],
253
- msg.d as Parameters<typeof hydrateQueryCache>[1],
254
- );
255
- })
256
- .catch(() => {});
281
+ applyQueryCacheHydration(options.queryClient, msg.d, options.signal);
257
282
  }
258
283
  break;
259
284
  }
@@ -6,10 +6,27 @@ import type { HeadConfig } from "../route-builder/types.ts";
6
6
  import type { ServerLogEntry } from "@lovrozagar/flare/server-context";
7
7
 
8
8
  export interface NDJSONResponseConfig {
9
+ abortController?: AbortController;
9
10
  deferContexts: Map<string, DeferContext>;
10
11
  matches: PipelineMatch[];
11
12
  queryClient?: unknown;
12
13
  serverLogs?: ServerLogEntry[];
14
+ status?: number;
15
+ }
16
+
17
+ export function followAbortSignal(controller: AbortController, signal: AbortSignal | undefined): void {
18
+ if (!signal) return;
19
+ if (signal.aborted) {
20
+ controller.abort();
21
+ return;
22
+ }
23
+ signal.addEventListener(
24
+ "abort",
25
+ () => {
26
+ if (!controller.signal.aborted) controller.abort();
27
+ },
28
+ { once: true },
29
+ );
13
30
  }
14
31
 
15
32
  const NDJSON_HEADERS = {
@@ -190,16 +207,44 @@ export function createNDJSONResponse(config: NDJSONResponseConfig): Response {
190
207
  for (const l of lines) {
191
208
  body += `${l}\n`;
192
209
  }
193
- return new Response(body, { headers: NDJSON_HEADERS, status: 200 });
210
+ return new Response(body, { headers: NDJSON_HEADERS, status: config.status ?? 200 });
211
+ }
212
+
213
+ export function createErrorNDJSONResponse(error: Error, status?: number): Response {
214
+ const http =
215
+ status ??
216
+ (error.name === "UnauthenticatedError"
217
+ ? 401
218
+ : error.name === "UnauthorizedError"
219
+ ? 403
220
+ : error.name === "NotFoundError"
221
+ ? 404
222
+ : 500);
223
+ const lines = [formatErrorMessage("", error), formatReadyMessage(), formatDoneMessage()];
224
+ let body = "";
225
+ for (const l of lines) {
226
+ body += `${l}\n`;
227
+ }
228
+ return new Response(body, { headers: NDJSON_HEADERS, status: http });
194
229
  }
195
230
 
196
231
  export function createStreamingNDJSONResponse(config: NDJSONResponseConfig): Response {
197
232
  const { deferContexts, matches } = config;
233
+ let cancelled = false;
198
234
 
199
235
  const stream = new ReadableStream({
236
+ cancel() {
237
+ cancelled = true;
238
+ if (config.abortController && !config.abortController.signal.aborted) {
239
+ config.abortController.abort();
240
+ }
241
+ },
200
242
  async start(controller) {
201
243
  const encoder = new TextEncoder();
202
- const enqueue = (msg: string) => controller.enqueue(encoder.encode(`${msg}\n`));
244
+ const enqueue = (msg: string) => {
245
+ if (cancelled) return;
246
+ controller.enqueue(encoder.encode(`${msg}\n`));
247
+ };
203
248
 
204
249
  /* 0. Server log messages */
205
250
  if (config.serverLogs) {
@@ -227,14 +272,6 @@ export function createStreamingNDJSONResponse(config: NDJSONResponseConfig): Res
227
272
  /* 3. Ready */
228
273
  enqueue(formatReadyMessage());
229
274
 
230
- /* Install QC tracking for deferred callbacks */
231
- let trackedQC: { drain(): Array<{ data: unknown; key: unknown[]; staleTime?: number }> } | undefined;
232
- if (config.queryClient) {
233
- const { createTrackedQueryClient } = await import("../query-client");
234
- const typed = config.queryClient as Parameters<typeof createTrackedQueryClient>[0];
235
- trackedQC = createTrackedQueryClient(typed);
236
- }
237
-
238
275
  /* 4. Stream deferred chunks */
239
276
  const entries: DeferredEntry[] = [];
240
277
  for (const ctx of deferContexts.values()) {
@@ -242,25 +279,37 @@ export function createStreamingNDJSONResponse(config: NDJSONResponseConfig): Res
242
279
  entries.push(e);
243
280
  }
244
281
  }
245
- await Promise.allSettled(
246
- entries.map(async (entry) => {
247
- try {
248
- const data = await entry.promise;
249
- enqueue(formatChunkMessage(entry.matchId, entry.key, data));
250
- } catch (e) {
251
- const err = e instanceof Error ? e : new Error(String(e));
252
- enqueue(formatErrorMessage(entry.matchId, err, entry.key));
253
- }
254
-
255
- /* Drain QC delta entries accumulated during this deferred resolve */
256
- if (trackedQC) {
257
- const qcEntries = trackedQC.drain();
258
- if (qcEntries.length > 0) {
259
- enqueue(formatQueryCacheMessage(qcEntries));
282
+
283
+ const streamDeferred = async (trackedQC?: {
284
+ drain(): Array<{ data: unknown; key: unknown[]; staleTime?: number }>;
285
+ }) => {
286
+ await Promise.allSettled(
287
+ entries.map(async (entry) => {
288
+ try {
289
+ const data = await entry.promise;
290
+ enqueue(formatChunkMessage(entry.matchId, entry.key, data));
291
+ } catch (e) {
292
+ const err = e instanceof Error ? e : new Error(String(e));
293
+ enqueue(formatErrorMessage(entry.matchId, err, entry.key));
260
294
  }
261
- }
262
- }),
263
- );
295
+
296
+ if (trackedQC) {
297
+ const qcEntries = trackedQC.drain();
298
+ if (qcEntries.length > 0) {
299
+ enqueue(formatQueryCacheMessage(qcEntries));
300
+ }
301
+ }
302
+ }),
303
+ );
304
+ };
305
+
306
+ if (config.queryClient) {
307
+ const { withTrackedQueryClient } = await import("../query-client");
308
+ const typed = config.queryClient as Parameters<typeof withTrackedQueryClient>[0];
309
+ await withTrackedQueryClient(typed, streamDeferred);
310
+ } else {
311
+ await streamDeferred();
312
+ }
264
313
 
265
314
  /* 5. Done */
266
315
  enqueue(formatDoneMessage());
@@ -268,7 +317,7 @@ export function createStreamingNDJSONResponse(config: NDJSONResponseConfig): Res
268
317
  },
269
318
  });
270
319
 
271
- return new Response(stream, { headers: NDJSON_HEADERS, status: 200 });
320
+ return new Response(stream, { headers: NDJSON_HEADERS, status: config.status ?? 200 });
272
321
  }
273
322
 
274
323
  export function createRedirectNDJSONResponse(redirect: RedirectResponse): Response {