@pracht/core 0.6.0 → 0.7.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/dist/index.d.mts CHANGED
@@ -23,6 +23,53 @@ type RegisteredContext = Register extends {
23
23
  } ? T : unknown;
24
24
  type RenderMode = "spa" | "ssr" | "ssg" | "isg";
25
25
  type RouteParams = Record<string, string>;
26
+ type RouteParamInput = string | number | boolean;
27
+ type SearchParamPrimitive = string | number | boolean;
28
+ type SearchParamValue = SearchParamPrimitive | null | undefined | readonly (SearchParamPrimitive | null | undefined)[];
29
+ type SearchParamsInput = string | URLSearchParams | Record<string, SearchParamValue>;
30
+ interface BuildHrefOptions {
31
+ params?: Record<string, RouteParamInput>;
32
+ search?: SearchParamsInput;
33
+ hash?: string;
34
+ }
35
+ interface NavigateOptions {
36
+ replace?: boolean;
37
+ }
38
+ interface HrefRouteDefinition {
39
+ id?: string;
40
+ path: string;
41
+ segments?: readonly RouteSegment[];
42
+ }
43
+ type RegisteredRouteMap = Register extends {
44
+ routes: infer TRoutes;
45
+ } ? TRoutes extends Record<string, unknown> ? TRoutes : {} : {};
46
+ type HasRegisteredRoutes = keyof RegisteredRouteMap extends never ? false : true;
47
+ type EmptyRouteParams = Record<never, never>;
48
+ type IsEmptyRouteParams<TParams> = keyof TParams extends never ? true : false;
49
+ type RouteId = HasRegisteredRoutes extends true ? Extract<keyof RegisteredRouteMap, string> : string;
50
+ type RouteParamsFor<TRoute extends RouteId> = HasRegisteredRoutes extends true ? TRoute extends keyof RegisteredRouteMap ? RegisteredRouteMap[TRoute] extends {
51
+ params: infer TParams;
52
+ } ? TParams extends Record<string, unknown> ? TParams : EmptyRouteParams : EmptyRouteParams : never : Record<string, RouteParamInput>;
53
+ type RouteSearchFor<TRoute extends RouteId> = HasRegisteredRoutes extends true ? TRoute extends keyof RegisteredRouteMap ? RegisteredRouteMap[TRoute] extends {
54
+ search: infer TSearch;
55
+ } ? TSearch : SearchParamsInput : never : SearchParamsInput;
56
+ type TypedHrefOptions<TRoute extends RouteId> = IsEmptyRouteParams<RouteParamsFor<TRoute>> extends true ? {
57
+ params?: never;
58
+ search?: RouteSearchFor<TRoute>;
59
+ hash?: string;
60
+ } : {
61
+ params: RouteParamsFor<TRoute>;
62
+ search?: RouteSearchFor<TRoute>;
63
+ hash?: string;
64
+ };
65
+ type HrefOptions<TRoute extends RouteId = RouteId> = HasRegisteredRoutes extends true ? TRoute extends RouteId ? TypedHrefOptions<TRoute> : never : BuildHrefOptions;
66
+ type HrefArgs<TRoute extends RouteId = RouteId> = HasRegisteredRoutes extends true ? TRoute extends RouteId ? IsEmptyRouteParams<RouteParamsFor<TRoute>> extends true ? [options?: TypedHrefOptions<TRoute>] : [options: TypedHrefOptions<TRoute>] : never : [options?: BuildHrefOptions];
67
+ type RouteTarget<TRoute extends RouteId = RouteId> = HasRegisteredRoutes extends true ? TRoute extends RouteId ? {
68
+ route: TRoute;
69
+ } & TypedHrefOptions<TRoute> : never : {
70
+ route: string;
71
+ } & BuildHrefOptions;
72
+ type HrefFn = <TRoute extends RouteId>(route: TRoute, ...args: HrefArgs<TRoute>) => string;
26
73
  /**
27
74
  * A reference to a module file — either a plain string path or a lazy import
28
75
  * function. Using `() => import("./path")` enables IDE click-to-navigate.
@@ -67,6 +114,7 @@ interface RouteMeta {
67
114
  middleware?: string[];
68
115
  revalidate?: RouteRevalidate;
69
116
  prefetch?: PrefetchStrategy;
117
+ hasLoader?: boolean;
70
118
  }
71
119
  interface GroupMeta {
72
120
  shell?: string;
@@ -241,7 +289,9 @@ declare function group(meta: GroupMeta, routes: RouteTreeNode[]): GroupDefinitio
241
289
  declare function defineApp(config: PrachtAppConfig): PrachtApp;
242
290
  declare function resolveApp(app: PrachtApp): ResolvedPrachtApp;
243
291
  declare function matchAppRoute(app: PrachtApp | ResolvedPrachtApp, pathname: string): RouteMatch | undefined;
244
- declare function buildPathFromSegments(segments: RouteSegment[], params: RouteParams): string;
292
+ declare function buildPathFromSegments(segments: readonly RouteSegment[], params: RouteParams): string;
293
+ declare function buildHref<TRoute extends RouteId>(routes: readonly HrefRouteDefinition[], routeId: TRoute, ...args: HrefArgs<TRoute>): string;
294
+ declare function createHref(routes: readonly HrefRouteDefinition[]): HrefFn;
245
295
  /**
246
296
  * Convert a list of file paths from `import.meta.glob` into resolved API routes.
247
297
  *
@@ -310,22 +360,22 @@ interface FormProps extends Omit<JSX.HTMLAttributes<HTMLFormElement>, "action" |
310
360
  action?: string;
311
361
  method?: string;
312
362
  }
363
+ type LinkProps<TRoute extends RouteId = RouteId> = Omit<JSX.HTMLAttributes<HTMLAnchorElement>, "href"> & RouteTarget<TRoute>;
313
364
  interface Location {
314
365
  pathname: string;
315
366
  search: string;
316
367
  }
317
368
  declare global {
369
+ var __PRACHT_ROUTE_DEFINITIONS__: readonly HrefRouteDefinition[] | undefined;
318
370
  interface Window {
319
371
  __PRACHT_STATE__?: PrachtHydrationState;
320
- __PRACHT_NAVIGATE__?: (to: string, options?: {
321
- replace?: boolean;
322
- }) => Promise<void>;
323
372
  }
324
373
  }
325
374
  interface PrachtRuntimeValue {
326
375
  data: unknown;
327
376
  params: RouteParams;
328
377
  routeId: string;
378
+ routes?: readonly HrefRouteDefinition[];
329
379
  url: string;
330
380
  setData: (data: unknown) => void;
331
381
  }
@@ -334,6 +384,7 @@ declare function PrachtRuntimeProvider<TData>({
334
384
  data,
335
385
  params,
336
386
  routeId,
387
+ routes,
337
388
  stateVersion,
338
389
  url
339
390
  }: {
@@ -341,6 +392,7 @@ declare function PrachtRuntimeProvider<TData>({
341
392
  data: TData;
342
393
  params?: RouteParams;
343
394
  routeId: string;
395
+ routes?: readonly HrefRouteDefinition[];
344
396
  stateVersion?: number;
345
397
  url: string;
346
398
  }): _$preact.VNode<_$preact.Attributes & {
@@ -354,6 +406,7 @@ declare function useRouteData<TData = unknown>(): TData;
354
406
  declare function useLocation(): Location;
355
407
  declare function useParams(): RouteParams;
356
408
  declare function useRevalidate(): () => Promise<unknown>;
409
+ declare function Link<TRoute extends RouteId>(props: LinkProps<TRoute>): _$preact.VNode<_$preact.ClassAttributes<HTMLAnchorElement> & h.JSX.HTMLAttributes<HTMLAnchorElement>>;
357
410
  declare function Form(props: FormProps): _$preact.VNode<_$preact.ClassAttributes<HTMLFormElement> & h.JSX.HTMLAttributes<HTMLFormElement>>;
358
411
  //#endregion
359
412
  //#region src/runtime-client-fetch.d.ts
@@ -422,9 +475,10 @@ declare global {
422
475
  }
423
476
  }
424
477
  type ModuleMap = Record<string, () => Promise<unknown>>;
425
- type NavigateFn = (to: string, options?: {
426
- replace?: boolean;
427
- }) => Promise<void>;
478
+ interface NavigateFn {
479
+ (to: string, options?: NavigateOptions): Promise<void>;
480
+ <TRoute extends RouteId>(to: RouteTarget<TRoute>, options?: NavigateOptions): Promise<void>;
481
+ }
428
482
  declare function useNavigate(): NavigateFn;
429
483
  interface InitClientRouterOptions {
430
484
  app: ResolvedPrachtApp;
@@ -436,4 +490,4 @@ interface InitClientRouterOptions {
436
490
  }
437
491
  declare function initClientRouter(options: InitClientRouterOptions): Promise<void>;
438
492
  //#endregion
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 };
493
+ export { type ApiConfig, type ApiRouteArgs, type ApiRouteHandler, type ApiRouteMatch, type ApiRouteModule, type BaseRouteArgs, type BuildHrefOptions, type DataModule, type ErrorBoundaryProps, Form, type FormProps, type GroupDefinition, type GroupMeta, type HandlePrachtRequestOptions, type HeadArgs, type HeadAttributes, type HeadMetadata, type HeadScriptDescriptor, type HeadersArgs, type HrefArgs, type HrefFn, type HrefOptions, type HrefRouteDefinition, type HttpMethod, type ISGManifestEntry, type InitClientRouterOptions, Link, type LinkProps, 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 NavigateOptions, 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 RouteId, type RouteMatch, type RouteMeta, type RouteModule, type RouteParamInput, type RouteParams, type RouteParamsFor, type RouteRevalidate, type RouteSearchFor, type RouteStateResult, type RouteTarget, type RouteTreeNode, type SearchParamPrimitive, type SearchParamValue, type SearchParamsInput, type SerializedRouteError, type ShellModule, type ShellProps, type StartAppOptions, Suspense, type TimeRevalidatePolicy, applyDefaultSecurityHeaders, buildHref, buildPathFromSegments, createHref, 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
@@ -22,6 +22,7 @@ function route(path, fileOrConfig, meta = {}) {
22
22
  path: normalizeRoutePath(path),
23
23
  file: resolveModuleRef(component),
24
24
  loaderFile: resolveModuleRef(loader),
25
+ hasLoader: !!loader,
25
26
  ...routeMeta
26
27
  };
27
28
  }
@@ -97,6 +98,7 @@ function flattenRouteNode(app, node, inherited, routes) {
97
98
  path: fullPath,
98
99
  file: node.file,
99
100
  loaderFile: node.loaderFile,
101
+ hasLoader: node.loaderFile ? true : node.hasLoader,
100
102
  shell,
101
103
  shellFile: shell ? app.shells[shell] : void 0,
102
104
  render: node.render ?? inherited.render,
@@ -188,6 +190,54 @@ function buildPathFromSegments(segments, params) {
188
190
  return (params[segment.name] ?? params["*"] ?? "").split("/").map((part) => encodeDynamicPathSegment(part)).join("/");
189
191
  }).join("/"));
190
192
  }
193
+ function buildHref(routes, routeId, ...args) {
194
+ return buildHrefUntyped(routes, String(routeId), args[0]);
195
+ }
196
+ function createHref(routes) {
197
+ return ((routeId, options) => buildHrefUntyped(routes, routeId, options));
198
+ }
199
+ function buildHrefUntyped(routes, routeId, options = {}) {
200
+ const route = routes.find((candidate) => candidate.id === routeId);
201
+ if (!route) throw new Error(`Unknown pracht route id: ${routeId}.`);
202
+ const segments = route.segments ?? parseRouteSegments(route.path);
203
+ return `${buildPathFromSegments(segments, normalizeHrefParams(segments, options.params ?? {}))}${serializeSearch(options.search)}${serializeHash(options.hash)}`;
204
+ }
205
+ function normalizeHrefParams(segments, params) {
206
+ const expected = new Set(segments.filter((segment) => segment.type === "param" || segment.type === "catchall").map((segment) => segment.name));
207
+ for (const name of expected) if (params[name] == null) throw new Error(`Missing route param: ${name}.`);
208
+ for (const name of Object.keys(params)) if (!expected.has(name)) throw new Error(`Unexpected route param: ${name}.`);
209
+ const normalized = {};
210
+ for (const name of expected) normalized[name] = String(params[name]);
211
+ return normalized;
212
+ }
213
+ function serializeSearch(search) {
214
+ if (search == null) return "";
215
+ if (typeof search === "string") {
216
+ if (!search) return "";
217
+ return search.startsWith("?") ? search : `?${search}`;
218
+ }
219
+ const serialized = (search instanceof URLSearchParams ? search : objectToSearchParams(search)).toString();
220
+ return serialized ? `?${serialized}` : "";
221
+ }
222
+ function objectToSearchParams(search) {
223
+ const params = new URLSearchParams();
224
+ for (const [key, value] of Object.entries(search)) {
225
+ if (Array.isArray(value)) {
226
+ for (const item of value) appendSearchValue(params, key, item);
227
+ continue;
228
+ }
229
+ appendSearchValue(params, key, value);
230
+ }
231
+ return params;
232
+ }
233
+ function appendSearchValue(params, key, value) {
234
+ if (value == null) return;
235
+ params.append(key, String(value));
236
+ }
237
+ function serializeHash(hash) {
238
+ if (!hash) return "";
239
+ return hash.startsWith("#") ? hash : `#${hash}`;
240
+ }
191
241
  /**
192
242
  * Encode one dynamic URL path segment for SSG/ISG output. `encodeURIComponent`
193
243
  * leaves unreserved characters (including `.`) intact, and even percent-encoded
@@ -532,6 +582,10 @@ function parseSafeNavigationUrl(location, base) {
532
582
  if (!SAFE_NAVIGATION_PROTOCOLS.has(targetUrl.protocol)) return null;
533
583
  return targetUrl;
534
584
  }
585
+ function routeNeedsServerFetch(route) {
586
+ if (route.hasLoader === false && route.middlewareFiles.length === 0) return false;
587
+ return true;
588
+ }
535
589
  function buildRouteStateUrl(url) {
536
590
  return `${url}${url.includes("?") ? "&" : "?"}_data=1`;
537
591
  }
@@ -589,6 +643,10 @@ async function navigateToClientLocation(location, options) {
589
643
  const CACHE_TTL_MS = 3e4;
590
644
  const MAX_PREFETCH_CACHE_ENTRIES = 100;
591
645
  const MAX_MATCH_CACHE_ENTRIES = 250;
646
+ const EMPTY_ROUTE_STATE_PROMISE = Promise.resolve({
647
+ type: "data",
648
+ data: void 0
649
+ });
592
650
  const prefetchCache = /* @__PURE__ */ new Map();
