@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.
- package/README.md +42 -19
- package/package.json +8 -6
- package/src/broadcast/index.ts +1 -1
- package/src/broadcast/provider.tsx +2 -2
- package/src/caches/index.ts +26 -0
- package/src/components/await.tsx +36 -39
- package/src/components/dev-error-overlay.tsx +8 -9
- package/src/components/direction-script.tsx +2 -2
- package/src/components/locale-script.tsx +2 -2
- package/src/components/reset-css.tsx +1 -1
- package/src/components/ssr-context.tsx +7 -14
- package/src/components/theme-script.tsx +2 -2
- package/src/components/view-transition-css.tsx +1 -1
- package/src/defer/index.ts +22 -0
- package/src/direction/index.tsx +61 -36
- package/src/errors/index.ts +45 -11
- package/src/fonts/component.tsx +2 -1
- package/src/form/index.tsx +18 -12
- package/src/generators/index.ts +73 -30
- package/src/head-client/index.ts +7 -0
- package/src/history/index.ts +6 -0
- package/src/hydrate/index.tsx +90 -60
- package/src/hydration/index.ts +26 -6
- package/src/i18n/index.ts +11 -2
- package/src/image/index.tsx +7 -4
- package/src/intercept-outlet/index.tsx +6 -2
- package/src/lazy/index.tsx +50 -50
- package/src/link/index.tsx +97 -58
- package/src/loader-pipeline/index.ts +93 -35
- package/src/locale/cookie.ts +15 -0
- package/src/locale/index.tsx +24 -35
- package/src/locale.ts +1 -0
- package/src/middleware/builtins/api-proxy.ts +58 -4
- package/src/middleware/builtins/cdn-proxy.ts +24 -3
- package/src/middleware/builtins/i18n.ts +23 -25
- package/src/middleware/builtins/keepalive.ts +2 -3
- package/src/mount/index.ts +8 -1
- package/src/navigation/index.ts +575 -129
- package/src/navigation/types.ts +11 -1
- package/src/navigation-progress/index.tsx +17 -16
- package/src/ndjson-client/index.ts +42 -17
- package/src/ndjson-server/index.ts +78 -29
- package/src/outlet/index.tsx +192 -97
- package/src/outlet/types.ts +9 -1
- package/src/plugins/dev-cdn-cache.ts +18 -5
- package/src/plugins/dev-dashboard/client.tsx +130 -118
- package/src/plugins/dev-prerender.ts +6 -1
- package/src/plugins/dev-server.ts +12 -3
- package/src/plugins/image-plugin.ts +18 -4
- package/src/plugins/index.ts +10 -7
- package/src/plugins/prerender-plugin.ts +3 -3
- package/src/plugins/server-fn.ts +15 -20
- package/src/plugins/solid-jsx.d.ts +3 -3
- package/src/plugins/sx-ast/atomic.ts +5 -5
- package/src/plugins/sx-ast/index.ts +9 -0
- package/src/plugins/sx.d.ts +4 -5
- package/src/plugins/virtual.ts +11 -6
- package/src/prerender/index.ts +68 -32
- package/src/protocol.ts +65 -0
- package/src/query-client/index.tsx +73 -14
- package/src/rewrite/index.ts +10 -4
- package/src/route-builder/create-layout.ts +1 -1
- package/src/route-builder/create-root-layout.ts +1 -1
- package/src/route-builder/types.ts +5 -1
- package/src/router-primitives/types.ts +2 -0
- package/src/security/index.ts +41 -5
- package/src/server/index.ts +1 -2
- package/src/server-fn/get-input.ts +90 -0
- package/src/server-fn/http-error.ts +13 -0
- package/src/server-fn/index.ts +94 -70
- package/src/server-fn-query.ts +9 -17
- package/src/server-handler/etag.ts +4 -2
- package/src/server-handler/headers.ts +1 -2
- package/src/server-handler/index.ts +164 -56
- package/src/server-handler/types.ts +2 -0
- package/src/service-worker/template.ts +10 -42
- package/src/ssr/head-prefix.ts +2 -1
- package/src/ssr/hoist-head-markers.ts +25 -0
- package/src/ssr/index.tsx +109 -92
- package/src/state-parser/index.ts +38 -26
- package/src/styles/index.ts +89 -60
- package/src/testing/index.ts +8 -3
- package/src/theme/index.tsx +35 -32
- package/src/tracing/timing.ts +6 -1
- package/src/url/index.ts +9 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
(
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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.
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
+
}
|
package/src/locale/index.tsx
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
52
|
+
onSettled(() => {
|
|
63
53
|
if (!channel) return;
|
|
64
54
|
|
|
65
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
(l)
|
|
75
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 = [
|
|
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(
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
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(
|
|
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 !==
|
|
11
|
+
if (ctx.url.pathname !== KEEPALIVE_PATH || ctx.request.method !== "GET") return ctx.next();
|
|
13
12
|
|
|
14
13
|
if (config?.handler) {
|
|
15
14
|
try {
|
package/src/mount/index.ts
CHANGED
|
@@ -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
|
-
|
|
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(), {
|