@pracht/core 0.3.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;
@@ -237,6 +246,7 @@ declare function buildPathFromSegments(segments: RouteSegment[], params: RoutePa
237
246
  *
238
247
  * Example: `"/src/api/health.ts"` → path `/api/health`
239
248
  * `"/src/api/users/[id].ts"` → path `/api/users/:id`
249
+ * `"/src/api/files/[...path].ts"` → path `/api/files/*`
240
250
  * `"/src/api/index.ts"` → path `/api`
241
251
  */
242
252
  declare function resolveApiRoutes(files: string[], apiDir?: string): ResolvedApiRoute[];
@@ -338,6 +348,7 @@ declare function PrachtRuntimeProvider<TData>({
338
348
  }>;
339
349
  declare function startApp<TData = unknown>(options?: StartAppOptions<TData>): TData | undefined;
340
350
  declare function readHydrationState<TData = unknown>(): PrachtHydrationState<TData> | undefined;
351
+ declare function useRouteData<TLoader extends LoaderLike>(): LoaderData<TLoader>;
341
352
  declare function useRouteData<TData = unknown>(): TData;
342
353
  declare function useLocation(): Location;
343
354
  declare function useParams(): RouteParams;
@@ -394,6 +405,8 @@ interface PrerenderAppOptions {
394
405
  cssManifest?: Record<string, string[]>;
395
406
  /** Per-source-file JS map produced by the vite plugin for modulepreload hints. */
396
407
  jsManifest?: Record<string, string[]>;
408
+ /** Maximum number of pages rendered concurrently. Defaults to 10. */
409
+ concurrency?: number;
397
410
  }
398
411
  declare function prerenderApp(options: PrerenderAppOptions): Promise<PrerenderResult[]>;
399
412
  declare function prerenderApp(options: PrerenderAppOptions & {
@@ -422,4 +435,4 @@ interface InitClientRouterOptions {
422
435
  }
423
436
  declare function initClientRouter(options: InitClientRouterOptions): Promise<void>;
424
437
  //#endregion
425
- 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
  /**
@@ -191,6 +199,7 @@ function encodeCatchAllSegment(part) {
191
199
  *
192
200
  * Example: `"/src/api/health.ts"` → path `/api/health`
193
201
  * `"/src/api/users/[id].ts"` → path `/api/users/:id`
202
+ * `"/src/api/files/[...path].ts"` → path `/api/files/*`
194
203
  * `"/src/api/index.ts"` → path `/api`
195
204
  */
196
205
  function resolveApiRoutes(files, apiDir = "/src/api") {
@@ -200,6 +209,7 @@ function resolveApiRoutes(files, apiDir = "/src/api") {
200
209
  if (relative.startsWith(normalizedDir)) relative = relative.slice(normalizedDir.length);
201
210
  relative = relative.replace(/\.(ts|tsx|js|jsx)$/, "");
202
211
  if (relative.endsWith("/index")) relative = relative.slice(0, -6) || "/";
212
+ relative = relative.replace(/\[\.\.\.[^\]]+\]/g, "*");
203
213
  relative = relative.replace(/\[([^\]]+)\]/g, ":$1");
204
214
  const path = normalizeRoutePath(`/api${relative}`);
205
215
  return {
@@ -528,7 +538,8 @@ async function fetchPrachtRouteState(url, options) {
528
538
  [ROUTE_STATE_REQUEST_HEADER]: "1",
529
539
  "Cache-Control": "no-cache"
530
540
  },
531
- redirect: "manual"
541
+ redirect: "manual",
542
+ signal: options?.signal
532
543
  });
533
544
  if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) return {
534
545
  location: response.headers.get("location") ?? url,
@@ -570,6 +581,153 @@ async function navigateToClientLocation(location, options) {
570
581
  window.location.href = targetUrl.toString();
571
582
  }
572
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
573
731
  //#region src/runtime-hooks.ts
574
732
  const RouteDataContext = createContext(void 0);
575
733
  function PrachtRuntimeProvider({ children, data, params = EMPTY_ROUTE_PARAMS, routeId, stateVersion = 0, url }) {
@@ -662,6 +820,7 @@ function Form(props) {
662
820
  const formMethod = (method ?? form.method ?? "post").toUpperCase();
663
821
  if (SAFE_METHODS.has(formMethod)) return;
664
822
  event.preventDefault();
823
+ clearPrefetchCache();
665
824
  const response = await fetch(props.action ?? form.action, {
666
825
  method: formMethod,
667
826
  body: new FormData(form),
@@ -691,13 +850,75 @@ function escapeHtml(str) {
691
850
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
692
851
  }
693
852
  function serializeJsonForHtml(value) {
694
- 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));
695
911
  }
696
912
  function buildHtmlDocument(options) {
697
913
  const { head, body, hydrationState, clientEntryUrl, cssUrls = [], modulePreloadUrls = [], routeStatePreloadUrl } = options;
698
914
  const titleTag = head.title ? `<title>${escapeHtml(head.title)}</title>` : "";
699
- const metaTags = (head.meta ?? []).map((m) => `<meta ${Object.entries(m).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ")}>`).join("\n ");
700
- 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 ");
701
922
  const cssTags = cssUrls.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join("\n ");
702
923
  const modulePreloadTags = modulePreloadUrls.map((url) => `<link rel="modulepreload" href="${escapeHtml(url)}">`).join("\n ");
703
924
  const routeStatePreloadTag = routeStatePreloadUrl ? `<link rel="preload" as="fetch" href="${escapeHtml(routeStatePreloadUrl)}" crossorigin="anonymous">` : "";
@@ -710,6 +931,7 @@ function buildHtmlDocument(options) {
710
931
  ${titleTag}
711
932
  ${metaTags}
712
933
  ${linkTags}
934
+ ${scriptTags}
713
935
  ${cssTags}
714
936
  ${modulePreloadTags}
715
937
  ${routeStatePreloadTag}
@@ -868,7 +1090,8 @@ async function mergeHeadMetadata(shellModule, routeModule, routeArgs, data) {
868
1090
  title: routeHead.title ?? shellHead.title,
869
1091
  lang: routeHead.lang ?? shellHead.lang,
870
1092
  meta: [...shellHead.meta ?? [], ...routeHead.meta ?? []],
871
- link: [...shellHead.link ?? [], ...routeHead.link ?? []]
1093
+ link: [...shellHead.link ?? [], ...routeHead.link ?? []],
1094
+ script: [...shellHead.script ?? [], ...routeHead.script ?? []]
872
1095
  };
873
1096
  }
874
1097
  async function mergeDocumentHeaders(shellModule, routeModule, routeArgs, data) {
@@ -1032,25 +1255,25 @@ function markdownResponse(source) {
1032
1255
  }
1033
1256
  //#endregion
1034
1257
  //#region src/runtime.ts
1035
- const FIRST_PARTY_FETCH_SITES = new Set(["same-origin", "same-site"]);
1258
+ const SAME_ORIGIN_FETCH_SITE = "same-origin";
1036
1259
  /**
1037
1260
  * Stricter variant of first-party detection used for CSRF protection on
1038
- * state-changing API requests. Unlike `isFirstPartyFetch`, this *only*
1039
- * accepts explicit positive evidence that the request came from this
1040
- * origin a cross-origin form POST will send `Origin` from the
1041
- * attacker, and a missing `Origin` on POST is unusual enough to block.
1042
- * Non-browser callers (curl, server-to-server) should set the header
1043
- * 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.
1044
1266
  */
1045
1267
  function isSameOriginMutation(request, url) {
1046
1268
  const site = request.headers.get("sec-fetch-site");
1047
- if (site) return FIRST_PARTY_FETCH_SITES.has(site);
1269
+ if (site && site !== SAME_ORIGIN_FETCH_SITE) return false;
1048
1270
  const origin = request.headers.get("origin");
1049
1271
  if (origin) try {
1050
1272
  return new URL(origin).origin === url.origin;
1051
1273
  } catch {
1052
1274
  return false;
1053
1275
  }
1276
+ if (site === SAME_ORIGIN_FETCH_SITE) return true;
1054
1277
  const referer = request.headers.get("referer");
1055
1278
  if (referer) try {
1056
1279
  return new URL(referer).origin === url.origin;
@@ -1065,27 +1288,31 @@ function isSameOriginMutation(request, url) {
1065
1288
  * otherwise reachable via any cross-origin `<a href>` / redirect.
1066
1289
  *
1067
1290
  * Accepts a request as first-party when:
1068
- * - Sec-Fetch-Site is `same-origin` or `same-site` (modern browsers),
1291
+ * - Sec-Fetch-Site is `same-origin` (modern browsers),
1069
1292
  * - OR Sec-Fetch-Site is absent AND the Origin header matches the
1070
1293
  * request URL's origin (older clients that still send Origin),
1071
- * - OR no Origin/Sec-Fetch-Site is present AND there is no Referer
1072
- * (non-browser clients like curl — CSRF is not the threat model
1073
- * there; blocking would break tests and CLIs).
1074
- *
1075
- * Cross-origin browser navigations set Sec-Fetch-Site to `cross-site`
1076
- * or `none` (for user-typed URLs Sec-Fetch-Site: none, Referer absent,
1077
- * Origin absent — handled by the "no headers → allow" branch since that
1078
- * 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).
1079
1299
  */
1080
1300
  function isFirstPartyFetch(request) {
1081
1301
  const site = request.headers.get("sec-fetch-site");
1082
- if (site) return FIRST_PARTY_FETCH_SITES.has(site);
1302
+ if (site && site !== SAME_ORIGIN_FETCH_SITE) return false;
1083
1303
  const origin = request.headers.get("origin");
1084
1304
  if (origin) try {
1085
1305
  return new URL(origin).origin === new URL(request.url).origin;
1086
1306
  } catch {
1087
1307
  return false;
1088
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
+ }
1089
1316
  return true;
1090
1317
  }
1091
1318
  async function handlePrachtRequest(options) {
@@ -1316,9 +1543,10 @@ async function prerenderApp(options) {
1316
1543
  revalidate: route.revalidate
1317
1544
  });
1318
1545
  }
1319
- const CONCURRENCY = 10;
1320
- for (let i = 0; i < work.length; i += CONCURRENCY) {
1321
- 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);
1322
1550
  const batchResults = await Promise.all(batch.map(async (item) => {
1323
1551
  const url = new URL(item.pathname, "http://localhost");
1324
1552
  const request = new Request(url, { method: "GET" });
@@ -1367,127 +1595,70 @@ async function collectSSGPaths(route, registry) {
1367
1595
  return (await routeModule.getStaticPaths()).map((params) => buildPathFromSegments(route.segments, params));
1368
1596
  }
1369
1597
  //#endregion
1370
- //#region src/prefetch.ts
1371
- const CACHE_TTL_MS = 3e4;
1372
- const prefetchCache = /* @__PURE__ */ new Map();
1373
- function getCachedRouteState(url) {
1374
- const entry = prefetchCache.get(url);
1375
- if (!entry) return null;
1376
- if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
1377
- prefetchCache.delete(url);
1378
- return null;
1379
- }
1380
- return entry.promise;
1381
- }
1382
- function prefetchRouteState(url) {
1383
- const cached = getCachedRouteState(url);
1384
- if (cached) return cached;
1385
- const promise = fetchPrachtRouteState(url);
1386
- prefetchCache.set(url, {
1387
- promise,
1388
- timestamp: Date.now()
1389
- });
1390
- 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
+ };
1391
1609
  }
1392
- function setupPrefetching(app, warmModules) {
1393
- let hoverTimer = null;
1394
- function getRoutePathname(url) {
1395
- try {
1396
- return new URL(url, window.location.origin).pathname;
1397
- } catch {
1398
- return null;
1399
- }
1400
- }
1401
- function getInternalHref(anchor) {
1402
- const href = anchor.getAttribute("href");
1403
- if (!href || href.startsWith("#")) return null;
1404
- let url;
1405
- try {
1406
- url = new URL(href, window.location.origin);
1407
- } catch {
1408
- 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);
1409
1621
  }
1410
- if (url.origin !== window.location.origin) return null;
1411
- return url.pathname + url.search;
1622
+ return;
1412
1623
  }
1413
- function getPrefetchStrategy(pathname) {
1414
- const routePathname = getRoutePathname(pathname);
1415
- if (!routePathname) return "none";
1416
- const match = matchAppRoute(app, routePathname);
1417
- if (!match) return "none";
1418
- if (match.route.prefetch) return match.route.prefetch;
1419
- 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";
1420
1660
  }
1421
- document.addEventListener("mouseenter", (e) => {
1422
- const anchor = e.target.closest?.("a");
1423
- if (!anchor) return;
1424
- const href = getInternalHref(anchor);
1425
- if (!href) return;
1426
- const strategy = getPrefetchStrategy(href);
1427
- if (strategy !== "hover" && strategy !== "intent") return;
1428
- if (hoverTimer) clearTimeout(hoverTimer);
1429
- hoverTimer = setTimeout(() => {
1430
- prefetchRouteState(href);
1431
- if (warmModules) {
1432
- const pathname = getRoutePathname(href);
1433
- const m = pathname ? matchAppRoute(app, pathname) : void 0;
1434
- if (m) warmModules(m);
1435
- }
1436
- }, 50);
1437
- }, true);
1438
- document.addEventListener("mouseleave", (e) => {
1439
- if (!e.target.closest?.("a")) return;
1440
- if (hoverTimer) {
1441
- clearTimeout(hoverTimer);
1442
- hoverTimer = null;
1443
- }
1444
- }, true);
1445
- document.addEventListener("focusin", (e) => {
1446
- const anchor = e.target.closest?.("a");
1447
- if (!anchor) return;
1448
- const href = getInternalHref(anchor);
1449
- if (!href) return;
1450
- const strategy = getPrefetchStrategy(href);
1451
- if (strategy !== "hover" && strategy !== "intent") return;
1452
- prefetchRouteState(href);
1453
- if (warmModules) {
1454
- const pathname = getRoutePathname(href);
1455
- const m = pathname ? matchAppRoute(app, pathname) : void 0;
1456
- if (m) warmModules(m);
1457
- }
1458
- }, true);
1459
- if (typeof IntersectionObserver === "undefined") return;
1460
- const observer = new IntersectionObserver((entries) => {
1461
- for (const entry of entries) {
1462
- if (!entry.isIntersecting) continue;
1463
- const anchor = entry.target;
1464
- const href = getInternalHref(anchor);
1465
- if (!href) continue;
1466
- prefetchRouteState(href);
1467
- if (warmModules) {
1468
- const pathname = getRoutePathname(href);
1469
- const m = pathname ? matchAppRoute(app, pathname) : void 0;
1470
- if (m) warmModules(m);
1471
- }
1472
- observer.unobserve(anchor);
1473
- }
1474
- }, { rootMargin: "200px" });
1475
- function observeViewportLinks() {
1476
- const anchors = document.querySelectorAll("a[href]");
1477
- for (const anchor of anchors) {
1478
- const href = getInternalHref(anchor);
1479
- if (!href) continue;
1480
- if (getPrefetchStrategy(href) !== "viewport") continue;
1481
- observer.observe(anchor);
1482
- }
1483
- }
1484
- observeViewportLinks();
1485
- new MutationObserver(() => {
1486
- observeViewportLinks();
1487
- }).observe(document.body, {
1488
- childList: true,
1489
- subtree: true
1490
- });
1661
+ return "Unknown";
1491
1662
  }
1492
1663
  //#endregion
1493
1664
  //#region src/router.ts
@@ -1497,6 +1668,7 @@ function useNavigate() {
1497
1668
  }
1498
1669
  async function initClientRouter(options) {
1499
1670
  const { app, routeModules, shellModules, root, findModuleKey } = options;
1671
+ if (import.meta.env?.DEV) installHydrationMismatchWarning();
1500
1672
  const moduleCache = /* @__PURE__ */ new Map();
1501
1673
  function loadModule(modules, key) {
1502
1674
  let cached = moduleCache.get(key);
@@ -1519,6 +1691,8 @@ async function initClientRouter(options) {
1519
1691
  }
1520
1692
  let updateRouteState = null;
1521
1693
  let routeStateVersion = 0;
1694
+ let latestNavigationId = 0;
1695
+ let activeNavigationAbort = null;
1522
1696
  function RouterRoot({ initialState }) {
1523
1697
  const [routeState, setRouteState] = useState(initialState);
1524
1698
  updateRouteState = setRouteState;
@@ -1605,6 +1779,10 @@ async function initClientRouter(options) {
1605
1779
  };
1606
1780
  }
1607
1781
  async function navigate(to, opts) {
1782
+ const navigationId = ++latestNavigationId;
1783
+ activeNavigationAbort?.abort();
1784
+ const abortController = new AbortController();
1785
+ activeNavigationAbort = abortController;
1608
1786
  const target = resolveBrowserRouteTarget(to);
1609
1787
  if (!target) {
1610
1788
  window.location.href = to;
@@ -1615,7 +1793,7 @@ async function initClientRouter(options) {
1615
1793
  window.location.href = target.browserUrl;
1616
1794
  return;
1617
1795
  }
1618
- const statePromise = getCachedRouteState(target.requestUrl) ?? fetchPrachtRouteState(target.requestUrl);
1796
+ const statePromise = getCachedRouteState(target.requestUrl) ?? fetchPrachtRouteState(target.requestUrl, { signal: abortController.signal });
1619
1797
  const routeModPromise = startRouteImport(match);
1620
1798
  const shellModPromise = startShellImport(match);
1621
1799
  let state = {
@@ -1624,6 +1802,7 @@ async function initClientRouter(options) {
1624
1802
  };
1625
1803
  try {
1626
1804
  const result = await statePromise;
1805
+ if (navigationId !== latestNavigationId) return;
1627
1806
  if (result.type === "redirect") {
1628
1807
  if (result.location) {
1629
1808
  const redirect = resolveRedirectTarget(result.location);
@@ -1659,12 +1838,15 @@ async function initClientRouter(options) {
1659
1838
  error: null
1660
1839
  };
1661
1840
  } catch {
1841
+ if (abortController.signal.aborted || navigationId !== latestNavigationId) return;
1662
1842
  window.location.href = target.browserUrl;
1663
1843
  return;
1664
1844
  }
1845
+ if (navigationId !== latestNavigationId) return;
1665
1846
  if (!opts?._popstate) if (opts?.replace) history.replaceState(null, "", target.browserUrl);
1666
1847
  else history.pushState(null, "", target.browserUrl);
1667
1848
  const routeState = await resolveRouteState(match, state, target.requestUrl, routeModPromise, shellModPromise);
1849
+ if (navigationId !== latestNavigationId) return;
1668
1850
  if (routeState) {
1669
1851
  applyRouteState(routeState);
1670
1852
  window.scrollTo(0, 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pracht/core",
3
- "version": "0.3.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": {