593
651
  function clearPrefetchCache() {
594
652
  prefetchCache.clear();
@@ -604,7 +662,8 @@ function getCachedRouteState(url) {
604
662
  prefetchCache.set(url, entry);
605
663
  return entry.promise;
606
664
  }
607
- function prefetchRouteState(url) {
665
+ function prefetchRouteState(url, route) {
666
+ if (route && !routeNeedsServerFetch(route)) return EMPTY_ROUTE_STATE_PROMISE;
608
667
  const cached = getCachedRouteState(url);
609
668
  if (cached) return cached;
610
669
  sweepPrefetchCache();
@@ -657,10 +716,9 @@ function setupPrefetching(app, warmModules) {
657
716
  return entry;
658
717
  }
659
718
  function prefetchHref(href) {
660
- prefetchRouteState(href);
661
- if (!warmModules) return;
662
719
  const match = getMatchEntry(href).match;
663
- if (match) warmModules(match);
720
+ prefetchRouteState(href, match?.route);
721
+ if (warmModules && match) warmModules(match);
664
722
  }
665
723
  document.addEventListener("mouseenter", (e) => {
666
724
  const anchor = e.target.closest?.("a");
@@ -734,7 +792,8 @@ function trimMapToSize(map, maxEntries) {
734
792
  //#endregion
735
793
  //#region src/runtime-hooks.ts
736
794
  const RouteDataContext = createContext(void 0);
737
- function PrachtRuntimeProvider({ children, data, params = EMPTY_ROUTE_PARAMS, routeId, stateVersion = 0, url }) {
795
+ function PrachtRuntimeProvider({ children, data, params = EMPTY_ROUTE_PARAMS, routeId, routes, stateVersion = 0, url }) {
796
+ registerRuntimeRoutes(routes);
738
797
  const [routeDataState, setRouteDataState] = useState({
739
798
  data,
740
799
  stateVersion
@@ -755,6 +814,7 @@ function PrachtRuntimeProvider({ children, data, params = EMPTY_ROUTE_PARAMS, ro
755
814
  data: routeData,
756
815
  params,
757
816
  routeId,
817
+ routes,
758
818
  setData: (nextData) => setRouteDataState({
759
819
  data: nextData,
760
820
  stateVersion
@@ -764,6 +824,7 @@ function PrachtRuntimeProvider({ children, data, params = EMPTY_ROUTE_PARAMS, ro
764
824
  routeData,
765
825
  params,
766
826
  routeId,
827
+ routes,
767
828
  stateVersion,
768
829
  url
769
830
  ]);
@@ -811,6 +872,23 @@ function useRevalidate() {
811
872
  return result.data;
812
873
  };
813
874
  }
875
+ function Link(props) {
876
+ const routes = useContext(RouteDataContext)?.routes ?? globalThis.__PRACHT_ROUTE_DEFINITIONS__;
877
+ if (!routes) throw new Error("<Link route=...> must render inside a pracht route tree.");
878
+ const { route, params, search, hash, ...anchorProps } = props;
879
+ return h("a", {
880
+ ...anchorProps,
881
+ href: buildHref(routes, route, {
882
+ params,
883
+ search,
884
+ hash
885
+ })
886
+ });
887
+ }
888
+ function registerRuntimeRoutes(routes) {
889
+ if (!routes) return;
890
+ globalThis.__PRACHT_ROUTE_DEFINITIONS__ = routes;
891
+ }
814
892
  function Form(props) {
815
893
  const { onSubmit, method, ...rest } = props;
816
894
  return h("form", {
@@ -1199,6 +1277,7 @@ async function renderRouteErrorResponse(options) {
1199
1277
  body: await renderToString(h(PrachtRuntimeProvider, {
1200
1278
  data: null,
1201
1279
  routeId: options.routeId,
1280
+ routes: options.routes,
1202
1281
  url: options.requestPath
1203
1282
  }, componentTree)),
1204
1283
  hydrationState: {
@@ -1326,6 +1405,7 @@ async function handlePrachtRequest(options) {
1326
1405
  if (hasDataParam) url.searchParams.delete("_data");
1327
1406
  const requestPath = getRequestPath(url);
1328
1407
  const registry = options.registry ?? {};
1408
+ const resolvedApp = getResolvedApp(options.app);
1329
1409
  const headerSignalsRouteState = options.request.headers.get(ROUTE_STATE_REQUEST_HEADER) === "1";
1330
1410
  const dataParamIsFirstParty = hasDataParam && isFirstPartyFetch(options.request);
1331
1411
  const isRouteStateRequest = headerSignalsRouteState || dataParamIsFirstParty;
@@ -1380,7 +1460,7 @@ async function handlePrachtRequest(options) {
1380
1460
  }
1381
1461
  }
1382
1462
  }
1383
- const match = matchAppRoute(options.app, url.pathname);
1463
+ const match = matchAppRoute(resolvedApp, url.pathname);
1384
1464
  if (!match) {
1385
1465
  if (isRouteStateRequest) return jsonErrorResponse(createSerializedRouteError("Not found", 404, {
1386
1466
  diagnostics: exposeDiagnostics ? buildRuntimeDiagnostics({
@@ -1462,11 +1542,18 @@ async function handlePrachtRequest(options) {
1462
1542
  const modulePreloadUrls = resolvePageJsUrls(options.jsManifest, match.route.shellFile, match.route.file);
1463
1543
  if (match.route.render === "spa") {
1464
1544
  let body = "";
1465
- if (shellModule?.Shell || shellModule?.Loading) {
1466
- const Shell = shellModule?.Shell;
1467
- const Loading = shellModule?.Loading;
1468
- const loadingTree = Shell != null ? h(Shell, null, Loading ? h(Loading, null) : null) : Loading ? h(Loading, null) : null;
1469
- if (loadingTree) body = await (await getRenderToStringAsync())(loadingTree);
1545
+ const Shell = shellModule?.Shell;
1546
+ const Loading = shellModule?.Loading;
1547
+ const loadingTree = Shell != null ? h(Shell, null, Loading ? h(Loading, null) : null) : Loading ? h(Loading, null) : null;
1548
+ if (loadingTree) {
1549
+ const tree = h(PrachtRuntimeProvider, {
1550
+ data: null,
1551
+ params: match.params,
1552
+ routeId: match.route.id ?? "",
1553
+ routes: resolvedApp.routes,
1554
+ url: requestPath
1555
+ }, loadingTree);
1556
+ body = await (await getRenderToStringAsync())(tree);
1470
1557
  }
1471
1558
  return htmlResponse(buildHtmlDocument({
1472
1559
  head,
@@ -1498,6 +1585,7 @@ async function handlePrachtRequest(options) {
1498
1585
  data,
1499
1586
  params: match.params,
1500
1587
  routeId: match.route.id ?? "",
1588
+ routes: resolvedApp.routes,
1501
1589
  url: requestPath
1502
1590
  }, componentTree);
1503
1591
  return htmlResponse(buildHtmlDocument({
@@ -1523,6 +1611,7 @@ async function handlePrachtRequest(options) {
1523
1611
  routeArgs,
1524
1612
  routeId: match.route.id ?? "",
1525
1613
  routeModule,
1614
+ routes: resolvedApp.routes,
1526
1615
  shellFile: match.route.shellFile,
1527
1616
  shellModule,
1528
1617
  requestPath
@@ -1532,8 +1621,24 @@ async function handlePrachtRequest(options) {
1532
1621
  function getRequestPath(url) {
1533
1622
  return `${url.pathname}${url.search}`;
1534
1623
  }
1624
+ function getResolvedApp(app) {
1625
+ const routes = app.routes;
1626
+ if (routes.length === 0 || isHrefRouteDefinition(routes[0])) return app;
1627
+ return resolveApp(app);
1628
+ }
1629
+ function isHrefRouteDefinition(value) {
1630
+ return Boolean(value && typeof value === "object" && "path" in value && "segments" in value && Array.isArray(value.segments));
1631
+ }
1535
1632
  //#endregion
1536
1633
  //#region src/prerender.ts
1634
+ const DANGEROUS_PRERENDER_HEADER_NAMES = new Set([
1635
+ "authorization",
1636
+ "proxy-authenticate",
1637
+ "proxy-authorization",
1638
+ "set-cookie",
1639
+ "www-authenticate"
1640
+ ]);
1641
+ const SECRET_SHAPED_PRERENDER_HEADER_RE = /^x-.*(?:api[-_]?key|client[-_]?secret|credential|jwt[-_]?secret|password|private[-_]?key|refresh[-_]?token|secret|session[-_]?secret|token|webhook[-_]?secret)(?:$|[-_])/i;
1537
1642
  async function prerenderApp(options) {
1538
1643
  const resolved = resolveApp(options.app);
1539
1644
  const results = [];
@@ -1567,6 +1672,7 @@ async function prerenderApp(options) {
1567
1672
  console.warn(` Warning: ${item.render.toUpperCase()} route "${item.pathname}" returned status ${response.status}, skipping.`);
1568
1673
  return null;
1569
1674
  }
1675
+ assertSafePrerenderHeaders(response.headers, item);
1570
1676
  const html = await response.text();
1571
1677
  return {
1572
1678
  headers: Object.fromEntries(response.headers),
@@ -1590,6 +1696,16 @@ async function prerenderApp(options) {
1590
1696
  };
1591
1697
  return results;
1592
1698
  }
1699
+ function assertSafePrerenderHeaders(headers, item) {
1700
+ const dangerous = [...headers.keys()].filter(isDangerousPrerenderHeader);
1701
+ if (dangerous.length === 0) return;
1702
+ const names = dangerous.map((name) => `"${name}"`).join(", ");
1703
+ throw new Error(`Refusing to prerender ${item.render.toUpperCase()} route "${item.pathname}" because its document headers include ${names}. SSG/ISG document headers are serialized into public static output and replayed for every visitor. Move cookies/authentication headers to API routes, loaders, middleware responses, or SSR-only routes.`);
1704
+ }
1705
+ function isDangerousPrerenderHeader(name) {
1706
+ const normalized = name.toLowerCase();
1707
+ return DANGEROUS_PRERENDER_HEADER_NAMES.has(normalized) || SECRET_SHAPED_PRERENDER_HEADER_RE.test(normalized);
1708
+ }
1593
1709
  async function collectSSGPaths(route, registry) {
1594
1710
  if (!route.segments.some((s) => s.type === "param" || s.type === "catchall")) return [route.path];
1595
1711
  const routeModule = await resolveRegistryModule(registry?.routeModules, route.file);
@@ -1708,6 +1824,7 @@ async function initClientRouter(options) {
1708
1824
  data,
1709
1825
  params,
1710
1826
  routeId,
1827
+ routes: app.routes,
1711
1828
  stateVersion: version,
1712
1829
  url
1713
1830
  }, componentTree));
@@ -1789,9 +1906,10 @@ async function initClientRouter(options) {
1789
1906
  activeNavigationAbort?.abort();
1790
1907
  const abortController = new AbortController();
1791
1908
  activeNavigationAbort = abortController;
1792
- const target = resolveBrowserRouteTarget(to);
1909
+ const navigationTarget = typeof to === "string" ? to : buildHref(app.routes, to.route, to);
1910
+ const target = resolveBrowserRouteTarget(navigationTarget);
1793
1911
  if (!target) {
1794
- window.location.href = to;
1912
+ window.location.href = navigationTarget;
1795
1913
  return;
1796
1914
  }
1797
1915
  const match = matchAppRoute(app, target.pathname);
@@ -1799,7 +1917,12 @@ async function initClientRouter(options) {
1799
1917
  window.location.href = target.browserUrl;
1800
1918
  return;
1801
1919
  }
1802
- const statePromise = getCachedRouteState(target.requestUrl) ?? fetchPrachtRouteState(target.requestUrl, { signal: abortController.signal });
1920
+ let statePromise;
1921
+ if (routeNeedsServerFetch(match.route)) statePromise = getCachedRouteState(target.requestUrl) ?? fetchPrachtRouteState(target.requestUrl, { signal: abortController.signal });
1922
+ else statePromise = Promise.resolve({
1923
+ type: "data",
1924
+ data: void 0
1925
+ });
1803
1926
  const routeModPromise = startRouteImport(match);
1804
1927
  const shellModPromise = startShellImport(match);
1805
1928
  let state = {
@@ -1963,4 +2086,4 @@ var PrachtHttpError = class extends Error {
1963
2086
  }
1964
2087
  };
1965
2088
  //#endregion
1966
- export { Form, PrachtHttpError, PrachtRuntimeProvider, Suspense, applyDefaultSecurityHeaders, buildPathFromSegments, defineApp, forwardRef, group, handlePrachtRequest, initClientRouter, lazy, matchApiRoute, matchAppRoute, prerenderApp, readHydrationState, resolveApiRoutes, resolveApp, route, startApp, timeRevalidate, useIsHydrated, useLocation, useNavigate, useParams, useRevalidate, useRouteData };
2089
+ export { Form, Link, PrachtHttpError, PrachtRuntimeProvider, Suspense, applyDefaultSecurityHeaders, buildHref, buildPathFromSegments, createHref, defineApp, forwardRef, group, handlePrachtRequest, initClientRouter, lazy, matchApiRoute, matchAppRoute, prerenderApp, readHydrationState, resolveApiRoutes, resolveApp, route, startApp, timeRevalidate, useIsHydrated, useLocation, useNavigate, useParams, useRevalidate, useRouteData };
package/package.json CHANGED
@@ -1,6 +1,19 @@
1
1
  {
2
2
  "name": "@pracht/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
+ "description": "Core runtime for Pracht, a full-stack Preact framework with per-route SSR, SSG, ISG, SPA, loaders, and API routes.",
5
+ "keywords": [
6
+ "pracht",
7
+ "preact",
8
+ "framework",
9
+ "full-stack",
10
+ "ssr",
11
+ "ssg",
12
+ "isg",
13
+ "routing",
14
+ "loaders",
15
+ "api-routes"
16
+ ],
4
17
  "license": "MIT",
5
18
  "homepage": "https://github.com/JoviDeCroock/pracht/tree/main/packages/framework",
6
19
  "bugs": {