@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 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; `@pracht/core` does not infer this
32
- from environment variables. Debug responses also attach `error.diagnostics`
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 a
82
- * same-origin/same-site fetch (Sec-Fetch-Site) or the request Origin
83
- * matches the request URL's origin. Set to `false` to opt out if you
84
- * build your own CSRF protection into middleware.
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?: Array<Record<string, string>>;
162
- link?: Array<Record<string, string>>;
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
- params[currentSegment.name] = targetSegments.slice(targetIndex).join("/");
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
694
851
  }
695
852
  function serializeJsonForHtml(value) {
696
- return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
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) => `<meta ${Object.entries(m).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ")}>`).join("\n ");
702
- const linkTags = (head.link ?? []).map((l) => `<link ${Object.entries(l).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ")}>`).join("\n ");
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 FIRST_PARTY_FETCH_SITES = new Set(["same-origin", "same-site"]);
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. Unlike `isFirstPartyFetch`, this *only*
1041
- * accepts explicit positive evidence that the request came from this
1042
- * origin a cross-origin form POST will send `Origin` from the
1043
- * attacker, and a missing `Origin` on POST is unusual enough to block.
1044
- * Non-browser callers (curl, server-to-server) should set the header
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 FIRST_PARTY_FETCH_SITES.has(site);
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` or `same-site` (modern browsers),
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 no Origin/Sec-Fetch-Site is present AND there is no Referer
1074
- * (non-browser clients like curl — CSRF is not the threat model
1075
- * there; blocking would break tests and CLIs).
1076
- *
1077
- * Cross-origin browser navigations set Sec-Fetch-Site to `cross-site`
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 FIRST_PARTY_FETCH_SITES.has(site);
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 CONCURRENCY = 10;
1322
- for (let i = 0; i < work.length; i += CONCURRENCY) {
1323
- const batch = work.slice(i, i + CONCURRENCY);
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/prefetch.ts
1373
- const CACHE_TTL_MS = 3e4;
1374
- const prefetchCache = /* @__PURE__ */ new Map();
1375
- function getCachedRouteState(url) {
1376
- const entry = prefetchCache.get(url);
1377
- if (!entry) return null;
1378
- if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
1379
- prefetchCache.delete(url);
1380
- return null;
1381
- }
1382
- return entry.promise;
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 setupPrefetching(app, warmModules) {
1395
- let hoverTimer = null;
1396
- function getRoutePathname(url) {
1397
- try {
1398
- return new URL(url, window.location.origin).pathname;
1399
- } catch {
1400
- return null;
1401
- }
1402
- }
1403
- function getInternalHref(anchor) {
1404
- const href = anchor.getAttribute("href");
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
- if (url.origin !== window.location.origin) return null;
1413
- return url.pathname + url.search;
1622
+ return;
1414
1623
  }
1415
- function getPrefetchStrategy(pathname) {
1416
- const routePathname = getRoutePathname(pathname);
1417
- if (!routePathname) return "none";
1418
- const match = matchAppRoute(app, routePathname);
1419
- if (!match) return "none";
1420
- if (match.route.prefetch) return match.route.prefetch;
1421
- return "intent";
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
- document.addEventListener("mouseenter", (e) => {
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pracht/core",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "homepage": "https://github.com/JoviDeCroock/pracht/tree/main/packages/framework",
6
6
  "bugs": {