@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
@@ -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
  }
@@ -18,6 +18,23 @@ interface CdnProxyConfig<TEnv = unknown> {
18
18
  pathPrefix: string;
19
19
  }
20
20
 
21
+ const NOSNIFF = { "X-Content-Type-Options": "nosniff" } as const;
22
+
23
+ function isNavigableExecutable(contentType: string): boolean {
24
+ const mime = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
25
+ return (
26
+ mime === "application/xhtml+xml" ||
27
+ mime === "application/xml" ||
28
+ mime === "image/svg+xml" ||
29
+ mime === "text/html" ||
30
+ mime === "text/xml"
31
+ );
32
+ }
33
+
34
+ function bypassText(ctx: MiddlewareContext, body: string, status: number) {
35
+ return ctx.bypass(new Response(body, { headers: NOSNIFF, status }));
36
+ }
37
+
21
38
  export function cdnProxy<TEnv = unknown>(config: CdnProxyConfig<TEnv>): FlareMiddleware<TEnv> {
22
39
  const defaultCacheControl = config.cacheControl ?? "public, max-age=31536000, immutable";
23
40
 
@@ -30,27 +47,31 @@ export function cdnProxy<TEnv = unknown>(config: CdnProxyConfig<TEnv>): FlareMid
30
47
  try {
31
48
  key = decodeURIComponent(rawKey);
32
49
  } catch {
33
- return ctx.bypass(new Response("Bad Request", { status: 400 }));
50
+ return bypassText(ctx, "Bad Request", 400);
34
51
  }
35
52
  /* Reject path traversal, null bytes, backslashes, and CRLF */
36
53
  if (key.includes("..") || key.includes("\0") || key.includes("\\") || key.includes("\r") || key.includes("\n")) {
37
- return ctx.bypass(new Response("Bad Request", { status: 400 }));
54
+ return bypassText(ctx, "Bad Request", 400);
38
55
  }
39
56
  const bucket = config.bucket({ env: ctx.env });
40
57
  const object = await bucket.get(key);
41
58
 
42
59
  if (!object) {
43
- return ctx.bypass(new Response("Not Found", { status: 404 }));
60
+ return bypassText(ctx, "Not Found", 404);
44
61
  }
45
62
 
46
63
  const headers: Record<string, string> = {
47
64
  "Cache-Control": defaultCacheControl,
48
65
  "Content-Length": String(object.size),
49
66
  ETag: object.etag,
67
+ "X-Content-Type-Options": "nosniff",
50
68
  };
51
69
 
52
70
  if (object.httpMetadata?.contentType) {
53
71
  headers["Content-Type"] = object.httpMetadata.contentType;
72
+ if (isNavigableExecutable(object.httpMetadata.contentType)) {
73
+ headers["Content-Disposition"] = "attachment";
74
+ }
54
75
  }
55
76
 
56
77
  return ctx.bypass(new Response(object.body, { headers }));
@@ -2,6 +2,8 @@ import { match as cldrMatch } from "@formatjs/intl-localematcher";
2
2
  import { isbot } from "isbot";
3
3
  import Negotiator from "negotiator";
4
4
  import type { FlareMiddleware } from "..";
5
+ import { formatLocaleCookie } from "../../locale/cookie.ts";
6
+ import { HEADER_DATA, HEADER_FLAG, HEADER_PRERENDER, HEADER_PREFETCH, INTERNAL_PATH_PREFIX } from "../../protocol.ts";
5
7
  import { matchRoute, toLocaleMatch } from "../../router-primitives/tree.ts";
6
8
 
7
9
  /**
@@ -68,6 +70,13 @@ function getLocaleFromAcceptLanguage(request: Request, locales: readonly string[
68
70
  return defaultLocale;
69
71
  }
70
72
 
73
+ /** Path-only Location so the document applies Set-Cookie on 3xx (SW fetch() with follow does not). */
74
+ function locationForPath(url: URL, pathname: string): string {
75
+ const next = new URL(url);
76
+ next.pathname = pathname;
77
+ return `${next.pathname}${next.search}${next.hash}`;
78
+ }
79
+
71
80
  function buildCookieHeader(
72
81
  locale: string,
73
82
  cookieName: string,
@@ -75,12 +84,7 @@ function buildCookieHeader(
75
84
  isHttps: boolean,
76
85
  secure?: boolean,
77
86
  ): string {
78
- /* Defense-in-depth: strip chars that could enable header/cookie injection.
79
- * Primary gate is localeSet.has() before any call site, but this guards
80
- * against future code paths that might bypass that check. */
81
- const safe = locale.replace(/[\r\n;\0]/g, "");
82
- const secureFlag = (secure ?? isHttps) ? "; Secure" : "";
83
- return `${cookieName}=${safe}; Path=/; Max-Age=${maxAge}; SameSite=Lax${secureFlag}`;
87
+ return formatLocaleCookie(locale, cookieName, { https: isHttps, maxAge, secure });
84
88
  }
85
89
 
86
90
  export function i18n(options?: I18nMiddlewareOptions): FlareMiddleware {
@@ -102,14 +106,14 @@ export function i18n(options?: I18nMiddlewareOptions): FlareMiddleware {
102
106
  cachedCookieName = options?.cookieName ?? ctx.locale.cookieName ?? "flare.locale";
103
107
  cachedMaxAge = options?.cookie?.maxAge ?? 31536000;
104
108
  const skip = options?.skip ?? ctx.locale.skip ?? [];
105
- cachedSkipPaths = ["/_fn/", ...skip];
109
+ cachedSkipPaths = [INTERNAL_PATH_PREFIX, ...skip];
106
110
  }
107
111
 
108
112
  const { pathname } = ctx.url;
109
113
  const isHttps = ctx.url.protocol === "https:";
110
114
 
111
115
  /* Prerender snapshots the exact expanded URL — do not locale-redirect. */
112
- if (ctx.request.headers.get("x-flare-prerender") === "1") {
116
+ if (ctx.request.headers.get(HEADER_PRERENDER) === HEADER_FLAG) {
113
117
  const prerenderLocale = getLocaleFromPath(pathname, cachedLocaleSet);
114
118
  ctx.serverContext.locale = prerenderLocale ?? defaultLocale;
115
119
  return ctx.next();
@@ -146,9 +150,9 @@ export function i18n(options?: I18nMiddlewareOptions): FlareMiddleware {
146
150
  if (firstSegment && LOCALE_LIKE_RE.test(firstSegment)) {
147
151
  const lowerSegment = firstSegment.toLowerCase();
148
152
  if (firstSegment !== lowerSegment) {
149
- const newUrl = new URL(ctx.url);
150
- newUrl.pathname = pathname.replace(firstSegment, lowerSegment);
151
- const headers = new Headers({ Location: newUrl.href });
153
+ const headers = new Headers({
154
+ Location: locationForPath(ctx.url, pathname.replace(firstSegment, lowerSegment)),
155
+ });
152
156
  if (cachedLocaleSet.has(lowerSegment)) {
153
157
  headers.set(
154
158
  "Set-Cookie",
@@ -165,9 +169,9 @@ export function i18n(options?: I18nMiddlewareOptions): FlareMiddleware {
165
169
 
166
170
  /* Invalid/unsupported locale-like segment in path → strip it */
167
171
  if (hasInvalidLocale) {
168
- const newUrl = new URL(ctx.url);
169
- newUrl.pathname = pathname.replace(`/${firstSegment}`, "") || "/";
170
- const headers = new Headers({ Location: newUrl.href });
172
+ const headers = new Headers({
173
+ Location: locationForPath(ctx.url, pathname.replace(`/${firstSegment}`, "") || "/"),
174
+ });
171
175
  headers.set(
172
176
  "Set-Cookie",
173
177
  buildCookieHeader(defaultLocale, cachedCookieName, cachedMaxAge, isHttps, options?.cookie?.secure),
@@ -182,9 +186,7 @@ export function i18n(options?: I18nMiddlewareOptions): FlareMiddleware {
182
186
  const canStrip =
183
187
  !ctx.routeTree || matchRoute(ctx.routeTree, strippedPath, false, toLocaleMatch(ctx.locale)) !== null;
184
188
  if (canStrip) {
185
- const newUrl = new URL(ctx.url);
186
- newUrl.pathname = strippedPath;
187
- const headers = new Headers({ Location: newUrl.href });
189
+ const headers = new Headers({ Location: locationForPath(ctx.url, strippedPath) });
188
190
  headers.set(
189
191
  "Set-Cookie",
190
192
  buildCookieHeader(defaultLocale, cachedCookieName, cachedMaxAge, isHttps, options?.cookie?.secure),
@@ -200,9 +202,7 @@ export function i18n(options?: I18nMiddlewareOptions): FlareMiddleware {
200
202
  if (!pathLocale && pathname === "/" && !cookieLocale) {
201
203
  const acceptLocale = getLocaleFromAcceptLanguage(ctx.request, locales, defaultLocale);
202
204
  if (acceptLocale !== defaultLocale) {
203
- const newUrl = new URL(ctx.url);
204
- newUrl.pathname = `/${acceptLocale}`;
205
- const headers = new Headers({ Location: newUrl.href });
205
+ const headers = new Headers({ Location: locationForPath(ctx.url, `/${acceptLocale}`) });
206
206
  headers.set(
207
207
  "Set-Cookie",
208
208
  buildCookieHeader(acceptLocale, cachedCookieName, cachedMaxAge, isHttps, options?.cookie?.secure),
@@ -216,13 +216,11 @@ export function i18n(options?: I18nMiddlewareOptions): FlareMiddleware {
216
216
  * Only for full page loads (not NDJSON/prefetch — SPA router handles those).
217
217
  * e.g. /about with cookie=fr → 302 → /fr/about
218
218
  */
219
- const isDataRequest = ctx.request.headers.get("x-d") === "1";
219
+ const isDataRequest = ctx.request.headers.get(HEADER_DATA) === HEADER_FLAG;
220
220
  if (!pathLocale && cookieLocale && cookieLocale !== defaultLocale && !isDataRequest) {
221
- const newUrl = new URL(ctx.url);
222
- newUrl.pathname = `/${cookieLocale}${pathname}`;
223
221
  return ctx.bypass(
224
222
  new Response(null, {
225
- headers: new Headers({ Location: newUrl.href }),
223
+ headers: new Headers({ Location: locationForPath(ctx.url, `/${cookieLocale}${pathname}`) }),
226
224
  status: 302,
227
225
  }),
228
226
  );
@@ -235,7 +233,7 @@ export function i18n(options?: I18nMiddlewareOptions): FlareMiddleware {
235
233
  /* Prefetch requests must not set cookies — SPA prefetches multiple
236
234
  * locale URLs after hydration, and the last one to complete would
237
235
  * overwrite the correct cookie from the actual page load. */
238
- const isPrefetch = ctx.request.headers.get("x-p") === "1";
236
+ const isPrefetch = ctx.request.headers.get(HEADER_PREFETCH) === HEADER_FLAG;
239
237
  const needsCookie = !isPrefetch && currentLocale !== cookieLocale;
240
238
  if (needsCookie) {
241
239
  const setCookieHeader = buildCookieHeader(
@@ -1,15 +1,14 @@
1
1
  import { error } from "../../logger.ts";
2
+ import { KEEPALIVE_PATH } from "../../protocol.ts";
2
3
  import type { FlareMiddleware, MiddlewareContext } from "..";
3
4
 
4
- const PING_PATH = "/_flare/keepalive";
5
-
6
5
  interface KeepaliveConfig<TEnv = unknown> {
7
6
  handler?: (ctx: MiddlewareContext<TEnv>) => Promise<void> | void;
8
7
  }
9
8
 
10
9
  export function keepalive<TEnv = unknown>(config?: KeepaliveConfig<TEnv>): FlareMiddleware<TEnv> {
11
10
  return async (ctx: MiddlewareContext<TEnv>) => {
12
- if (ctx.url.pathname !== PING_PATH || ctx.request.method !== "GET") return ctx.next();
11
+ if (ctx.url.pathname !== KEEPALIVE_PATH || ctx.request.method !== "GET") return ctx.next();
13
12
 
14
13
  if (config?.handler) {
15
14
  try {
@@ -41,8 +41,15 @@ export function matchMount<TEnv>(mounts: MountConfig<TEnv>[], pathname: string):
41
41
  }
42
42
 
43
43
  export function buildMountRequest<TEnv>(request: Request, mount: MountConfig<TEnv>, url: URL): Request {
44
- const strippedPath = url.pathname.slice(mount.prefix.length) || "/";
44
+ let strippedPath = url.pathname.slice(mount.prefix.length) || "/";
45
+ if (!strippedPath.startsWith("/")) strippedPath = `/${strippedPath}`;
46
+ if (strippedPath.startsWith("//") || strippedPath.includes("\\") || /[\0\r\n]/.test(strippedPath)) {
47
+ strippedPath = "/";
48
+ }
45
49
  const target = new URL(strippedPath, url.origin);
50
+ if (target.origin !== url.origin) {
51
+ target.href = `${url.origin}/`;
52
+ }
46
53
  target.search = url.search;
47
54
  target.hash = url.hash;
48
55
  return new Request(target.toString(), {