@pracht/core 0.4.0 → 0.6.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;
@@ -194,6 +203,7 @@ interface RouteModule<TContext = any, TLoader extends LoaderLike = undefined> {
194
203
  interface ShellModule<TContext = any> {
195
204
  Shell: FunctionComponent<ShellProps>;
196
205
  Loading?: FunctionComponent;
206
+ ErrorBoundary?: FunctionComponent<ErrorBoundaryProps>;
197
207
  head?: (args: BaseRouteArgs<TContext>) => MaybePromise<HeadMetadata>;
198
208
  headers?: (args: BaseRouteArgs<TContext>) => MaybePromise<HeadersInit>;
199
209
  }
@@ -339,6 +349,7 @@ declare function PrachtRuntimeProvider<TData>({
339
349
  }>;
340
350
  declare function startApp<TData = unknown>(options?: StartAppOptions<TData>): TData | undefined;
341
351
  declare function readHydrationState<TData = unknown>(): PrachtHydrationState<TData> | undefined;
352
+ declare function useRouteData<TLoader extends LoaderLike>(): LoaderData<TLoader>;
342
353
  declare function useRouteData<TData = unknown>(): TData;
343
354
  declare function useLocation(): Location;
344
355
  declare function useParams(): RouteParams;
@@ -395,6 +406,8 @@ interface PrerenderAppOptions {
395
406
  cssManifest?: Record<string, string[]>;
396
407
  /** Per-source-file JS map produced by the vite plugin for modulepreload hints. */
397
408
  jsManifest?: Record<string, string[]>;
409
+ /** Maximum number of pages rendered concurrently. Defaults to 10. */
410
+ concurrency?: number;
398
411
  }
399
412
  declare function prerenderApp(options: PrerenderAppOptions): Promise<PrerenderResult[]>;
400
413
  declare function prerenderApp(options: PrerenderAppOptions & {
@@ -423,4 +436,4 @@ interface InitClientRouterOptions {
423
436
  }
424
437
  declare function initClientRouter(options: InitClientRouterOptions): Promise<void>;
425
438
  //#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 };
439
+ 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,10 +146,15 @@ 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)
148
156
  };
157
+ assertSafeStaticRouteSegment(segment);
149
158
  return {
150
159
  type: "static",
151
160
  value: segment
@@ -155,6 +164,10 @@ function parseRouteSegments(path) {
155
164
  function splitPathSegments(path) {
156
165
  return normalizeRoutePath(path).split("/").filter(Boolean);
157
166
  }
167
+ function assertSafeStaticRouteSegment(segment) {
168
+ if (segment === "." || segment === "..") throw new Error(`Unsafe static route segment "${segment}" is not allowed.`);
169
+ if (segment.includes("\0") || /[\r\n\\]/.test(segment)) throw new Error(`Unsafe static route segment "${segment}" contains a forbidden character.`);
170
+ }
158
171
  function mergeRoutePaths(prefix, path) {
159
172
  if (!path) return normalizeRoutePath(prefix);
160
173
  const normalizedPrefix = normalizeRoutePath(prefix);
@@ -171,19 +184,18 @@ function normalizeRoutePath(path) {
171
184
  function buildPathFromSegments(segments, params) {
172
185
  return normalizeRoutePath("/" + segments.map((segment) => {
173
186
  if (segment.type === "static") return segment.value;
174
- if (segment.type === "param") return encodeURIComponent(params[segment.name] ?? "");
175
- return (params["*"] ?? "").split("/").map((part) => encodeCatchAllSegment(part)).join("/");
187
+ if (segment.type === "param") return encodeDynamicPathSegment(params[segment.name] ?? "");
188
+ return (params[segment.name] ?? params["*"] ?? "").split("/").map((part) => encodeDynamicPathSegment(part)).join("/");
176
189
  }).join("/"));
177
190
  }
178
191
  /**
179
- * Encode a single path segment for a catch-all route. `encodeURIComponent`
180
- * leaves unreserved characters (including `.`) intact, so `..` would
181
- * round-trip unchanged and still resolve as parent-dir in `path.join`.
182
- * Explicitly percent-encode `.` / `..` segments to neutralise them.
192
+ * Encode one dynamic URL path segment for SSG/ISG output. `encodeURIComponent`
193
+ * leaves unreserved characters (including `.`) intact, and even percent-encoded
194
+ * dot segments are normalized by URL parsers. Reject exact `.` / `..` segments
195
+ * instead of allowing them to reach filesystem output path construction.
183
196
  */
184
- function encodeCatchAllSegment(part) {
185
- if (part === ".") return "%2E";
186
- if (part === "..") return "%2E%2E";
197
+ function encodeDynamicPathSegment(part) {
198
+ if (part === "." || part === "..") throw new Error(`Unsafe dynamic route param segment "${part}" is not allowed.`);
187
199
  return encodeURIComponent(part);
188
200
  }
189
201
  /**
@@ -530,7 +542,8 @@ async function fetchPrachtRouteState(url, options) {
530
542
  [ROUTE_STATE_REQUEST_HEADER]: "1",
531
543
  "Cache-Control": "no-cache"
532
544
  },
533
- redirect: "manual"
545
+ redirect: "manual",
546
+ signal: options?.signal
534
547
  });
535
548
  if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) return {
536
549
  location: response.headers.get("location") ?? url,
@@ -572,6 +585,153 @@ async function navigateToClientLocation(location, options) {
572
585
  window.location.href = targetUrl.toString();
573
586
  }
574
587
  //#endregion
588
+ //#region src/prefetch.ts
589
+ const CACHE_TTL_MS = 3e4;
590
+ const MAX_PREFETCH_CACHE_ENTRIES = 100;
591
+ const MAX_MATCH_CACHE_ENTRIES = 250;
592
+ const prefetchCache = /* @__PURE__ */ new Map();
593
+ function clearPrefetchCache() {
594
+ prefetchCache.clear();
595
+ }
596
+ function getCachedRouteState(url) {
597
+ const entry = prefetchCache.get(url);
598
+ if (!entry) return null;
599
+ if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
600
+ prefetchCache.delete(url);
601
+ return null;
602
+ }
603
+ prefetchCache.delete(url);
604
+ prefetchCache.set(url, entry);
605
+ return entry.promise;
606
+ }
607
+ function prefetchRouteState(url) {
608
+ const cached = getCachedRouteState(url);
609
+ if (cached) return cached;
610
+ sweepPrefetchCache();
611
+ const promise = fetchPrachtRouteState(url);
612
+ prefetchCache.set(url, {
613
+ promise,
614
+ timestamp: Date.now()
615
+ });
616
+ trimMapToSize(prefetchCache, MAX_PREFETCH_CACHE_ENTRIES);
617
+ return promise;
618
+ }
619
+ function setupPrefetching(app, warmModules) {
620
+ let hoverTimer = null;
621
+ const observedViewportAnchors = /* @__PURE__ */ new WeakSet();
622
+ const matchCache = /* @__PURE__ */ new Map();
623
+ function getRoutePathname(url) {
624
+ try {
625
+ return new URL(url, window.location.origin).pathname;
626
+ } catch {
627
+ return null;
628
+ }
629
+ }
630
+ function getInternalHref(anchor) {
631
+ const href = anchor.getAttribute("href");
632
+ if (!href || href.startsWith("#")) return null;
633
+ let url;
634
+ try {
635
+ url = new URL(href, window.location.origin);
636
+ } catch {
637
+ return null;
638
+ }
639
+ if (url.origin !== window.location.origin) return null;
640
+ return url.pathname + url.search;
641
+ }
642
+ function getMatchEntry(href) {
643
+ const cached = matchCache.get(href);
644
+ if (cached) {
645
+ matchCache.delete(href);
646
+ matchCache.set(href, cached);
647
+ return cached;
648
+ }
649
+ const routePathname = getRoutePathname(href);
650
+ const match = routePathname ? matchAppRoute(app, routePathname) ?? null : null;
651
+ const entry = {
652
+ match,
653
+ strategy: match ? match.route.prefetch ?? "intent" : "none"
654
+ };
655
+ matchCache.set(href, entry);
656
+ trimMapToSize(matchCache, MAX_MATCH_CACHE_ENTRIES);
657
+ return entry;
658
+ }
659
+ function prefetchHref(href) {
660
+ prefetchRouteState(href);
661
+ if (!warmModules) return;
662
+ const match = getMatchEntry(href).match;
663
+ if (match) warmModules(match);
664
+ }
665
+ document.addEventListener("mouseenter", (e) => {
666
+ const anchor = e.target.closest?.("a");
667
+ if (!anchor) return;
668
+ const href = getInternalHref(anchor);
669
+ if (!href) return;
670
+ const strategy = getMatchEntry(href).strategy;
671
+ if (strategy !== "hover" && strategy !== "intent") return;
672
+ if (hoverTimer) clearTimeout(hoverTimer);
673
+ hoverTimer = setTimeout(() => {
674
+ prefetchHref(href);
675
+ }, 50);
676
+ }, true);
677
+ document.addEventListener("mouseleave", (e) => {
678
+ if (!e.target.closest?.("a")) return;
679
+ if (hoverTimer) {
680
+ clearTimeout(hoverTimer);
681
+ hoverTimer = null;
682
+ }
683
+ }, true);
684
+ document.addEventListener("focusin", (e) => {
685
+ const anchor = e.target.closest?.("a");
686
+ if (!anchor) return;
687
+ const href = getInternalHref(anchor);
688
+ if (!href) return;
689
+ const strategy = getMatchEntry(href).strategy;
690
+ if (strategy !== "hover" && strategy !== "intent") return;
691
+ prefetchHref(href);
692
+ }, true);
693
+ if (typeof IntersectionObserver === "undefined") return;
694
+ const observer = new IntersectionObserver((entries) => {
695
+ for (const entry of entries) {
696
+ if (!entry.isIntersecting) continue;
697
+ const anchor = entry.target;
698
+ const href = getInternalHref(anchor);
699
+ if (!href) continue;
700
+ prefetchHref(href);
701
+ observer.unobserve(anchor);
702
+ }
703
+ }, { rootMargin: "200px" });
704
+ function observeAnchor(anchor) {
705
+ if (observedViewportAnchors.has(anchor)) return;
706
+ const href = getInternalHref(anchor);
707
+ if (!href) return;
708
+ if (getMatchEntry(href).strategy !== "viewport") return;
709
+ observedViewportAnchors.add(anchor);
710
+ observer.observe(anchor);
711
+ }
712
+ function observeViewportLinks(root) {
713
+ if (root instanceof HTMLAnchorElement) observeAnchor(root);
714
+ for (const anchor of root.querySelectorAll("a[href]")) observeAnchor(anchor);
715
+ }
716
+ observeViewportLinks(document.body);
717
+ new MutationObserver((records) => {
718
+ for (const record of records) for (const node of record.addedNodes) if (node instanceof HTMLElement || node instanceof DocumentFragment) observeViewportLinks(node);
719
+ }).observe(document.body, {
720
+ childList: true,
721
+ subtree: true
722
+ });
723
+ }
724
+ function sweepPrefetchCache(now = Date.now()) {
725
+ for (const [url, entry] of prefetchCache) if (now - entry.timestamp > CACHE_TTL_MS) prefetchCache.delete(url);
726
+ }
727
+ function trimMapToSize(map, maxEntries) {
728
+ while (map.size > maxEntries) {
729
+ const first = map.keys().next();
730
+ if (first.done) return;
731
+ map.delete(first.value);
732
+ }
733
+ }
734
+ //#endregion
575
735
  //#region src/runtime-hooks.ts
576
736
  const RouteDataContext = createContext(void 0);
577
737
  function PrachtRuntimeProvider({ children, data, params = EMPTY_ROUTE_PARAMS, routeId, stateVersion = 0, url }) {
@@ -664,6 +824,7 @@ function Form(props) {
664
824
  const formMethod = (method ?? form.method ?? "post").toUpperCase();
665
825
  if (SAFE_METHODS.has(formMethod)) return;
666
826
  event.preventDefault();
827
+ clearPrefetchCache();
667
828
  const response = await fetch(props.action ?? form.action, {
668
829
  method: formMethod,
669
830
  body: new FormData(form),
@@ -693,13 +854,75 @@ function escapeHtml(str) {
693
854
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
694
855
  }
695
856
  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");
857
+ return escapeScriptText(JSON.stringify(value) ?? "null");
858
+ }
859
+ function escapeScriptText(value) {
860
+ return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
861
+ }
862
+ const SAFE_ATTRIBUTE_NAME_RE = /^[A-Za-z_:][A-Za-z0-9:._-]*$/;
863
+ const GLOBAL_HEAD_ATTRIBUTE_PREFIXES = ["data-", "aria-"];
864
+ const META_ATTRIBUTES = new Set([
865
+ "charset",
866
+ "content",
867
+ "http-equiv",
868
+ "itemprop",
869
+ "media",
870
+ "name",
871
+ "property"
872
+ ]);
873
+ const LINK_ATTRIBUTES = new Set([
874
+ "as",
875
+ "blocking",
876
+ "color",
877
+ "crossorigin",
878
+ "disabled",
879
+ "fetchpriority",
880
+ "href",
881
+ "hreflang",
882
+ "imagesizes",
883
+ "imagesrcset",
884
+ "integrity",
885
+ "media",
886
+ "referrerpolicy",
887
+ "rel",
888
+ "sizes",
889
+ "title",
890
+ "type"
891
+ ]);
892
+ const SCRIPT_ATTRIBUTES = new Set([
893
+ "async",
894
+ "blocking",
895
+ "class",
896
+ "crossorigin",
897
+ "defer",
898
+ "fetchpriority",
899
+ "id",
900
+ "integrity",
901
+ "nomodule",
902
+ "nonce",
903
+ "referrerpolicy",
904
+ "src",
905
+ "type"
906
+ ]);
907
+ function renderAttributes(attributes, allowedAttributes) {
908
+ return Object.entries(attributes).filter(([key, value]) => isAllowedHeadAttribute(key, value, allowedAttributes)).map(([key, value]) => `${key}="${escapeHtml(value ?? "")}"`).join(" ");
909
+ }
910
+ function isAllowedHeadAttribute(key, value, allowedAttributes) {
911
+ if (key === "children" || typeof value === "undefined" || !SAFE_ATTRIBUTE_NAME_RE.test(key)) return false;
912
+ const normalized = key.toLowerCase();
913
+ if (normalized.startsWith("on")) return false;
914
+ return allowedAttributes.has(normalized) || GLOBAL_HEAD_ATTRIBUTE_PREFIXES.some((prefix) => normalized.startsWith(prefix));
697
915
  }
698
916
  function buildHtmlDocument(options) {
699
917
  const { head, body, hydrationState, clientEntryUrl, cssUrls = [], modulePreloadUrls = [], routeStatePreloadUrl } = options;
700
918
  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 ");
919
+ const metaTags = (head.meta ?? []).map((m) => renderAttributes(m, META_ATTRIBUTES)).filter(Boolean).map((attrs) => `<meta ${attrs}>`).join("\n ");
920
+ const linkTags = (head.link ?? []).map((l) => renderAttributes(l, LINK_ATTRIBUTES)).filter(Boolean).map((attrs) => `<link ${attrs}>`).join("\n ");
921
+ const scriptTags = (head.script ?? []).map((script) => {
922
+ const attrs = renderAttributes(script, SCRIPT_ATTRIBUTES);
923
+ const children = script.children ? escapeScriptText(script.children) : "";
924
+ return attrs ? `<script ${attrs}>${children}<\/script>` : `<script>${children}<\/script>`;
925
+ }).join("\n ");
703
926
  const cssTags = cssUrls.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join("\n ");
704
927
  const modulePreloadTags = modulePreloadUrls.map((url) => `<link rel="modulepreload" href="${escapeHtml(url)}">`).join("\n ");
705
928
  const routeStatePreloadTag = routeStatePreloadUrl ? `<link rel="preload" as="fetch" href="${escapeHtml(routeStatePreloadUrl)}" crossorigin="anonymous">` : "";
@@ -712,6 +935,7 @@ function buildHtmlDocument(options) {
712
935
  ${titleTag}
713
936
  ${metaTags}
714
937
  ${linkTags}
938
+ ${scriptTags}
715
939
  ${cssTags}
716
940
  ${modulePreloadTags}
717
941
  ${routeStatePreloadTag}
@@ -870,7 +1094,8 @@ async function mergeHeadMetadata(shellModule, routeModule, routeArgs, data) {
870
1094
  title: routeHead.title ?? shellHead.title,
871
1095
  lang: routeHead.lang ?? shellHead.lang,
872
1096
  meta: [...shellHead.meta ?? [], ...routeHead.meta ?? []],
873
- link: [...shellHead.link ?? [], ...routeHead.link ?? []]
1097
+ link: [...shellHead.link ?? [], ...routeHead.link ?? []],
1098
+ script: [...shellHead.script ?? [], ...routeHead.script ?? []]
874
1099
  };
875
1100
  }
876
1101
  async function mergeDocumentHeaders(shellModule, routeModule, routeArgs, data) {
@@ -949,25 +1174,26 @@ async function renderRouteErrorResponse(options) {
949
1174
  status: routeError.status
950
1175
  })
951
1176
  } : routeError;
952
- if (!options.routeModule?.ErrorBoundary) {
953
- if (options.isRouteStateRequest) return jsonErrorResponse(routeErrorWithDiagnostics, { isRouteStateRequest: true });
954
- const message = routeErrorWithDiagnostics.status >= 500 ? "Internal Server Error" : routeErrorWithDiagnostics.message;
955
- return withDefaultSecurityHeaders(new Response(message, {
1177
+ if (options.isRouteStateRequest) return jsonErrorResponse(routeErrorWithDiagnostics, { isRouteStateRequest: true });
1178
+ const shellModule = options.shellModule ?? (options.shellFile ? await resolveRegistryModule(options.options.registry?.shellModules, options.shellFile) : void 0);
1179
+ const ErrorBoundary = options.routeModule?.ErrorBoundary ?? shellModule?.ErrorBoundary;
1180
+ if (!ErrorBoundary) {
1181
+ const message = routeErrorWithDiagnostics.status >= 500 && !exposeDetails ? "Internal Server Error" : routeErrorWithDiagnostics.message;
1182
+ const diagnostics = exposeDetails && routeErrorWithDiagnostics.diagnostics ? `\n\n${JSON.stringify(routeErrorWithDiagnostics.diagnostics, null, 2)}` : "";
1183
+ return withDefaultSecurityHeaders(new Response(`${message}${diagnostics}`, {
956
1184
  status: routeErrorWithDiagnostics.status,
957
1185
  headers: { "content-type": "text/plain; charset=utf-8" }
958
1186
  }));
959
1187
  }
960
- if (options.isRouteStateRequest) return jsonErrorResponse(routeErrorWithDiagnostics, { isRouteStateRequest: true });
961
- const shellModule = options.shellModule ?? (options.shellFile ? await resolveRegistryModule(options.options.registry?.shellModules, options.shellFile) : void 0);
962
1188
  const head = shellModule?.head ? await shellModule.head(options.routeArgs) : {};
963
1189
  const documentHeaders = await mergeDocumentHeaders(shellModule, void 0, options.routeArgs, void 0);
964
1190
  const cssUrls = resolvePageCssUrls(options.options.cssManifest, options.shellFile, options.routeArgs.route.file);
965
1191
  const modulePreloadUrls = resolvePageJsUrls(options.options.jsManifest, options.shellFile, options.routeArgs.route.file);
966
1192
  const renderToString = await getRenderToStringAsync();
967
- const ErrorBoundary = options.routeModule.ErrorBoundary;
1193
+ const Boundary = ErrorBoundary;
968
1194
  const Shell = shellModule?.Shell;
969
1195
  const errorValue = deserializeRouteError(routeErrorWithDiagnostics);
970
- const componentTree = Shell ? h(Shell, null, h(ErrorBoundary, { error: errorValue })) : h(ErrorBoundary, { error: errorValue });
1196
+ const componentTree = Shell ? h(Shell, null, h(Boundary, { error: errorValue })) : h(Boundary, { error: errorValue });
971
1197
  return htmlResponse(buildHtmlDocument({
972
1198
  head,
973
1199
  body: await renderToString(h(PrachtRuntimeProvider, {
@@ -1034,25 +1260,25 @@ function markdownResponse(source) {
1034
1260
  }
1035
1261
  //#endregion
1036
1262
  //#region src/runtime.ts
1037
- const FIRST_PARTY_FETCH_SITES = new Set(["same-origin", "same-site"]);
1263
+ const SAME_ORIGIN_FETCH_SITE = "same-origin";
1038
1264
  /**
1039
1265
  * 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.
1266
+ * state-changing API requests. It rejects any browser signal that points
1267
+ * outside this exact origin a cross-origin form POST will send `Origin`
1268
+ * from the attacker, and `Sec-Fetch-Site: same-site` is not enough because
1269
+ * sibling subdomains can be attacker-controlled. Requests with no browser
1270
+ * provenance headers are treated as non-browser callers.
1046
1271
  */
1047
1272
  function isSameOriginMutation(request, url) {
1048
1273
  const site = request.headers.get("sec-fetch-site");
1049
- if (site) return FIRST_PARTY_FETCH_SITES.has(site);
1274
+ if (site && site !== SAME_ORIGIN_FETCH_SITE) return false;
1050
1275
  const origin = request.headers.get("origin");
1051
1276
  if (origin) try {
1052
1277
  return new URL(origin).origin === url.origin;
1053
1278
  } catch {
1054
1279
  return false;
1055
1280
  }
1281
+ if (site === SAME_ORIGIN_FETCH_SITE) return true;
1056
1282
  const referer = request.headers.get("referer");
1057
1283
  if (referer) try {
1058
1284
  return new URL(referer).origin === url.origin;
@@ -1067,27 +1293,31 @@ function isSameOriginMutation(request, url) {
1067
1293
  * otherwise reachable via any cross-origin `<a href>` / redirect.
1068
1294
  *
1069
1295
  * Accepts a request as first-party when:
1070
- * - Sec-Fetch-Site is `same-origin` or `same-site` (modern browsers),
1296
+ * - Sec-Fetch-Site is `same-origin` (modern browsers),
1071
1297
  * - OR Sec-Fetch-Site is absent AND the Origin header matches the
1072
1298
  * 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).
1299
+ * - OR Sec-Fetch-Site/Origin are absent AND Referer matches the request
1300
+ * URL's origin,
1301
+ * - OR no Origin/Sec-Fetch-Site/Referer is present (non-browser clients like
1302
+ * curl — CSRF is not the threat model there; blocking would break
1303
+ * tests and CLIs).
1081
1304
  */
1082
1305
  function isFirstPartyFetch(request) {
1083
1306
  const site = request.headers.get("sec-fetch-site");
1084
- if (site) return FIRST_PARTY_FETCH_SITES.has(site);
1307
+ if (site && site !== SAME_ORIGIN_FETCH_SITE) return false;
1085
1308
  const origin = request.headers.get("origin");
1086
1309
  if (origin) try {
1087
1310
  return new URL(origin).origin === new URL(request.url).origin;
1088
1311
  } catch {
1089
1312
  return false;
1090
1313
  }
1314
+ if (site === SAME_ORIGIN_FETCH_SITE) return true;
1315
+ const referer = request.headers.get("referer");
1316
+ if (referer) try {
1317
+ return new URL(referer).origin === new URL(request.url).origin;
1318
+ } catch {
1319
+ return false;
1320
+ }
1091
1321
  return true;
1092
1322
  }
1093
1323
  async function handlePrachtRequest(options) {
@@ -1318,9 +1548,10 @@ async function prerenderApp(options) {
1318
1548
  revalidate: route.revalidate
1319
1549
  });
1320
1550
  }
1321
- const CONCURRENCY = 10;
1322
- for (let i = 0; i < work.length; i += CONCURRENCY) {
1323
- const batch = work.slice(i, i + CONCURRENCY);
1551
+ const concurrency = options.concurrency ?? 10;
1552
+ if (!Number.isInteger(concurrency) || concurrency <= 0) throw new Error("prerenderApp({ concurrency }) expects a positive integer.");
1553
+ for (let i = 0; i < work.length; i += concurrency) {
1554
+ const batch = work.slice(i, i + concurrency);
1324
1555
  const batchResults = await Promise.all(batch.map(async (item) => {
1325
1556
  const url = new URL(item.pathname, "http://localhost");
1326
1557
  const request = new Request(url, { method: "GET" });
@@ -1369,127 +1600,70 @@ async function collectSSGPaths(route, registry) {
1369
1600
  return (await routeModule.getStaticPaths()).map((params) => buildPathFromSegments(route.segments, params));
1370
1601
  }
1371
1602
  //#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;
1603
+ //#region src/hydration-mismatch.ts
1604
+ const HYDRATION_BANNER_ID = "__pracht_hydration_mismatch__";
1605
+ let installed = false;
1606
+ function installHydrationMismatchWarning() {
1607
+ if (installed) return;
1608
+ installed = true;
1609
+ const prev = options.__m;
1610
+ options.__m = function(vnode) {
1611
+ appendHydrationWarning(vnode);
1612
+ if (prev) prev(vnode);
1613
+ };
1393
1614
  }
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;
1615
+ function appendHydrationWarning(vnode) {
1616
+ if (typeof document === "undefined") return;
1617
+ const componentName = getVNodeName(vnode);
1618
+ let banner = document.getElementById(HYDRATION_BANNER_ID);
1619
+ const message = `Hydration mismatch detected on <${componentName}>. The server-rendered HTML did not match the client.`;
1620
+ if (banner) {
1621
+ const list = banner.querySelector(`[data-pracht-mismatch-list]`);
1622
+ if (list) {
1623
+ const item = document.createElement("li");
1624
+ item.textContent = message;
1625
+ list.appendChild(item);
1411
1626
  }
1412
- if (url.origin !== window.location.origin) return null;
1413
- return url.pathname + url.search;
1414
- }
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";
1627
+ return;
1422
1628
  }
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
- }
1629
+ banner = document.createElement("div");
1630
+ banner.id = HYDRATION_BANNER_ID;
1631
+ banner.setAttribute("role", "alert");
1632
+ banner.style.cssText = [
1633
+ "position:fixed",
1634
+ "top:0",
1635
+ "left:0",
1636
+ "right:0",
1637
+ "z-index:2147483647",
1638
+ "background:#1a1a2e",
1639
+ "color:#ff6b6b",
1640
+ "padding:12px 16px",
1641
+ "font:12px/1.5 ui-monospace,Menlo,Consolas,monospace",
1642
+ "border-bottom:2px solid #e74c3c",
1643
+ "box-shadow:0 2px 8px rgba(0,0,0,0.3)"
1644
+ ].join(";");
1645
+ const title = document.createElement("strong");
1646
+ title.textContent = "pracht: hydration mismatch";
1647
+ title.style.cssText = "display:block;margin-bottom:4px;color:#fff";
1648
+ banner.appendChild(title);
1649
+ const list = document.createElement("ul");
1650
+ list.setAttribute("data-pracht-mismatch-list", "");
1651
+ list.style.cssText = "margin:0;padding-left:18px";
1652
+ const item = document.createElement("li");
1653
+ item.textContent = message;
1654
+ list.appendChild(item);
1655
+ banner.appendChild(list);
1656
+ document.body.appendChild(banner);
1657
+ }
1658
+ function getVNodeName(vnode) {
1659
+ if (!vnode) return "Unknown";
1660
+ const type = vnode.type;
1661
+ if (typeof type === "string") return type;
1662
+ if (typeof type === "function") {
1663
+ const fn = type;
1664
+ return fn.displayName || fn.name || "Component";
1485
1665
  }
1486
- observeViewportLinks();
1487
- new MutationObserver(() => {
1488
- observeViewportLinks();
1489
- }).observe(document.body, {
1490
- childList: true,
1491
- subtree: true
1492
- });
1666
+ return "Unknown";
1493
1667
  }
1494
1668
  //#endregion
1495
1669
  //#region src/router.ts
@@ -1499,6 +1673,7 @@ function useNavigate() {
1499
1673
  }
1500
1674
  async function initClientRouter(options) {
1501
1675
  const { app, routeModules, shellModules, root, findModuleKey } = options;
1676
+ if (import.meta.env?.DEV) installHydrationMismatchWarning();
1502
1677
  const moduleCache = /* @__PURE__ */ new Map();
1503
1678
  function loadModule(modules, key) {
1504
1679
  let cached = moduleCache.get(key);
@@ -1521,6 +1696,8 @@ async function initClientRouter(options) {
1521
1696
  }
1522
1697
  let updateRouteState = null;
1523
1698
  let routeStateVersion = 0;
1699
+ let latestNavigationId = 0;
1700
+ let activeNavigationAbort = null;
1524
1701
  function RouterRoot({ initialState }) {
1525
1702
  const [routeState, setRouteState] = useState(initialState);
1526
1703
  updateRouteState = setRouteState;
@@ -1549,7 +1726,8 @@ async function initClientRouter(options) {
1549
1726
  const resolvedShell = await (shellModPromise ?? startShellImport(match));
1550
1727
  if (resolvedShell) Shell = resolvedShell.Shell;
1551
1728
  const DefaultComponent = typeof routeMod.default === "function" ? routeMod.default : void 0;
1552
- const Component = state.error ? routeMod.ErrorBoundary : routeMod.Component ?? DefaultComponent;
1729
+ const ErrorBoundary = routeMod.ErrorBoundary ?? resolvedShell?.ErrorBoundary;
1730
+ const Component = state.error ? ErrorBoundary : routeMod.Component ?? DefaultComponent;
1553
1731
  if (!Component) return null;
1554
1732
  const componentProps = state.error ? { error: deserializeRouteError(state.error) } : {
1555
1733
  data: state.data,
@@ -1607,6 +1785,10 @@ async function initClientRouter(options) {
1607
1785
  };
1608
1786
  }
1609
1787
  async function navigate(to, opts) {
1788
+ const navigationId = ++latestNavigationId;
1789
+ activeNavigationAbort?.abort();
1790
+ const abortController = new AbortController();
1791
+ activeNavigationAbort = abortController;
1610
1792
  const target = resolveBrowserRouteTarget(to);
1611
1793
  if (!target) {
1612
1794
  window.location.href = to;
@@ -1617,7 +1799,7 @@ async function initClientRouter(options) {
1617
1799
  window.location.href = target.browserUrl;
1618
1800
  return;
1619
1801
  }
1620
- const statePromise = getCachedRouteState(target.requestUrl) ?? fetchPrachtRouteState(target.requestUrl);
1802
+ const statePromise = getCachedRouteState(target.requestUrl) ?? fetchPrachtRouteState(target.requestUrl, { signal: abortController.signal });
1621
1803
  const routeModPromise = startRouteImport(match);
1622
1804
  const shellModPromise = startShellImport(match);
1623
1805
  let state = {
@@ -1626,6 +1808,7 @@ async function initClientRouter(options) {
1626
1808
  };
1627
1809
  try {
1628
1810
  const result = await statePromise;
1811
+ if (navigationId !== latestNavigationId) return;
1629
1812
  if (result.type === "redirect") {
1630
1813
  if (result.location) {
1631
1814
  const redirect = resolveRedirectTarget(result.location);
@@ -1661,12 +1844,15 @@ async function initClientRouter(options) {
1661
1844
  error: null
1662
1845
  };
1663
1846
  } catch {
1847
+ if (abortController.signal.aborted || navigationId !== latestNavigationId) return;
1664
1848
  window.location.href = target.browserUrl;
1665
1849
  return;
1666
1850
  }
1851
+ if (navigationId !== latestNavigationId) return;
1667
1852
  if (!opts?._popstate) if (opts?.replace) history.replaceState(null, "", target.browserUrl);
1668
1853
  else history.pushState(null, "", target.browserUrl);
1669
1854
  const routeState = await resolveRouteState(match, state, target.requestUrl, routeModPromise, shellModPromise);
1855
+ if (navigationId !== latestNavigationId) return;
1670
1856
  if (routeState) {
1671
1857
  applyRouteState(routeState);
1672
1858
  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.6.0",
4
4
  "license": "MIT",
5
5
  "homepage": "https://github.com/JoviDeCroock/pracht/tree/main/packages/framework",
6
6
  "bugs": {