@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.
- package/README.md +44 -19
- package/package.json +10 -7
- 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
package/src/link/index.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { createMemo,
|
|
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
|
|
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
|
|
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
|
|
113
|
-
if (local.href !== undefined)
|
|
114
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
el
|
|
302
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
}
|
|
357
|
+
},
|
|
358
|
+
{ threshold: 0 },
|
|
359
|
+
);
|
|
360
|
+
observer.observe(el);
|
|
361
|
+
});
|
|
362
|
+
return () => {
|
|
363
|
+
cancel();
|
|
364
|
+
observer?.disconnect();
|
|
365
|
+
};
|
|
366
|
+
}
|
|
328
367
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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()}
|
|
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={
|
|
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
|
-
|
|
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
|
}
|