@pracht/core 0.4.0 → 0.5.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 +2 -2
- package/dist/index.d.mts +21 -9
- package/dist/index.mjs +326 -146
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,8 +28,8 @@ Route modules may export the page as a function default export or as a named
|
|
|
28
28
|
`handlePrachtRequest()` sanitizes unexpected 5xx errors by default so raw server
|
|
29
29
|
messages do not leak into SSR HTML or route-state JSON. Explicit
|
|
30
30
|
`PrachtHttpError` 4xx messages are preserved. Pass `debugErrors: true` to expose
|
|
31
|
-
raw details intentionally during debugging;
|
|
32
|
-
|
|
31
|
+
raw details intentionally during debugging; the flag is ignored when
|
|
32
|
+
`NODE_ENV=production`. Debug responses also attach `error.diagnostics`
|
|
33
33
|
metadata for the failure phase and matched framework files when available.
|
|
34
34
|
|
|
35
35
|
### Client
|
package/dist/index.d.mts
CHANGED
|
@@ -78,10 +78,11 @@ interface ApiConfig {
|
|
|
78
78
|
middleware?: string[];
|
|
79
79
|
/**
|
|
80
80
|
* When `true` (the default), state-changing API requests
|
|
81
|
-
* (POST/PUT/PATCH/DELETE) are rejected unless the browser signals
|
|
82
|
-
* same-origin
|
|
83
|
-
* matches the request URL's origin.
|
|
84
|
-
*
|
|
81
|
+
* (POST/PUT/PATCH/DELETE) are rejected unless the browser signals an
|
|
82
|
+
* exact same-origin fetch (`Sec-Fetch-Site: same-origin`) or the request
|
|
83
|
+
* Origin/Referer matches the request URL's origin. `same-site` is not
|
|
84
|
+
* accepted by default because sibling subdomains can be attacker-controlled.
|
|
85
|
+
* Set to `false` to opt out if you build your own CSRF protection into middleware.
|
|
85
86
|
*/
|
|
86
87
|
requireSameOrigin?: boolean;
|
|
87
88
|
}
|
|
@@ -123,7 +124,7 @@ interface ParamRouteSegment {
|
|
|
123
124
|
}
|
|
124
125
|
interface CatchAllRouteSegment {
|
|
125
126
|
type: "catchall";
|
|
126
|
-
name:
|
|
127
|
+
name: string;
|
|
127
128
|
}
|
|
128
129
|
type RouteSegment = StaticRouteSegment | ParamRouteSegment | CatchAllRouteSegment;
|
|
129
130
|
interface ResolvedRoute extends Omit<RouteMeta, "middleware"> {
|
|
@@ -155,11 +156,16 @@ interface BaseRouteArgs<TContext = RegisteredContext> {
|
|
|
155
156
|
}
|
|
156
157
|
interface LoaderArgs<TContext = RegisteredContext> extends BaseRouteArgs<TContext> {}
|
|
157
158
|
interface MiddlewareArgs<TContext = RegisteredContext> extends BaseRouteArgs<TContext> {}
|
|
159
|
+
type HeadAttributes = Record<string, string | undefined>;
|
|
160
|
+
interface HeadScriptDescriptor extends HeadAttributes {
|
|
161
|
+
children?: string;
|
|
162
|
+
}
|
|
158
163
|
interface HeadMetadata {
|
|
159
164
|
title?: string;
|
|
160
165
|
lang?: string;
|
|
161
|
-
meta?:
|
|
162
|
-
link?:
|
|
166
|
+
meta?: HeadAttributes[];
|
|
167
|
+
link?: HeadAttributes[];
|
|
168
|
+
script?: HeadScriptDescriptor[];
|
|
163
169
|
}
|
|
164
170
|
type MaybePromise<T> = T | Promise<T>;
|
|
165
171
|
type LoaderLike = ((args: LoaderArgs<any>) => unknown) | undefined;
|
|
@@ -175,7 +181,10 @@ interface RouteComponentProps<TLoader extends LoaderLike = undefined> {
|
|
|
175
181
|
params: RouteParams;
|
|
176
182
|
}
|
|
177
183
|
interface ErrorBoundaryProps {
|
|
178
|
-
error: Error
|
|
184
|
+
error: Error & {
|
|
185
|
+
diagnostics?: unknown;
|
|
186
|
+
status?: number;
|
|
187
|
+
};
|
|
179
188
|
}
|
|
180
189
|
interface ShellProps {
|
|
181
190
|
children: ComponentChildren;
|
|
@@ -339,6 +348,7 @@ declare function PrachtRuntimeProvider<TData>({
|
|
|
339
348
|
}>;
|
|
340
349
|
declare function startApp<TData = unknown>(options?: StartAppOptions<TData>): TData | undefined;
|
|
341
350
|
declare function readHydrationState<TData = unknown>(): PrachtHydrationState<TData> | undefined;
|
|
351
|
+
declare function useRouteData<TLoader extends LoaderLike>(): LoaderData<TLoader>;
|
|
342
352
|
declare function useRouteData<TData = unknown>(): TData;
|
|
343
353
|
declare function useLocation(): Location;
|
|
344
354
|
declare function useParams(): RouteParams;
|
|
@@ -395,6 +405,8 @@ interface PrerenderAppOptions {
|
|
|
395
405
|
cssManifest?: Record<string, string[]>;
|
|
396
406
|
/** Per-source-file JS map produced by the vite plugin for modulepreload hints. */
|
|
397
407
|
jsManifest?: Record<string, string[]>;
|
|
408
|
+
/** Maximum number of pages rendered concurrently. Defaults to 10. */
|
|
409
|
+
concurrency?: number;
|
|
398
410
|
}
|
|
399
411
|
declare function prerenderApp(options: PrerenderAppOptions): Promise<PrerenderResult[]>;
|
|
400
412
|
declare function prerenderApp(options: PrerenderAppOptions & {
|
|
@@ -423,4 +435,4 @@ interface InitClientRouterOptions {
|
|
|
423
435
|
}
|
|
424
436
|
declare function initClientRouter(options: InitClientRouterOptions): Promise<void>;
|
|
425
437
|
//#endregion
|
|
426
|
-
export { type ApiConfig, type ApiRouteArgs, type ApiRouteHandler, type ApiRouteMatch, type ApiRouteModule, type BaseRouteArgs, type DataModule, type ErrorBoundaryProps, Form, type FormProps, type GroupDefinition, type GroupMeta, type HandlePrachtRequestOptions, type HeadArgs, type HeadMetadata, type HeadersArgs, type HttpMethod, type ISGManifestEntry, type InitClientRouterOptions, type LoaderArgs, type LoaderData, type LoaderFn, type Location, type MiddlewareArgs, type MiddlewareFn, type MiddlewareModule, type MiddlewareResult, type ModuleImporter, type ModuleRef, type ModuleRegistry, type NavigateFn, type PrachtApp, type PrachtAppConfig, PrachtHttpError, type PrachtHydrationState, type PrachtRuntimeDiagnosticPhase, type PrachtRuntimeDiagnostics, PrachtRuntimeProvider, type PrefetchStrategy, type PrerenderAppOptions, type PrerenderAppResult, type PrerenderResult, type Register, type RenderMode, type ResolvedApiRoute, type ResolvedPrachtApp, type ResolvedRoute, type RouteComponentProps, type RouteConfig, type RouteDefinition, type RouteMatch, type RouteMeta, type RouteModule, type RouteParams, type RouteRevalidate, type RouteStateResult, type RouteTreeNode, type SerializedRouteError, type ShellModule, type ShellProps, type StartAppOptions, Suspense, type TimeRevalidatePolicy, applyDefaultSecurityHeaders, buildPathFromSegments, defineApp, forwardRef, group, handlePrachtRequest, initClientRouter, lazy, matchApiRoute, matchAppRoute, prerenderApp, readHydrationState, resolveApiRoutes, resolveApp, route, startApp, timeRevalidate, useIsHydrated, useLocation, useNavigate, useParams, useRevalidate, useRouteData };
|
|
438
|
+
export { type ApiConfig, type ApiRouteArgs, type ApiRouteHandler, type ApiRouteMatch, type ApiRouteModule, type BaseRouteArgs, type DataModule, type ErrorBoundaryProps, Form, type FormProps, type GroupDefinition, type GroupMeta, type HandlePrachtRequestOptions, type HeadArgs, type HeadAttributes, type HeadMetadata, type HeadScriptDescriptor, type HeadersArgs, type HttpMethod, type ISGManifestEntry, type InitClientRouterOptions, type LoaderArgs, type LoaderData, type LoaderFn, type Location, type MiddlewareArgs, type MiddlewareFn, type MiddlewareModule, type MiddlewareResult, type ModuleImporter, type ModuleRef, type ModuleRegistry, type NavigateFn, type PrachtApp, type PrachtAppConfig, PrachtHttpError, type PrachtHydrationState, type PrachtRuntimeDiagnosticPhase, type PrachtRuntimeDiagnostics, PrachtRuntimeProvider, type PrefetchStrategy, type PrerenderAppOptions, type PrerenderAppResult, type PrerenderResult, type Register, type RenderMode, type ResolvedApiRoute, type ResolvedPrachtApp, type ResolvedRoute, type RouteComponentProps, type RouteConfig, type RouteDefinition, type RouteMatch, type RouteMeta, type RouteModule, type RouteParams, type RouteRevalidate, type RouteStateResult, type RouteTreeNode, type SerializedRouteError, type ShellModule, type ShellProps, type StartAppOptions, Suspense, type TimeRevalidatePolicy, applyDefaultSecurityHeaders, buildPathFromSegments, defineApp, forwardRef, group, handlePrachtRequest, initClientRouter, lazy, matchApiRoute, matchAppRoute, prerenderApp, readHydrationState, resolveApiRoutes, resolveApp, route, startApp, timeRevalidate, useIsHydrated, useLocation, useNavigate, useParams, useRevalidate, useRouteData };
|
package/dist/index.mjs
CHANGED
|
@@ -119,7 +119,11 @@ function matchRouteSegments(routeSegments, targetSegments) {
|
|
|
119
119
|
while (routeIndex < routeSegments.length) {
|
|
120
120
|
const currentSegment = routeSegments[routeIndex];
|
|
121
121
|
if (currentSegment.type === "catchall") {
|
|
122
|
-
|
|
122
|
+
try {
|
|
123
|
+
params[currentSegment.name] = targetSegments.slice(targetIndex).map(decodeURIComponent).join("/");
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
123
127
|
return params;
|
|
124
128
|
}
|
|
125
129
|
const targetSegment = targetSegments[targetIndex];
|
|
@@ -142,6 +146,10 @@ function parseRouteSegments(path) {
|
|
|
142
146
|
type: "catchall",
|
|
143
147
|
name: "*"
|
|
144
148
|
};
|
|
149
|
+
if (segment.startsWith(":") && segment.endsWith("*")) return {
|
|
150
|
+
type: "catchall",
|
|
151
|
+
name: segment.slice(1, -1) || "*"
|
|
152
|
+
};
|
|
145
153
|
if (segment.startsWith(":")) return {
|
|
146
154
|
type: "param",
|
|
147
155
|
name: segment.slice(1)
|
|
@@ -172,7 +180,7 @@ function buildPathFromSegments(segments, params) {
|
|
|
172
180
|
return normalizeRoutePath("/" + segments.map((segment) => {
|
|
173
181
|
if (segment.type === "static") return segment.value;
|
|
174
182
|
if (segment.type === "param") return encodeURIComponent(params[segment.name] ?? "");
|
|
175
|
-
return (params["*"] ?? "").split("/").map((part) => encodeCatchAllSegment(part)).join("/");
|
|
183
|
+
return (params[segment.name] ?? params["*"] ?? "").split("/").map((part) => encodeCatchAllSegment(part)).join("/");
|
|
176
184
|
}).join("/"));
|
|
177
185
|
}
|
|
178
186
|
/**
|
|
@@ -530,7 +538,8 @@ async function fetchPrachtRouteState(url, options) {
|
|
|
530
538
|
[ROUTE_STATE_REQUEST_HEADER]: "1",
|
|
531
539
|
"Cache-Control": "no-cache"
|
|
532
540
|
},
|
|
533
|
-
redirect: "manual"
|
|
541
|
+
redirect: "manual",
|
|
542
|
+
signal: options?.signal
|
|
534
543
|
});
|
|
535
544
|
if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) return {
|
|
536
545
|
location: response.headers.get("location") ?? url,
|
|
@@ -572,6 +581,153 @@ async function navigateToClientLocation(location, options) {
|
|
|
572
581
|
window.location.href = targetUrl.toString();
|
|
573
582
|
}
|
|
574
583
|
//#endregion
|
|
584
|
+
//#region src/prefetch.ts
|
|
585
|
+
const CACHE_TTL_MS = 3e4;
|
|
586
|
+
const MAX_PREFETCH_CACHE_ENTRIES = 100;
|
|
587
|
+
const MAX_MATCH_CACHE_ENTRIES = 250;
|
|
588
|
+
const prefetchCache = /* @__PURE__ */ new Map();
|
|
589
|
+
function clearPrefetchCache() {
|
|
590
|
+
prefetchCache.clear();
|
|
591
|
+
}
|
|
592
|
+
function getCachedRouteState(url) {
|
|
593
|
+
const entry = prefetchCache.get(url);
|
|
594
|
+
if (!entry) return null;
|
|
595
|
+
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
|
|
596
|
+
prefetchCache.delete(url);
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
prefetchCache.delete(url);
|
|
600
|
+
prefetchCache.set(url, entry);
|
|
601
|
+
return entry.promise;
|
|
602
|
+
}
|
|
603
|
+
function prefetchRouteState(url) {
|
|
604
|
+
const cached = getCachedRouteState(url);
|
|
605
|
+
if (cached) return cached;
|
|
606
|
+
sweepPrefetchCache();
|
|
607
|
+
const promise = fetchPrachtRouteState(url);
|
|
608
|
+
prefetchCache.set(url, {
|
|
609
|
+
promise,
|
|
610
|
+
timestamp: Date.now()
|
|
611
|
+
});
|
|
612
|
+
trimMapToSize(prefetchCache, MAX_PREFETCH_CACHE_ENTRIES);
|
|
613
|
+
return promise;
|
|
614
|
+
}
|
|
615
|
+
function setupPrefetching(app, warmModules) {
|
|
616
|
+
let hoverTimer = null;
|
|
617
|
+
const observedViewportAnchors = /* @__PURE__ */ new WeakSet();
|
|
618
|
+
const matchCache = /* @__PURE__ */ new Map();
|
|
619
|
+
function getRoutePathname(url) {
|
|
620
|
+
try {
|
|
621
|
+
return new URL(url, window.location.origin).pathname;
|
|
622
|
+
} catch {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
function getInternalHref(anchor) {
|
|
627
|
+
const href = anchor.getAttribute("href");
|
|
628
|
+
if (!href || href.startsWith("#")) return null;
|
|
629
|
+
let url;
|
|
630
|
+
try {
|
|
631
|
+
url = new URL(href, window.location.origin);
|
|
632
|
+
} catch {
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
if (url.origin !== window.location.origin) return null;
|
|
636
|
+
return url.pathname + url.search;
|
|
637
|
+
}
|
|
638
|
+
function getMatchEntry(href) {
|
|
639
|
+
const cached = matchCache.get(href);
|
|
640
|
+
if (cached) {
|
|
641
|
+
matchCache.delete(href);
|
|
642
|
+
matchCache.set(href, cached);
|
|
643
|
+
return cached;
|
|
644
|
+
}
|
|
645
|
+
const routePathname = getRoutePathname(href);
|
|
646
|
+
const match = routePathname ? matchAppRoute(app, routePathname) ?? null : null;
|
|
647
|
+
const entry = {
|
|
648
|
+
match,
|
|
649
|
+
strategy: match ? match.route.prefetch ?? "intent" : "none"
|
|
650
|
+
};
|
|
651
|
+
matchCache.set(href, entry);
|
|
652
|
+
trimMapToSize(matchCache, MAX_MATCH_CACHE_ENTRIES);
|
|
653
|
+
return entry;
|
|
654
|
+
}
|
|
655
|
+
function prefetchHref(href) {
|
|
656
|
+
prefetchRouteState(href);
|
|
657
|
+
if (!warmModules) return;
|
|
658
|
+
const match = getMatchEntry(href).match;
|
|
659
|
+
if (match) warmModules(match);
|
|
660
|
+
}
|
|
661
|
+
document.addEventListener("mouseenter", (e) => {
|
|
662
|
+
const anchor = e.target.closest?.("a");
|
|
663
|
+
if (!anchor) return;
|
|
664
|
+
const href = getInternalHref(anchor);
|
|
665
|
+
if (!href) return;
|
|
666
|
+
const strategy = getMatchEntry(href).strategy;
|
|
667
|
+
if (strategy !== "hover" && strategy !== "intent") return;
|
|
668
|
+
if (hoverTimer) clearTimeout(hoverTimer);
|
|
669
|
+
hoverTimer = setTimeout(() => {
|
|
670
|
+
prefetchHref(href);
|
|
671
|
+
}, 50);
|
|
672
|
+
}, true);
|
|
673
|
+
document.addEventListener("mouseleave", (e) => {
|
|
674
|
+
if (!e.target.closest?.("a")) return;
|
|
675
|
+
if (hoverTimer) {
|
|
676
|
+
clearTimeout(hoverTimer);
|
|
677
|
+
hoverTimer = null;
|
|
678
|
+
}
|
|
679
|
+
}, true);
|
|
680
|
+
document.addEventListener("focusin", (e) => {
|
|
681
|
+
const anchor = e.target.closest?.("a");
|
|
682
|
+
if (!anchor) return;
|
|
683
|
+
const href = getInternalHref(anchor);
|
|
684
|
+
if (!href) return;
|
|
685
|
+
const strategy = getMatchEntry(href).strategy;
|
|
686
|
+
if (strategy !== "hover" && strategy !== "intent") return;
|
|
687
|
+
prefetchHref(href);
|
|
688
|
+
}, true);
|
|
689
|
+
if (typeof IntersectionObserver === "undefined") return;
|
|
690
|
+
const observer = new IntersectionObserver((entries) => {
|
|
691
|
+
for (const entry of entries) {
|
|
692
|
+
if (!entry.isIntersecting) continue;
|
|
693
|
+
const anchor = entry.target;
|
|
694
|
+
const href = getInternalHref(anchor);
|
|
695
|
+
if (!href) continue;
|
|
696
|
+
prefetchHref(href);
|
|
697
|
+
observer.unobserve(anchor);
|
|
698
|
+
}
|
|
699
|
+
}, { rootMargin: "200px" });
|
|
700
|
+
function observeAnchor(anchor) {
|
|
701
|
+
if (observedViewportAnchors.has(anchor)) return;
|
|
702
|
+
const href = getInternalHref(anchor);
|
|
703
|
+
if (!href) return;
|
|
704
|
+
if (getMatchEntry(href).strategy !== "viewport") return;
|
|
705
|
+
observedViewportAnchors.add(anchor);
|
|
706
|
+
observer.observe(anchor);
|
|
707
|
+
}
|
|
708
|
+
function observeViewportLinks(root) {
|
|
709
|
+
if (root instanceof HTMLAnchorElement) observeAnchor(root);
|
|
710
|
+
for (const anchor of root.querySelectorAll("a[href]")) observeAnchor(anchor);
|
|
711
|
+
}
|
|
712
|
+
observeViewportLinks(document.body);
|
|
713
|
+
new MutationObserver((records) => {
|
|
714
|
+
for (const record of records) for (const node of record.addedNodes) if (node instanceof HTMLElement || node instanceof DocumentFragment) observeViewportLinks(node);
|
|
715
|
+
}).observe(document.body, {
|
|
716
|
+
childList: true,
|
|
717
|
+
subtree: true
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
function sweepPrefetchCache(now = Date.now()) {
|
|
721
|
+
for (const [url, entry] of prefetchCache) if (now - entry.timestamp > CACHE_TTL_MS) prefetchCache.delete(url);
|
|
722
|
+
}
|
|
723
|
+
function trimMapToSize(map, maxEntries) {
|
|
724
|
+
while (map.size > maxEntries) {
|
|
725
|
+
const first = map.keys().next();
|
|
726
|
+
if (first.done) return;
|
|
727
|
+
map.delete(first.value);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
//#endregion
|
|
575
731
|
//#region src/runtime-hooks.ts
|
|
576
732
|
const RouteDataContext = createContext(void 0);
|
|
577
733
|
function PrachtRuntimeProvider({ children, data, params = EMPTY_ROUTE_PARAMS, routeId, stateVersion = 0, url }) {
|
|
@@ -664,6 +820,7 @@ function Form(props) {
|
|
|
664
820
|
const formMethod = (method ?? form.method ?? "post").toUpperCase();
|
|
665
821
|
if (SAFE_METHODS.has(formMethod)) return;
|
|
666
822
|
event.preventDefault();
|
|
823
|
+
clearPrefetchCache();
|
|
667
824
|
const response = await fetch(props.action ?? form.action, {
|
|
668
825
|
method: formMethod,
|
|
669
826
|
body: new FormData(form),
|
|
@@ -693,13 +850,75 @@ function escapeHtml(str) {
|
|
|
693
850
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
694
851
|
}
|
|
695
852
|
function serializeJsonForHtml(value) {
|
|
696
|
-
return JSON.stringify(value)
|
|
853
|
+
return escapeScriptText(JSON.stringify(value) ?? "null");
|
|
854
|
+
}
|
|
855
|
+
function escapeScriptText(value) {
|
|
856
|
+
return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
857
|
+
}
|
|
858
|
+
const SAFE_ATTRIBUTE_NAME_RE = /^[A-Za-z_:][A-Za-z0-9:._-]*$/;
|
|
859
|
+
const GLOBAL_HEAD_ATTRIBUTE_PREFIXES = ["data-", "aria-"];
|
|
860
|
+
const META_ATTRIBUTES = new Set([
|
|
861
|
+
"charset",
|
|
862
|
+
"content",
|
|
863
|
+
"http-equiv",
|
|
864
|
+
"itemprop",
|
|
865
|
+
"media",
|
|
866
|
+
"name",
|
|
867
|
+
"property"
|
|
868
|
+
]);
|
|
869
|
+
const LINK_ATTRIBUTES = new Set([
|
|
870
|
+
"as",
|
|
871
|
+
"blocking",
|
|
872
|
+
"color",
|
|
873
|
+
"crossorigin",
|
|
874
|
+
"disabled",
|
|
875
|
+
"fetchpriority",
|
|
876
|
+
"href",
|
|
877
|
+
"hreflang",
|
|
878
|
+
"imagesizes",
|
|
879
|
+
"imagesrcset",
|
|
880
|
+
"integrity",
|
|
881
|
+
"media",
|
|
882
|
+
"referrerpolicy",
|
|
883
|
+
"rel",
|
|
884
|
+
"sizes",
|
|
885
|
+
"title",
|
|
886
|
+
"type"
|
|
887
|
+
]);
|
|
888
|
+
const SCRIPT_ATTRIBUTES = new Set([
|
|
889
|
+
"async",
|
|
890
|
+
"blocking",
|
|
891
|
+
"class",
|
|
892
|
+
"crossorigin",
|
|
893
|
+
"defer",
|
|
894
|
+
"fetchpriority",
|
|
895
|
+
"id",
|
|
896
|
+
"integrity",
|
|
897
|
+
"nomodule",
|
|
898
|
+
"nonce",
|
|
899
|
+
"referrerpolicy",
|
|
900
|
+
"src",
|
|
901
|
+
"type"
|
|
902
|
+
]);
|
|
903
|
+
function renderAttributes(attributes, allowedAttributes) {
|
|
904
|
+
return Object.entries(attributes).filter(([key, value]) => isAllowedHeadAttribute(key, value, allowedAttributes)).map(([key, value]) => `${key}="${escapeHtml(value ?? "")}"`).join(" ");
|
|
905
|
+
}
|
|
906
|
+
function isAllowedHeadAttribute(key, value, allowedAttributes) {
|
|
907
|
+
if (key === "children" || typeof value === "undefined" || !SAFE_ATTRIBUTE_NAME_RE.test(key)) return false;
|
|
908
|
+
const normalized = key.toLowerCase();
|
|
909
|
+
if (normalized.startsWith("on")) return false;
|
|
910
|
+
return allowedAttributes.has(normalized) || GLOBAL_HEAD_ATTRIBUTE_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
|
697
911
|
}
|
|
698
912
|
function buildHtmlDocument(options) {
|
|
699
913
|
const { head, body, hydrationState, clientEntryUrl, cssUrls = [], modulePreloadUrls = [], routeStatePreloadUrl } = options;
|
|
700
914
|
const titleTag = head.title ? `<title>${escapeHtml(head.title)}</title>` : "";
|
|
701
|
-
const metaTags = (head.meta ?? []).map((m) =>
|
|
702
|
-
const linkTags = (head.link ?? []).map((l) =>
|
|
915
|
+
const metaTags = (head.meta ?? []).map((m) => renderAttributes(m, META_ATTRIBUTES)).filter(Boolean).map((attrs) => `<meta ${attrs}>`).join("\n ");
|
|
916
|
+
const linkTags = (head.link ?? []).map((l) => renderAttributes(l, LINK_ATTRIBUTES)).filter(Boolean).map((attrs) => `<link ${attrs}>`).join("\n ");
|
|
917
|
+
const scriptTags = (head.script ?? []).map((script) => {
|
|
918
|
+
const attrs = renderAttributes(script, SCRIPT_ATTRIBUTES);
|
|
919
|
+
const children = script.children ? escapeScriptText(script.children) : "";
|
|
920
|
+
return attrs ? `<script ${attrs}>${children}<\/script>` : `<script>${children}<\/script>`;
|
|
921
|
+
}).join("\n ");
|
|
703
922
|
const cssTags = cssUrls.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join("\n ");
|
|
704
923
|
const modulePreloadTags = modulePreloadUrls.map((url) => `<link rel="modulepreload" href="${escapeHtml(url)}">`).join("\n ");
|
|
705
924
|
const routeStatePreloadTag = routeStatePreloadUrl ? `<link rel="preload" as="fetch" href="${escapeHtml(routeStatePreloadUrl)}" crossorigin="anonymous">` : "";
|
|
@@ -712,6 +931,7 @@ function buildHtmlDocument(options) {
|
|
|
712
931
|
${titleTag}
|
|
713
932
|
${metaTags}
|
|
714
933
|
${linkTags}
|
|
934
|
+
${scriptTags}
|
|
715
935
|
${cssTags}
|
|
716
936
|
${modulePreloadTags}
|
|
717
937
|
${routeStatePreloadTag}
|
|
@@ -870,7 +1090,8 @@ async function mergeHeadMetadata(shellModule, routeModule, routeArgs, data) {
|
|
|
870
1090
|
title: routeHead.title ?? shellHead.title,
|
|
871
1091
|
lang: routeHead.lang ?? shellHead.lang,
|
|
872
1092
|
meta: [...shellHead.meta ?? [], ...routeHead.meta ?? []],
|
|
873
|
-
link: [...shellHead.link ?? [], ...routeHead.link ?? []]
|
|
1093
|
+
link: [...shellHead.link ?? [], ...routeHead.link ?? []],
|
|
1094
|
+
script: [...shellHead.script ?? [], ...routeHead.script ?? []]
|
|
874
1095
|
};
|
|
875
1096
|
}
|
|
876
1097
|
async function mergeDocumentHeaders(shellModule, routeModule, routeArgs, data) {
|
|
@@ -1034,25 +1255,25 @@ function markdownResponse(source) {
|
|
|
1034
1255
|
}
|
|
1035
1256
|
//#endregion
|
|
1036
1257
|
//#region src/runtime.ts
|
|
1037
|
-
const
|
|
1258
|
+
const SAME_ORIGIN_FETCH_SITE = "same-origin";
|
|
1038
1259
|
/**
|
|
1039
1260
|
* Stricter variant of first-party detection used for CSRF protection on
|
|
1040
|
-
* state-changing API requests.
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
* explicitly or pre-flight via middleware.
|
|
1261
|
+
* state-changing API requests. It rejects any browser signal that points
|
|
1262
|
+
* outside this exact origin — a cross-origin form POST will send `Origin`
|
|
1263
|
+
* from the attacker, and `Sec-Fetch-Site: same-site` is not enough because
|
|
1264
|
+
* sibling subdomains can be attacker-controlled. Requests with no browser
|
|
1265
|
+
* provenance headers are treated as non-browser callers.
|
|
1046
1266
|
*/
|
|
1047
1267
|
function isSameOriginMutation(request, url) {
|
|
1048
1268
|
const site = request.headers.get("sec-fetch-site");
|
|
1049
|
-
if (site) return
|
|
1269
|
+
if (site && site !== SAME_ORIGIN_FETCH_SITE) return false;
|
|
1050
1270
|
const origin = request.headers.get("origin");
|
|
1051
1271
|
if (origin) try {
|
|
1052
1272
|
return new URL(origin).origin === url.origin;
|
|
1053
1273
|
} catch {
|
|
1054
1274
|
return false;
|
|
1055
1275
|
}
|
|
1276
|
+
if (site === SAME_ORIGIN_FETCH_SITE) return true;
|
|
1056
1277
|
const referer = request.headers.get("referer");
|
|
1057
1278
|
if (referer) try {
|
|
1058
1279
|
return new URL(referer).origin === url.origin;
|
|
@@ -1067,27 +1288,31 @@ function isSameOriginMutation(request, url) {
|
|
|
1067
1288
|
* otherwise reachable via any cross-origin `<a href>` / redirect.
|
|
1068
1289
|
*
|
|
1069
1290
|
* Accepts a request as first-party when:
|
|
1070
|
-
* - Sec-Fetch-Site is `same-origin`
|
|
1291
|
+
* - Sec-Fetch-Site is `same-origin` (modern browsers),
|
|
1071
1292
|
* - OR Sec-Fetch-Site is absent AND the Origin header matches the
|
|
1072
1293
|
* request URL's origin (older clients that still send Origin),
|
|
1073
|
-
* - OR
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1078
|
-
* or `none` (for user-typed URLs Sec-Fetch-Site: none, Referer absent,
|
|
1079
|
-
* Origin absent — handled by the "no headers → allow" branch since that
|
|
1080
|
-
* matches a first-party typed URL too).
|
|
1294
|
+
* - OR Sec-Fetch-Site/Origin are absent AND Referer matches the request
|
|
1295
|
+
* URL's origin,
|
|
1296
|
+
* - OR no Origin/Sec-Fetch-Site/Referer is present (non-browser clients like
|
|
1297
|
+
* curl — CSRF is not the threat model there; blocking would break
|
|
1298
|
+
* tests and CLIs).
|
|
1081
1299
|
*/
|
|
1082
1300
|
function isFirstPartyFetch(request) {
|
|
1083
1301
|
const site = request.headers.get("sec-fetch-site");
|
|
1084
|
-
if (site) return
|
|
1302
|
+
if (site && site !== SAME_ORIGIN_FETCH_SITE) return false;
|
|
1085
1303
|
const origin = request.headers.get("origin");
|
|
1086
1304
|
if (origin) try {
|
|
1087
1305
|
return new URL(origin).origin === new URL(request.url).origin;
|
|
1088
1306
|
} catch {
|
|
1089
1307
|
return false;
|
|
1090
1308
|
}
|
|
1309
|
+
if (site === SAME_ORIGIN_FETCH_SITE) return true;
|
|
1310
|
+
const referer = request.headers.get("referer");
|
|
1311
|
+
if (referer) try {
|
|
1312
|
+
return new URL(referer).origin === new URL(request.url).origin;
|
|
1313
|
+
} catch {
|
|
1314
|
+
return false;
|
|
1315
|
+
}
|
|
1091
1316
|
return true;
|
|
1092
1317
|
}
|
|
1093
1318
|
async function handlePrachtRequest(options) {
|
|
@@ -1318,9 +1543,10 @@ async function prerenderApp(options) {
|
|
|
1318
1543
|
revalidate: route.revalidate
|
|
1319
1544
|
});
|
|
1320
1545
|
}
|
|
1321
|
-
const
|
|
1322
|
-
|
|
1323
|
-
|
|
1546
|
+
const concurrency = options.concurrency ?? 10;
|
|
1547
|
+
if (!Number.isInteger(concurrency) || concurrency <= 0) throw new Error("prerenderApp({ concurrency }) expects a positive integer.");
|
|
1548
|
+
for (let i = 0; i < work.length; i += concurrency) {
|
|
1549
|
+
const batch = work.slice(i, i + concurrency);
|
|
1324
1550
|
const batchResults = await Promise.all(batch.map(async (item) => {
|
|
1325
1551
|
const url = new URL(item.pathname, "http://localhost");
|
|
1326
1552
|
const request = new Request(url, { method: "GET" });
|
|
@@ -1369,127 +1595,70 @@ async function collectSSGPaths(route, registry) {
|
|
|
1369
1595
|
return (await routeModule.getStaticPaths()).map((params) => buildPathFromSegments(route.segments, params));
|
|
1370
1596
|
}
|
|
1371
1597
|
//#endregion
|
|
1372
|
-
//#region src/
|
|
1373
|
-
const
|
|
1374
|
-
|
|
1375
|
-
function
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
}
|
|
1384
|
-
function prefetchRouteState(url) {
|
|
1385
|
-
const cached = getCachedRouteState(url);
|
|
1386
|
-
if (cached) return cached;
|
|
1387
|
-
const promise = fetchPrachtRouteState(url);
|
|
1388
|
-
prefetchCache.set(url, {
|
|
1389
|
-
promise,
|
|
1390
|
-
timestamp: Date.now()
|
|
1391
|
-
});
|
|
1392
|
-
return promise;
|
|
1598
|
+
//#region src/hydration-mismatch.ts
|
|
1599
|
+
const HYDRATION_BANNER_ID = "__pracht_hydration_mismatch__";
|
|
1600
|
+
let installed = false;
|
|
1601
|
+
function installHydrationMismatchWarning() {
|
|
1602
|
+
if (installed) return;
|
|
1603
|
+
installed = true;
|
|
1604
|
+
const prev = options.__m;
|
|
1605
|
+
options.__m = function(vnode) {
|
|
1606
|
+
appendHydrationWarning(vnode);
|
|
1607
|
+
if (prev) prev(vnode);
|
|
1608
|
+
};
|
|
1393
1609
|
}
|
|
1394
|
-
function
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
if (!href || href.startsWith("#")) return null;
|
|
1406
|
-
let url;
|
|
1407
|
-
try {
|
|
1408
|
-
url = new URL(href, window.location.origin);
|
|
1409
|
-
} catch {
|
|
1410
|
-
return null;
|
|
1610
|
+
function appendHydrationWarning(vnode) {
|
|
1611
|
+
if (typeof document === "undefined") return;
|
|
1612
|
+
const componentName = getVNodeName(vnode);
|
|
1613
|
+
let banner = document.getElementById(HYDRATION_BANNER_ID);
|
|
1614
|
+
const message = `Hydration mismatch detected on <${componentName}>. The server-rendered HTML did not match the client.`;
|
|
1615
|
+
if (banner) {
|
|
1616
|
+
const list = banner.querySelector(`[data-pracht-mismatch-list]`);
|
|
1617
|
+
if (list) {
|
|
1618
|
+
const item = document.createElement("li");
|
|
1619
|
+
item.textContent = message;
|
|
1620
|
+
list.appendChild(item);
|
|
1411
1621
|
}
|
|
1412
|
-
|
|
1413
|
-
return url.pathname + url.search;
|
|
1622
|
+
return;
|
|
1414
1623
|
}
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1624
|
+
banner = document.createElement("div");
|
|
1625
|
+
banner.id = HYDRATION_BANNER_ID;
|
|
1626
|
+
banner.setAttribute("role", "alert");
|
|
1627
|
+
banner.style.cssText = [
|
|
1628
|
+
"position:fixed",
|
|
1629
|
+
"top:0",
|
|
1630
|
+
"left:0",
|
|
1631
|
+
"right:0",
|
|
1632
|
+
"z-index:2147483647",
|
|
1633
|
+
"background:#1a1a2e",
|
|
1634
|
+
"color:#ff6b6b",
|
|
1635
|
+
"padding:12px 16px",
|
|
1636
|
+
"font:12px/1.5 ui-monospace,Menlo,Consolas,monospace",
|
|
1637
|
+
"border-bottom:2px solid #e74c3c",
|
|
1638
|
+
"box-shadow:0 2px 8px rgba(0,0,0,0.3)"
|
|
1639
|
+
].join(";");
|
|
1640
|
+
const title = document.createElement("strong");
|
|
1641
|
+
title.textContent = "pracht: hydration mismatch";
|
|
1642
|
+
title.style.cssText = "display:block;margin-bottom:4px;color:#fff";
|
|
1643
|
+
banner.appendChild(title);
|
|
1644
|
+
const list = document.createElement("ul");
|
|
1645
|
+
list.setAttribute("data-pracht-mismatch-list", "");
|
|
1646
|
+
list.style.cssText = "margin:0;padding-left:18px";
|
|
1647
|
+
const item = document.createElement("li");
|
|
1648
|
+
item.textContent = message;
|
|
1649
|
+
list.appendChild(item);
|
|
1650
|
+
banner.appendChild(list);
|
|
1651
|
+
document.body.appendChild(banner);
|
|
1652
|
+
}
|
|
1653
|
+
function getVNodeName(vnode) {
|
|
1654
|
+
if (!vnode) return "Unknown";
|
|
1655
|
+
const type = vnode.type;
|
|
1656
|
+
if (typeof type === "string") return type;
|
|
1657
|
+
if (typeof type === "function") {
|
|
1658
|
+
const fn = type;
|
|
1659
|
+
return fn.displayName || fn.name || "Component";
|
|
1422
1660
|
}
|
|
1423
|
-
|
|
1424
|
-
const anchor = e.target.closest?.("a");
|
|
1425
|
-
if (!anchor) return;
|
|
1426
|
-
const href = getInternalHref(anchor);
|
|
1427
|
-
if (!href) return;
|
|
1428
|
-
const strategy = getPrefetchStrategy(href);
|
|
1429
|
-
if (strategy !== "hover" && strategy !== "intent") return;
|
|
1430
|
-
if (hoverTimer) clearTimeout(hoverTimer);
|
|
1431
|
-
hoverTimer = setTimeout(() => {
|
|
1432
|
-
prefetchRouteState(href);
|
|
1433
|
-
if (warmModules) {
|
|
1434
|
-
const pathname = getRoutePathname(href);
|
|
1435
|
-
const m = pathname ? matchAppRoute(app, pathname) : void 0;
|
|
1436
|
-
if (m) warmModules(m);
|
|
1437
|
-
}
|
|
1438
|
-
}, 50);
|
|
1439
|
-
}, true);
|
|
1440
|
-
document.addEventListener("mouseleave", (e) => {
|
|
1441
|
-
if (!e.target.closest?.("a")) return;
|
|
1442
|
-
if (hoverTimer) {
|
|
1443
|
-
clearTimeout(hoverTimer);
|
|
1444
|
-
hoverTimer = null;
|
|
1445
|
-
}
|
|
1446
|
-
}, true);
|
|
1447
|
-
document.addEventListener("focusin", (e) => {
|
|
1448
|
-
const anchor = e.target.closest?.("a");
|
|
1449
|
-
if (!anchor) return;
|
|
1450
|
-
const href = getInternalHref(anchor);
|
|
1451
|
-
if (!href) return;
|
|
1452
|
-
const strategy = getPrefetchStrategy(href);
|
|
1453
|
-
if (strategy !== "hover" && strategy !== "intent") return;
|
|
1454
|
-
prefetchRouteState(href);
|
|
1455
|
-
if (warmModules) {
|
|
1456
|
-
const pathname = getRoutePathname(href);
|
|
1457
|
-
const m = pathname ? matchAppRoute(app, pathname) : void 0;
|
|
1458
|
-
if (m) warmModules(m);
|
|
1459
|
-
}
|
|
1460
|
-
}, true);
|
|
1461
|
-
if (typeof IntersectionObserver === "undefined") return;
|
|
1462
|
-
const observer = new IntersectionObserver((entries) => {
|
|
1463
|
-
for (const entry of entries) {
|
|
1464
|
-
if (!entry.isIntersecting) continue;
|
|
1465
|
-
const anchor = entry.target;
|
|
1466
|
-
const href = getInternalHref(anchor);
|
|
1467
|
-
if (!href) continue;
|
|
1468
|
-
prefetchRouteState(href);
|
|
1469
|
-
if (warmModules) {
|
|
1470
|
-
const pathname = getRoutePathname(href);
|
|
1471
|
-
const m = pathname ? matchAppRoute(app, pathname) : void 0;
|
|
1472
|
-
if (m) warmModules(m);
|
|
1473
|
-
}
|
|
1474
|
-
observer.unobserve(anchor);
|
|
1475
|
-
}
|
|
1476
|
-
}, { rootMargin: "200px" });
|
|
1477
|
-
function observeViewportLinks() {
|
|
1478
|
-
const anchors = document.querySelectorAll("a[href]");
|
|
1479
|
-
for (const anchor of anchors) {
|
|
1480
|
-
const href = getInternalHref(anchor);
|
|
1481
|
-
if (!href) continue;
|
|
1482
|
-
if (getPrefetchStrategy(href) !== "viewport") continue;
|
|
1483
|
-
observer.observe(anchor);
|
|
1484
|
-
}
|
|
1485
|
-
}
|
|
1486
|
-
observeViewportLinks();
|
|
1487
|
-
new MutationObserver(() => {
|
|
1488
|
-
observeViewportLinks();
|
|
1489
|
-
}).observe(document.body, {
|
|
1490
|
-
childList: true,
|
|
1491
|
-
subtree: true
|
|
1492
|
-
});
|
|
1661
|
+
return "Unknown";
|
|
1493
1662
|
}
|
|
1494
1663
|
//#endregion
|
|
1495
1664
|
//#region src/router.ts
|
|
@@ -1499,6 +1668,7 @@ function useNavigate() {
|
|
|
1499
1668
|
}
|
|
1500
1669
|
async function initClientRouter(options) {
|
|
1501
1670
|
const { app, routeModules, shellModules, root, findModuleKey } = options;
|
|
1671
|
+
if (import.meta.env?.DEV) installHydrationMismatchWarning();
|
|
1502
1672
|
const moduleCache = /* @__PURE__ */ new Map();
|
|
1503
1673
|
function loadModule(modules, key) {
|
|
1504
1674
|
let cached = moduleCache.get(key);
|
|
@@ -1521,6 +1691,8 @@ async function initClientRouter(options) {
|
|
|
1521
1691
|
}
|
|
1522
1692
|
let updateRouteState = null;
|
|
1523
1693
|
let routeStateVersion = 0;
|
|
1694
|
+
let latestNavigationId = 0;
|
|
1695
|
+
let activeNavigationAbort = null;
|
|
1524
1696
|
function RouterRoot({ initialState }) {
|
|
1525
1697
|
const [routeState, setRouteState] = useState(initialState);
|
|
1526
1698
|
updateRouteState = setRouteState;
|
|
@@ -1607,6 +1779,10 @@ async function initClientRouter(options) {
|
|
|
1607
1779
|
};
|
|
1608
1780
|
}
|
|
1609
1781
|
async function navigate(to, opts) {
|
|
1782
|
+
const navigationId = ++latestNavigationId;
|
|
1783
|
+
activeNavigationAbort?.abort();
|
|
1784
|
+
const abortController = new AbortController();
|
|
1785
|
+
activeNavigationAbort = abortController;
|
|
1610
1786
|
const target = resolveBrowserRouteTarget(to);
|
|
1611
1787
|
if (!target) {
|
|
1612
1788
|
window.location.href = to;
|
|
@@ -1617,7 +1793,7 @@ async function initClientRouter(options) {
|
|
|
1617
1793
|
window.location.href = target.browserUrl;
|
|
1618
1794
|
return;
|
|
1619
1795
|
}
|
|
1620
|
-
const statePromise = getCachedRouteState(target.requestUrl) ?? fetchPrachtRouteState(target.requestUrl);
|
|
1796
|
+
const statePromise = getCachedRouteState(target.requestUrl) ?? fetchPrachtRouteState(target.requestUrl, { signal: abortController.signal });
|
|
1621
1797
|
const routeModPromise = startRouteImport(match);
|
|
1622
1798
|
const shellModPromise = startShellImport(match);
|
|
1623
1799
|
let state = {
|
|
@@ -1626,6 +1802,7 @@ async function initClientRouter(options) {
|
|
|
1626
1802
|
};
|
|
1627
1803
|
try {
|
|
1628
1804
|
const result = await statePromise;
|
|
1805
|
+
if (navigationId !== latestNavigationId) return;
|
|
1629
1806
|
if (result.type === "redirect") {
|
|
1630
1807
|
if (result.location) {
|
|
1631
1808
|
const redirect = resolveRedirectTarget(result.location);
|
|
@@ -1661,12 +1838,15 @@ async function initClientRouter(options) {
|
|
|
1661
1838
|
error: null
|
|
1662
1839
|
};
|
|
1663
1840
|
} catch {
|
|
1841
|
+
if (abortController.signal.aborted || navigationId !== latestNavigationId) return;
|
|
1664
1842
|
window.location.href = target.browserUrl;
|
|
1665
1843
|
return;
|
|
1666
1844
|
}
|
|
1845
|
+
if (navigationId !== latestNavigationId) return;
|
|
1667
1846
|
if (!opts?._popstate) if (opts?.replace) history.replaceState(null, "", target.browserUrl);
|
|
1668
1847
|
else history.pushState(null, "", target.browserUrl);
|
|
1669
1848
|
const routeState = await resolveRouteState(match, state, target.requestUrl, routeModPromise, shellModPromise);
|
|
1849
|
+
if (navigationId !== latestNavigationId) return;
|
|
1670
1850
|
if (routeState) {
|
|
1671
1851
|
applyRouteState(routeState);
|
|
1672
1852
|
window.scrollTo(0, 0);
|