@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,5 @@
1
- import { createMemo, type JSX, onCleanup, Show, splitProps } from "solid-js";
1
+ import { createEffect, createMemo, createSignal, omit, Show } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
2
3
  import { applyRewriteOutput, isExternal, navigate, prefetch } from "../navigation/index.ts";
3
4
  import { useRouterContext } from "../outlet/index.tsx";
4
5
  import type { RouteParamsProps, RoutePaths, RouteSearchProps } from "../route-builder/register.ts";
@@ -39,7 +40,7 @@ export type ExternalLinkProps = FlareAnchorProps & {
39
40
 
40
41
  export type LinkProps<TPath extends RoutePaths = RoutePaths> = InternalLinkProps<TPath> | ExternalLinkProps;
41
42
 
42
- /* Flattened internal type for splitProps — avoids TS intersection issues with Omit */
43
+ /* Flattened internal type for omit — avoids TS intersection issues with Omit */
43
44
  interface LinkPropsInternal {
44
45
  activeClass?: string;
45
46
  activeProps?: FlareAnchorProps;
@@ -83,7 +84,9 @@ function isDangerousHref(href: string): boolean {
83
84
  export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Element {
84
85
  const ctx = useRouterContext();
85
86
 
86
- const [local, rest] = splitProps(props as unknown as LinkPropsInternal, [
87
+ const local = props as unknown as LinkPropsInternal;
88
+ const rest = omit(
89
+ local,
87
90
  "activeClass",
88
91
  "activeProps",
89
92
  "children",
@@ -107,19 +110,24 @@ export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Ele
107
110
  "target",
108
111
  "to",
109
112
  "viewTransition",
110
- ]);
113
+ );
111
114
 
112
- const resolvedHref = createMemo(() => {
113
- if (local.href !== undefined) {
114
- if (isDangerousHref(local.href)) return "#";
115
- return local.href;
116
- }
117
- const raw = buildUrl({
115
+ const internalHref = createMemo(() => {
116
+ if (local.href !== undefined) return undefined;
117
+ return buildUrl({
118
118
  hash: local.hash,
119
119
  params: local.params,
120
120
  search: local.search,
121
121
  to: local.to ?? "",
122
122
  });
123
+ });
124
+
125
+ const resolvedHref = createMemo(() => {
126
+ if (local.href !== undefined) {
127
+ if (isDangerousHref(local.href)) return "#";
128
+ return local.href;
129
+ }
130
+ const raw = internalHref() ?? "";
123
131
  if (isDangerousHref(raw)) return "#";
124
132
  return applyRewriteOutput(raw);
125
133
  });
@@ -132,8 +140,8 @@ export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Ele
132
140
 
133
141
  const routePrefetch = createMemo(() => {
134
142
  if (local.href !== undefined) return undefined;
135
- const h = resolvedHref();
136
- if (isExternal(h)) return undefined;
143
+ const h = internalHref();
144
+ if (!h || isExternal(h)) return undefined;
137
145
  try {
138
146
  const url = new URL(h, typeof window !== "undefined" ? window.location.href : "http://localhost/");
139
147
  const match = matchRoute(ctx.routeTree, url.pathname, ctx.caseSensitive, toLocaleMatch(ctx.localeConfig));
@@ -226,15 +234,27 @@ export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Ele
226
234
  return;
227
235
  }
228
236
 
237
+ let to = local.to ?? "";
238
+ let hash = local.hash;
239
+ if (!local.to && local.href !== undefined) {
240
+ try {
241
+ const url = new URL(h, typeof window !== "undefined" ? window.location.href : "http://localhost/");
242
+ to = url.pathname + url.search;
243
+ hash = hash ?? (url.hash || undefined);
244
+ } catch {
245
+ to = h;
246
+ }
247
+ }
248
+
229
249
  navigate({
230
- hash: local.hash,
250
+ hash,
231
251
  params: local.params,
232
252
  replace: local.replace,
233
253
  revalidate: local.revalidate,
234
254
  scroll: local.scroll,
235
255
  search: local.search,
236
256
  shallow: local.shallow,
237
- to: local.to ?? "",
257
+ to,
238
258
  viewTransition: local.viewTransition,
239
259
  });
240
260
  }
@@ -244,8 +264,14 @@ export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Ele
244
264
  if (local.href !== undefined) return;
245
265
  const h = resolvedHref();
246
266
  if (isExternal(h)) return;
247
- /* params + search already resolved into h by resolvedHref() — passing them again would double-apply */
248
- prefetch({ to: h });
267
+ /* params + search already resolved into h by resolvedHref() — passing them again would double-apply.
268
+ * Intent prefetches that URL's NDJSON shell. Viewport/render only warm JS once per route. */
269
+ const strategy = effectivePrefetch();
270
+ if (strategy === "viewport" || strategy === "render") {
271
+ prefetch({ modulesOnly: true, to: h });
272
+ } else {
273
+ prefetch({ to: h });
274
+ }
249
275
  }
250
276
 
251
277
  function handleIntent(): void {
@@ -288,49 +314,62 @@ export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Ele
288
314
  };
289
315
  }
290
316
 
291
- function setupPrefetchBehavior(el: HTMLAnchorElement): void {
292
- if (local.href !== undefined) return;
293
- if (isExternal(resolvedHref())) return;
294
-
295
- const strategy = effectivePrefetch();
296
-
297
- if (strategy === "intent") {
298
- el.addEventListener("focus", handleIntent);
299
- el.addEventListener("touchstart", handleIntent, { passive: true });
300
- onCleanup(() => {
301
- el.removeEventListener("focus", handleIntent);
302
- el.removeEventListener("touchstart", handleIntent);
303
- });
304
- }
317
+ /* Capture the element; attach listeners in createEffect so cleanup is owned. */
318
+ const [anchor, setAnchor] = createSignal<HTMLAnchorElement | undefined>();
319
+
320
+ createEffect(
321
+ () => {
322
+ const el = anchor();
323
+ const strategy = effectivePrefetch();
324
+ const href = resolvedHref();
325
+ return {
326
+ disabled: !!local.disabled,
327
+ el,
328
+ external: isExternal(href),
329
+ hrefOnly: local.href !== undefined,
330
+ strategy,
331
+ };
332
+ },
333
+ ({ disabled, el, external, hrefOnly, strategy }) => {
334
+ if (!el || disabled || hrefOnly || external) return;
335
+
336
+ if (strategy === "intent") {
337
+ el.addEventListener("focus", handleIntent);
338
+ el.addEventListener("touchstart", handleIntent, { passive: true });
339
+ return () => {
340
+ el.removeEventListener("focus", handleIntent);
341
+ el.removeEventListener("touchstart", handleIntent);
342
+ };
343
+ }
305
344
 
306
- if (strategy === "viewport") {
307
- let observer: IntersectionObserver | undefined;
308
- const cancel = scheduleAfterLoad(() => {
309
- if (typeof IntersectionObserver === "undefined") return;
310
- observer = new IntersectionObserver(
311
- (entries) => {
312
- for (const entry of entries) {
313
- if (entry.isIntersecting) {
314
- triggerPrefetch();
315
- observer?.unobserve(entry.target);
345
+ if (strategy === "viewport") {
346
+ let observer: IntersectionObserver | undefined;
347
+ const cancel = scheduleAfterLoad(() => {
348
+ if (typeof IntersectionObserver === "undefined") return;
349
+ observer = new IntersectionObserver(
350
+ (entries) => {
351
+ for (const entry of entries) {
352
+ if (entry.isIntersecting) {
353
+ triggerPrefetch();
354
+ observer?.unobserve(entry.target);
355
+ }
316
356
  }
317
- }
318
- },
319
- { threshold: 0 },
320
- );
321
- observer.observe(el);
322
- });
323
- onCleanup(() => {
324
- cancel();
325
- observer?.disconnect();
326
- });
327
- }
357
+ },
358
+ { threshold: 0 },
359
+ );
360
+ observer.observe(el);
361
+ });
362
+ return () => {
363
+ cancel();
364
+ observer?.disconnect();
365
+ };
366
+ }
328
367
 
329
- if (strategy === "render") {
330
- const cancel = scheduleAfterLoad(() => triggerPrefetch());
331
- onCleanup(cancel);
332
- }
333
- }
368
+ if (strategy === "render") {
369
+ return scheduleAfterLoad(() => triggerPrefetch());
370
+ }
371
+ },
372
+ );
334
373
 
335
374
  function disabledStyle(): JSX.CSSProperties | string {
336
375
  if (typeof local.style === "string") return `cursor:not-allowed;${local.style}`;
@@ -341,7 +380,7 @@ export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Ele
341
380
  return (
342
381
  <Show
343
382
  fallback={
344
- <span {...rest} aria-disabled="true" class={classes()} style={disabledStyle()} tabIndex={-1}>
383
+ <span {...rest} aria-disabled="true" class={classes()} style={disabledStyle()} tabindex={-1}>
345
384
  {local.children}
346
385
  </span>
347
386
  }
@@ -356,7 +395,7 @@ export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Ele
356
395
  href={resolvedHref()}
357
396
  onClick={handleClick}
358
397
  onMouseEnter={handleIntent}
359
- ref={setupPrefetchBehavior}
398
+ ref={setAnchor}
360
399
  rel={effectiveRel()}
361
400
  target={local.target}
362
401
  >
@@ -1,5 +1,5 @@
1
1
  import type { DeferContext } from "../defer/index.ts";
2
- import { createDeferContext } from "../defer/index.ts";
2
+ import { containsDeferred, createDeferContext } from "../defer/index.ts";
3
3
  import { parseMilliseconds, parseSeconds } from "../duration/index.ts";
4
4
  import {
5
5
  isNotFoundError,
@@ -35,7 +35,7 @@ import { buildVaryHeader } from "../server-handler/etag.ts";
35
35
  import type { FlareStore } from "../store/index.ts";
36
36
  import { noopTracer } from "../tracing/noop.ts";
37
37
  import type { FlareTracer } from "../tracing/types.ts";
38
- import { buildUrl, parseSearchParams, type SearchParams } from "../url/index.ts";
38
+ import { buildUrl, parseSearchParams, serializeSearchParams, type SearchParams } from "../url/index.ts";
39
39
  import { isStandardSchema, issuesToFlattenedError } from "../validation/index.ts";
40
40
 
41
41
  export type { LoaderCause };
@@ -144,6 +144,7 @@ function buildCdnCacheHeaders(cdn: CdnCacheConfig, params: Record<string, string
144
144
  }
145
145
 
146
146
  interface InternalRoute {
147
+ authenticateError?: Error;
147
148
  authorizeError?: Error;
148
149
  location: Location<Record<string, string | string[]>, SearchParams, string>;
149
150
  preloaderError?: Error;
@@ -153,6 +154,37 @@ interface InternalRoute {
153
154
  validatedSearch: SearchParams;
154
155
  }
155
156
 
157
+ function defaultSsrCacheKey(
158
+ virtualPath: string,
159
+ params: Record<string, string | string[]>,
160
+ search: SearchParams,
161
+ ): string {
162
+ const base = `flare:${virtualPath}:${JSON.stringify(params)}`;
163
+ const searchPart = serializeSearchParams(search);
164
+ return searchPart.length > 0 ? `${base}:${searchPart}` : base;
165
+ }
166
+
167
+ function isRequiredAuth(route: ResolvedRoute): boolean {
168
+ return route.authenticate !== undefined && route.authenticateMode !== "optional";
169
+ }
170
+
171
+ /** Store UnauthenticatedError on the first required-auth route and every subsequent match so loaders skip. */
172
+ function attachUnauthenticated(internalRoutes: InternalRoute[], err: UnauthenticatedError): void {
173
+ let first = -1;
174
+ for (let i = 0; i < internalRoutes.length; i++) {
175
+ const ir = internalRoutes[i];
176
+ if (ir && isRequiredAuth(ir.route)) {
177
+ first = i;
178
+ break;
179
+ }
180
+ }
181
+ if (first < 0) return;
182
+ for (let j = first; j < internalRoutes.length; j++) {
183
+ const ir = internalRoutes[j];
184
+ if (ir) ir.authenticateError = err;
185
+ }
186
+ }
187
+
156
188
  /** Phase 1: Validate route inputs (params + search) and build location objects. */
157
189
  async function buildInternalRoutes(
158
190
  routes: ResolvedRoute[],
@@ -235,38 +267,43 @@ export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>):
235
267
  const hasAnyAuth = internalRoutes.some((r) => r.route.authenticate !== undefined);
236
268
 
237
269
  if (hasAnyAuth) {
270
+ const hasRequired = internalRoutes.some((r) => isRequiredAuth(r.route));
238
271
  if (!config.authenticateFn) {
239
- /* Only throw if at least one route requires auth (not just optional) */
240
- const hasRequired = internalRoutes.some(
241
- (r) => r.route.authenticate !== undefined && r.route.authenticateMode !== "optional",
242
- );
243
272
  if (hasRequired) {
244
273
  authSpan.setStatus("error");
245
- authSpan.end();
246
- throw new UnauthenticatedError();
274
+ attachUnauthenticated(internalRoutes, new UnauthenticatedError());
247
275
  }
248
276
  } else {
249
- /* find first route with authenticate to get callerData */
250
277
  const firstAuthRoute = internalRoutes.find((r) => r.route.authenticate !== undefined);
251
- auth = await config.authenticateFn({
252
- callerData: firstAuthRoute?.route.authenticate as unknown[] | undefined,
253
- env,
254
- request,
255
- serverContext: getServerContext(),
256
- url,
257
- });
258
-
259
- if (auth === null || auth === undefined) {
260
- /* Only throw for required auth (true or unset mode), not optional */
261
- const hasRequired = internalRoutes.some(
262
- (r) => r.route.authenticate !== undefined && r.route.authenticateMode !== "optional",
263
- );
264
- if (hasRequired) {
278
+ try {
279
+ auth = await config.authenticateFn({
280
+ callerData: firstAuthRoute?.route.authenticate as unknown[] | undefined,
281
+ env,
282
+ request,
283
+ serverContext: getServerContext(),
284
+ url,
285
+ });
286
+ } catch (e: unknown) {
287
+ if (isRedirectResponse(e) || isNotFoundError(e)) {
288
+ authSpan.setStatus("error");
289
+ authSpan.end();
290
+ throw e;
291
+ }
292
+ if (isUnauthenticatedError(e)) {
293
+ authSpan.setStatus("error");
294
+ attachUnauthenticated(internalRoutes, e);
295
+ auth = null;
296
+ } else {
265
297
  authSpan.setStatus("error");
266
298
  authSpan.end();
267
- throw new UnauthenticatedError();
299
+ throw e;
268
300
  }
269
301
  }
302
+
303
+ if (!internalRoutes.some((r) => r.authenticateError) && (auth === null || auth === undefined) && hasRequired) {
304
+ authSpan.setStatus("error");
305
+ attachUnauthenticated(internalRoutes, new UnauthenticatedError());
306
+ }
270
307
  }
271
308
  }
272
309
  authSpan.end();
@@ -292,6 +329,8 @@ export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>):
292
329
  };
293
330
 
294
331
  for (const ir of internalRoutes) {
332
+ if (ir.authenticateError) continue;
333
+
295
334
  /* 3a. Preloader */
296
335
  if (ir.route.preloader && !ir.preloaderError) {
297
336
  const preloaderSpan = tracer.startSpan(`flare.pipeline.preloader:${ir.route.virtualPath}`);
@@ -391,6 +430,18 @@ export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>):
391
430
  const deferContext = createDeferContext(matchId, { prefetch: config.prefetch });
392
431
  deferContexts.set(matchId, deferContext);
393
432
 
433
+ if (ir.authenticateError) {
434
+ return {
435
+ deferContext,
436
+ error: ir.authenticateError,
437
+ loaderData: undefined,
438
+ matchId,
439
+ preloaderContext: ir.preloaderSnapshot,
440
+ route: ir.route,
441
+ status: "error",
442
+ };
443
+ }
444
+
394
445
  /* Authorize failure stored in phase 3b — skip loader, produce error match */
395
446
  if (ir.authorizeError) {
396
447
  return {
@@ -434,14 +485,17 @@ export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>):
434
485
  const ssrConfig = ir.route.cache?.ssr ?? ssrCacheDefaults;
435
486
  let cacheKey: string | undefined;
436
487
  if (ssrConfig && resolvedStore) {
437
- cacheKey = ssrConfig.key
438
- ? ssrConfig.key({ params: ir.validatedParams })
439
- : `flare:${ir.route.virtualPath}:${JSON.stringify(ir.validatedParams)}`;
488
+ const personalized = ir.route.authenticateMode === true || ir.route.authenticateMode === "optional";
489
+ if (ssrConfig.key) {
490
+ cacheKey = ssrConfig.key({ auth, params: ir.validatedParams, search });
491
+ } else if (!personalized) {
492
+ cacheKey = defaultSsrCacheKey(ir.route.virtualPath, ir.validatedParams, search);
493
+ }
440
494
  }
441
495
  if (ssrConfig && resolvedStore && cacheKey) {
442
496
  try {
443
497
  const cached = await resolvedStore.get(cacheKey);
444
- if (cached) {
498
+ if (cached && !containsDeferred(cached.data)) {
445
499
  const isFresh =
446
500
  ssrConfig.staleTime === undefined || Date.now() - cached.storedAt < parseMilliseconds(ssrConfig.staleTime);
447
501
  if (isFresh) {
@@ -488,20 +542,24 @@ export async function runPipeline<TEnv = unknown>(config: PipelineConfig<TEnv>):
488
542
  if (config.queryClient) {
489
543
  loaderCtx.queryClient = config.queryClient;
490
544
  }
545
+ const startedAt = Date.now();
491
546
  const loaderData = await ir.route.loader(loaderCtx);
492
547
 
493
- /* Store cache write-back */
494
- if (ssrConfig && resolvedStore && cacheKey) {
548
+ /* Store cache write-back — skip deferred trees (markers have no live promises) */
549
+ if (ssrConfig && resolvedStore && cacheKey && !containsDeferred(loaderData)) {
495
550
  let tags: string[] | undefined;
496
551
  if (ssrConfig.tags) {
497
552
  tags = typeof ssrConfig.tags === "function" ? ssrConfig.tags({ params: ir.validatedParams }) : ssrConfig.tags;
498
553
  }
499
554
  try {
500
- await resolvedStore.set(
501
- cacheKey,
502
- { data: serializeLoaderData(loaderData), storedAt: Date.now(), tags },
503
- ssrConfig.ttl !== undefined ? parseSeconds(ssrConfig.ttl) : undefined,
504
- );
555
+ const existing = await resolvedStore.get(cacheKey);
556
+ if (!existing || existing.storedAt < startedAt) {
557
+ await resolvedStore.set(
558
+ cacheKey,
559
+ { data: serializeLoaderData(loaderData), storedAt: startedAt, tags },
560
+ ssrConfig.ttl !== undefined ? parseSeconds(ssrConfig.ttl) : undefined,
561
+ );
562
+ }
505
563
  } catch (e: unknown) {
506
564
  warn("cache", "Store set failed", e);
507
565
  }
@@ -0,0 +1,15 @@
1
+ const DEFAULT_COOKIE_NAME = "flare.locale";
2
+ const COOKIE_NAME_RE = /^[\w.-]+$/;
3
+
4
+ export function formatLocaleCookie(
5
+ locale: string,
6
+ cookieName: string = DEFAULT_COOKIE_NAME,
7
+ opts?: { https?: boolean; maxAge?: number; secure?: boolean },
8
+ ): string {
9
+ const name = COOKIE_NAME_RE.test(cookieName) ? cookieName : DEFAULT_COOKIE_NAME;
10
+ const safe = locale.replace(/[\r\n;\0]/g, "");
11
+ const maxAge = opts?.maxAge ?? 31_536_000;
12
+ const useSecure = opts?.secure ?? opts?.https ?? false;
13
+ const secureFlag = useSecure ? "; Secure" : "";
14
+ return `${name}=${safe}; Path=/; Max-Age=${maxAge}; SameSite=Lax${secureFlag}`;
15
+ }
@@ -1,16 +1,8 @@
1
- import {
2
- createContext,
3
- createEffect,
4
- createSignal,
5
- type JSX,
6
- on,
7
- onCleanup,
8
- onMount,
9
- sharedConfig,
10
- useContext,
11
- } from "solid-js";
1
+ import { createContext, createEffect, createSignal, onSettled, untrack, useContext } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
12
3
  import { BroadcastCtx } from "../broadcast/context.ts";
13
4
  import { escapeJsString } from "../theme/index.tsx";
5
+ import { formatLocaleCookie } from "./cookie.ts";
14
6
 
15
7
  /* ── Types ─────────────────────────────────────────────────────────── */
16
8
 
@@ -24,6 +16,8 @@ export interface LocaleConfig {
24
16
  syncDirection?: boolean;
25
17
  }
26
18
 
19
+ export { formatLocaleCookie } from "./cookie.ts";
20
+
27
21
  export interface LocaleContextValue {
28
22
  defaultLocale: string;
29
23
  locale: () => string;
@@ -40,52 +34,47 @@ export function getLocaleScript(opts: LocaleConfig): string {
40
34
 
41
35
  /* ── Context ───────────────────────────────────────────────────────── */
42
36
 
43
- const LocaleCtx = createContext<LocaleContextValue>();
37
+ const LocaleCtx = createContext<LocaleContextValue | null>(null);
44
38
 
45
39
  export function LocaleProvider(props: { children: JSX.Element; config: LocaleConfig; initial?: string }): JSX.Element {
46
- if (sharedConfig.context) {
47
- return props.children;
48
- }
49
-
50
- const cfg = props.config;
40
+ const cfg = untrack(() => props.config);
51
41
  const defaultLocale = cfg.defaultLocale;
52
42
  const cookieName = cfg.cookieName ?? "flare.locale";
53
43
 
54
- /* Capture context at component setup level (not inside onMount) */
44
+ /* Capture context at component setup level (not inside onSettled) */
55
45
  const channel = cfg.broadcast ? useContext(BroadcastCtx) : undefined;
56
46
 
57
47
  /* URL is truth: initial comes from server (URL params), not localStorage */
58
- const initial = props.initial ?? defaultLocale;
48
+ const initial = untrack(() => props.initial) ?? defaultLocale;
59
49
  const [locale, setLocaleSignal] = createSignal(initial);
60
50
 
61
51
  /* Cross-tab sync: BroadcastChannel for locale (opt-in) */
62
- onMount(() => {
52
+ onSettled(() => {
63
53
  if (!channel) return;
64
54
 
65
- const unsubscribe = channel.onMessage((msg) => {
55
+ return channel.onMessage((msg) => {
66
56
  if (msg.type === "locale" && typeof msg.value === "string" && cfg.locales.includes(msg.value)) {
67
57
  setLocaleSignal(msg.value);
68
58
  }
69
59
  });
60
+ });
70
61
 
62
+ if (channel) {
71
63
  createEffect(
72
- on(
73
- locale,
74
- (l) => {
75
- channel.broadcast({ type: "locale", value: l });
76
- },
77
- { defer: true },
78
- ),
64
+ locale,
65
+ (l) => {
66
+ channel.broadcast({ type: "locale", value: l });
67
+ },
68
+ { defer: true },
79
69
  );
70
+ }
80
71
 
81
- onCleanup(unsubscribe);
82
- });
83
-
84
- createEffect(() => {
85
- const l = locale();
72
+ createEffect(locale, (l) => {
86
73
  if (typeof document === "undefined") return;
87
74
  document.documentElement.setAttribute("lang", l);
88
- document.cookie = `${cookieName}=${l}; path=/; max-age=31536000; samesite=lax`;
75
+ document.cookie = formatLocaleCookie(l, cookieName, {
76
+ https: typeof location !== "undefined" && location.protocol === "https:",
77
+ });
89
78
  });
90
79
 
91
80
  const setLocale = (l: string): void => {
@@ -100,7 +89,7 @@ export function LocaleProvider(props: { children: JSX.Element; config: LocaleCon
100
89
  setLocale,
101
90
  };
102
91
 
103
- return <LocaleCtx.Provider value={value}>{props.children}</LocaleCtx.Provider>;
92
+ return <LocaleCtx value={value}>{props.children}</LocaleCtx>;
104
93
  }
105
94
 
106
95
  export function useLocale(): LocaleContextValue {
package/src/locale.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { LocaleScript } from "./components/locale-script.tsx";
2
2
  export type { LocaleConfig, LocaleContextValue } from "./locale/index.tsx";
3
+ export { formatLocaleCookie } from "./locale/cookie.ts";
3
4
  export { buildHreflangLinks, getLocaleScript, LocaleProvider, useLocale } from "./locale/index.tsx";
@@ -12,6 +12,57 @@ interface ApiProxyConfig<TEnv = unknown> {
12
12
  target: (ctx: { env: TEnv }) => ServiceBinding;
13
13
  }
14
14
 
15
+ const HOP_BY_HOP = new Set([
16
+ "connection",
17
+ "host",
18
+ "keep-alive",
19
+ "proxy-authenticate",
20
+ "proxy-authorization",
21
+ "te",
22
+ "trailer",
23
+ "trailers",
24
+ "transfer-encoding",
25
+ "upgrade",
26
+ ]);
27
+
28
+ function copyForwardHeaders(source: Headers): Headers {
29
+ const headers = new Headers();
30
+ const extraHop = new Set<string>();
31
+ const connection = source.get("connection");
32
+ if (connection) {
33
+ for (const name of connection.split(",")) {
34
+ const trimmed = name.trim().toLowerCase();
35
+ if (trimmed) extraHop.add(trimmed);
36
+ }
37
+ }
38
+ for (const [key, value] of source) {
39
+ const lower = key.toLowerCase();
40
+ if (HOP_BY_HOP.has(lower) || extraHop.has(lower)) continue;
41
+ headers.append(key, value);
42
+ }
43
+ return headers;
44
+ }
45
+
46
+ function withNosniff(response: Response): Response {
47
+ if (response.headers.get("X-Content-Type-Options")) return response;
48
+ const headers = new Headers(response.headers);
49
+ headers.set("X-Content-Type-Options", "nosniff");
50
+ return new Response(response.body, {
51
+ headers,
52
+ status: response.status,
53
+ statusText: response.statusText,
54
+ });
55
+ }
56
+
57
+ function bypassText(ctx: MiddlewareContext, body: string, status: number) {
58
+ return ctx.bypass(
59
+ new Response(body, {
60
+ headers: { "X-Content-Type-Options": "nosniff" },
61
+ status,
62
+ }),
63
+ );
64
+ }
65
+
15
66
  export function apiProxy<TEnv = unknown>(config: ApiProxyConfig<TEnv>): FlareMiddleware<TEnv> {
16
67
  return async (ctx: MiddlewareContext<TEnv>) => {
17
68
  /* check enabled */
@@ -35,10 +86,13 @@ export function apiProxy<TEnv = unknown>(config: ApiProxyConfig<TEnv>): FlareMid
35
86
  targetPath = pathname.slice(config.pathPrefix.length) || "/";
36
87
  }
37
88
 
38
- /* build proxied request */
89
+ /* build proxied request — rewrite must stay on this origin */
39
90
  const targetUrl = new URL(targetPath, ctx.url.origin);
91
+ if (targetUrl.origin !== ctx.url.origin) {
92
+ return bypassText(ctx, "Bad Request", 400);
93
+ }
40
94
  targetUrl.search = ctx.url.search;
41
- const headers = new Headers(ctx.request.headers);
95
+ const headers = copyForwardHeaders(ctx.request.headers);
42
96
 
43
97
  if (config.headers) {
44
98
  const extra = config.headers({ env: ctx.env, request: ctx.request });
@@ -56,9 +110,9 @@ export function apiProxy<TEnv = unknown>(config: ApiProxyConfig<TEnv>): FlareMid
56
110
 
57
111
  const service = config.target({ env: ctx.env });
58
112
  const response = await service.fetch(proxyRequest);
59
- return ctx.bypass(response);
113
+ return ctx.bypass(withNosniff(response));
60
114
  } catch {
61
- return ctx.bypass(new Response("Bad Gateway", { status: 502 }));
115
+ return bypassText(ctx, "Bad Gateway", 502);
62
116
  }
63
117
  };
64
118
  }