@pracht/core 0.0.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.mjs ADDED
@@ -0,0 +1,837 @@
1
+ import { a as matchApiRoute, c as resolveApp, i as group, l as route, n as buildPathFromSegments, o as matchAppRoute, r as defineApp, s as resolveApiRoutes, u as timeRevalidate } from "./app-CAoDWWNO.mjs";
2
+ import { Suspense, lazy } from "preact-suspense";
3
+ import { createContext, h, hydrate, render } from "preact";
4
+ import { useContext, useEffect, useMemo, useState } from "preact/hooks";
5
+ //#region src/runtime.ts
6
+ const SAFE_METHODS = new Set(["GET", "HEAD"]);
7
+ const HYDRATION_STATE_ELEMENT_ID = "pracht-state";
8
+ const RouteDataContext = createContext(void 0);
9
+ function PrachtRuntimeProvider({ children, data, params = {}, routeId, url }) {
10
+ const [routeData, setRouteData] = useState(data);
11
+ useEffect(() => {
12
+ setRouteData(data);
13
+ }, [
14
+ data,
15
+ routeId,
16
+ url
17
+ ]);
18
+ const context = useMemo(() => ({
19
+ data: routeData,
20
+ params,
21
+ routeId,
22
+ setData: setRouteData,
23
+ url
24
+ }), [
25
+ routeData,
26
+ params,
27
+ routeId,
28
+ url
29
+ ]);
30
+ return h(RouteDataContext.Provider, {
31
+ value: context,
32
+ children
33
+ });
34
+ }
35
+ function startApp(options = {}) {
36
+ if (typeof window === "undefined") return options.initialData;
37
+ if (typeof options.initialData !== "undefined") return options.initialData;
38
+ return readHydrationState()?.data;
39
+ }
40
+ function readHydrationState() {
41
+ if (typeof window === "undefined") return;
42
+ if (window.__PRACHT_STATE__) return window.__PRACHT_STATE__;
43
+ const element = document.getElementById(HYDRATION_STATE_ELEMENT_ID);
44
+ if (!(element instanceof HTMLScriptElement)) return;
45
+ const raw = element.textContent;
46
+ if (!raw) return;
47
+ try {
48
+ const state = JSON.parse(raw);
49
+ window.__PRACHT_STATE__ = state;
50
+ return state;
51
+ } catch {
52
+ return;
53
+ }
54
+ }
55
+ function useRouteData() {
56
+ return useContext(RouteDataContext)?.data;
57
+ }
58
+ function useLocation() {
59
+ return { pathname: useContext(RouteDataContext)?.url ?? (typeof window !== "undefined" ? window.location.pathname : "/") };
60
+ }
61
+ function useParams() {
62
+ return useContext(RouteDataContext)?.params ?? {};
63
+ }
64
+ function useRevalidate() {
65
+ const runtime = useContext(RouteDataContext);
66
+ return async () => {
67
+ if (typeof window === "undefined") return;
68
+ const result = await fetchPrachtRouteState(runtime?.url || window.location.pathname + window.location.search);
69
+ if (result.type === "redirect") {
70
+ await navigateToClientLocation(result.location);
71
+ return;
72
+ }
73
+ if (result.type === "error") throw deserializeRouteError$1(result.error);
74
+ runtime?.setData(result.data);
75
+ return result.data;
76
+ };
77
+ }
78
+ /** @deprecated Use useRevalidate instead. */
79
+ const useRevalidateRoute = useRevalidate;
80
+ function Form(props) {
81
+ const { onSubmit, method, ...rest } = props;
82
+ return h("form", {
83
+ ...rest,
84
+ method,
85
+ onSubmit: async (event) => {
86
+ onSubmit?.(event);
87
+ if (event.defaultPrevented) return;
88
+ const form = event.currentTarget;
89
+ if (!(form instanceof HTMLFormElement)) return;
90
+ const formMethod = (method ?? form.method ?? "post").toUpperCase();
91
+ if (SAFE_METHODS.has(formMethod)) return;
92
+ event.preventDefault();
93
+ const response = await fetch(props.action ?? form.action, {
94
+ method: formMethod,
95
+ body: new FormData(form),
96
+ redirect: "manual"
97
+ });
98
+ if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) {
99
+ const location = response.headers.get("location");
100
+ if (location) {
101
+ await navigateToClientLocation(location);
102
+ return;
103
+ }
104
+ window.location.href = props.action ?? form.action;
105
+ }
106
+ }
107
+ });
108
+ }
109
+ async function fetchPrachtRouteState(url) {
110
+ const response = await fetch(url, {
111
+ headers: {
112
+ "x-pracht-route-state-request": "1",
113
+ "Cache-Control": "no-cache"
114
+ },
115
+ redirect: "manual"
116
+ });
117
+ if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) return {
118
+ location: response.headers.get("location") ?? url,
119
+ type: "redirect"
120
+ };
121
+ const json = await response.json();
122
+ if (!response.ok) {
123
+ if (json.error) return {
124
+ error: json.error,
125
+ type: "error"
126
+ };
127
+ throw new Error(`Failed to fetch route state (${response.status})`);
128
+ }
129
+ return {
130
+ data: json.data,
131
+ type: "data"
132
+ };
133
+ }
134
+ async function handlePrachtRequest(options) {
135
+ const url = new URL(options.request.url);
136
+ const registry = options.registry ?? {};
137
+ if (options.apiRoutes?.length) {
138
+ const apiMatch = matchApiRoute(options.apiRoutes, url.pathname);
139
+ if (apiMatch) {
140
+ const apiMiddlewareFiles = (options.app.api.middleware ?? []).flatMap((name) => {
141
+ const middlewareFile = options.app.middleware[name];
142
+ return middlewareFile ? [middlewareFile] : [];
143
+ });
144
+ const middlewareResult = await runMiddlewareChain({
145
+ context: options.context ?? {},
146
+ middlewareFiles: apiMiddlewareFiles,
147
+ params: apiMatch.params,
148
+ registry,
149
+ request: options.request,
150
+ route: apiMatch.route,
151
+ url
152
+ });
153
+ if (middlewareResult.response) return middlewareResult.response;
154
+ const apiModule = await resolveRegistryModule(registry.apiModules, apiMatch.route.file);
155
+ if (!apiModule) return withDefaultSecurityHeaders(new Response("API route module not found", {
156
+ status: 500,
157
+ headers: { "content-type": "text/plain; charset=utf-8" }
158
+ }));
159
+ const handler = apiModule[options.request.method.toUpperCase()];
160
+ if (!handler) return withDefaultSecurityHeaders(new Response("Method not allowed", {
161
+ status: 405,
162
+ headers: { "content-type": "text/plain; charset=utf-8" }
163
+ }));
164
+ return withDefaultSecurityHeaders(await handler({
165
+ request: options.request,
166
+ params: apiMatch.params,
167
+ context: middlewareResult.context,
168
+ signal: AbortSignal.timeout(3e4),
169
+ url,
170
+ route: apiMatch.route
171
+ }));
172
+ }
173
+ }
174
+ const match = matchAppRoute(options.app, url.pathname);
175
+ if (!match) return withDefaultSecurityHeaders(new Response("Not found", {
176
+ status: 404,
177
+ headers: { "content-type": "text/plain; charset=utf-8" }
178
+ }));
179
+ const isRouteStateRequest = options.request.headers.get("x-pracht-route-state-request") === "1";
180
+ if (!SAFE_METHODS.has(options.request.method)) return withDefaultSecurityHeaders(new Response("Method not allowed", {
181
+ status: 405,
182
+ headers: { "content-type": "text/plain; charset=utf-8" }
183
+ }));
184
+ const middlewareResult = await runMiddlewareChain({
185
+ context: options.context ?? {},
186
+ middlewareFiles: match.route.middlewareFiles,
187
+ params: match.params,
188
+ registry,
189
+ request: options.request,
190
+ route: match.route,
191
+ url
192
+ });
193
+ if (middlewareResult.response) return middlewareResult.response;
194
+ const context = middlewareResult.context;
195
+ const routeModule = await resolveRegistryModule(registry.routeModules, match.route.file);
196
+ const routeArgs = {
197
+ request: options.request,
198
+ params: match.params,
199
+ context,
200
+ signal: AbortSignal.timeout(3e4),
201
+ url,
202
+ route: match.route
203
+ };
204
+ const { loader } = await resolveDataFunctions(match.route, routeModule, registry);
205
+ let shellModule;
206
+ try {
207
+ const loaderResult = loader ? await loader(routeArgs) : void 0;
208
+ if (loaderResult instanceof Response) return withDefaultSecurityHeaders(loaderResult);
209
+ const data = loaderResult;
210
+ if (isRouteStateRequest) return withDefaultSecurityHeaders(Response.json({ data }));
211
+ shellModule = match.route.shellFile ? await resolveRegistryModule(registry.shellModules, match.route.shellFile) : void 0;
212
+ const head = await mergeHeadMetadata(shellModule, routeModule, routeArgs, data);
213
+ const cssUrls = resolvePageCssUrls(options, match.route.shellFile, match.route.file);
214
+ const modulePreloadUrls = resolvePageJsUrls(options, match.route.shellFile, match.route.file);
215
+ if (match.route.render === "spa") return htmlResponse(buildHtmlDocument({
216
+ head,
217
+ body: "",
218
+ hydrationState: {
219
+ url: url.pathname,
220
+ routeId: match.route.id ?? "",
221
+ data: null,
222
+ error: null
223
+ },
224
+ clientEntryUrl: options.clientEntryUrl,
225
+ cssUrls,
226
+ modulePreloadUrls
227
+ }));
228
+ if (!routeModule?.Component) return withDefaultSecurityHeaders(new Response("Route has no Component export", {
229
+ status: 500,
230
+ headers: { "content-type": "text/plain; charset=utf-8" }
231
+ }));
232
+ const { renderToStringAsync } = await import("preact-render-to-string");
233
+ const Component = routeModule.Component;
234
+ const Shell = shellModule?.Shell;
235
+ const componentProps = {
236
+ data,
237
+ params: match.params
238
+ };
239
+ const componentTree = Shell ? h(Shell, null, h(Component, componentProps)) : h(Component, componentProps);
240
+ return htmlResponse(buildHtmlDocument({
241
+ head,
242
+ body: await renderToStringAsync(h(PrachtRuntimeProvider, {
243
+ data,
244
+ params: match.params,
245
+ routeId: match.route.id ?? "",
246
+ url: url.pathname
247
+ }, componentTree)),
248
+ hydrationState: {
249
+ url: url.pathname,
250
+ routeId: match.route.id ?? "",
251
+ data,
252
+ error: null
253
+ },
254
+ clientEntryUrl: options.clientEntryUrl,
255
+ cssUrls,
256
+ modulePreloadUrls
257
+ }));
258
+ } catch (error) {
259
+ return renderRouteErrorResponse({
260
+ error,
261
+ isRouteStateRequest,
262
+ options,
263
+ routeArgs,
264
+ routeId: match.route.id ?? "",
265
+ routeModule,
266
+ shellFile: match.route.shellFile,
267
+ shellModule,
268
+ urlPathname: url.pathname
269
+ });
270
+ }
271
+ }
272
+ function resolvePageCssUrls(options, shellFile, routeFile) {
273
+ if (!options.cssManifest) return options.cssUrls ?? [];
274
+ const css = /* @__PURE__ */ new Set();
275
+ function addFromManifest(file) {
276
+ const suffix = file.replace(/^\.\//, "");
277
+ for (const [key, cssFiles] of Object.entries(options.cssManifest)) if (key === file || key.endsWith(`/${suffix}`) || key.endsWith(suffix)) {
278
+ for (const c of cssFiles) css.add(c);
279
+ break;
280
+ }
281
+ }
282
+ if (shellFile) addFromManifest(shellFile);
283
+ addFromManifest(routeFile);
284
+ return [...css];
285
+ }
286
+ function resolvePageJsUrls(options, shellFile, routeFile) {
287
+ if (!options.jsManifest) return [];
288
+ const js = /* @__PURE__ */ new Set();
289
+ function addFromManifest(file) {
290
+ const suffix = file.replace(/^\.\//, "");
291
+ for (const [key, jsFiles] of Object.entries(options.jsManifest)) if (key === file || key.endsWith(`/${suffix}`) || key.endsWith(suffix)) {
292
+ for (const j of jsFiles) js.add(j);
293
+ break;
294
+ }
295
+ }
296
+ if (shellFile) addFromManifest(shellFile);
297
+ addFromManifest(routeFile);
298
+ return [...js];
299
+ }
300
+ async function navigateToClientLocation(location, options) {
301
+ if (typeof window === "undefined") return;
302
+ const targetUrl = new URL(location, window.location.href);
303
+ const target = targetUrl.pathname + targetUrl.search + targetUrl.hash;
304
+ if (targetUrl.origin === window.location.origin && window.__PRACHT_NAVIGATE__) {
305
+ await window.__PRACHT_NAVIGATE__(target, options);
306
+ return;
307
+ }
308
+ if (options?.replace) {
309
+ window.location.replace(targetUrl.toString());
310
+ return;
311
+ }
312
+ window.location.href = targetUrl.toString();
313
+ }
314
+ function isPrachtHttpError(error) {
315
+ return error instanceof Error && error.name === "PrachtHttpError" && "status" in error;
316
+ }
317
+ function normalizeRouteError(error) {
318
+ if (isPrachtHttpError(error)) return {
319
+ message: error.message,
320
+ name: error.name,
321
+ status: typeof error.status === "number" ? error.status : 500
322
+ };
323
+ if (error instanceof Error) return {
324
+ message: error.message || "Internal Server Error",
325
+ name: error.name || "Error",
326
+ status: 500
327
+ };
328
+ return {
329
+ message: "Internal Server Error",
330
+ name: "Error",
331
+ status: 500
332
+ };
333
+ }
334
+ function deserializeRouteError$1(error) {
335
+ const result = new Error(error.message);
336
+ result.name = error.name;
337
+ result.status = error.status;
338
+ return result;
339
+ }
340
+ async function renderRouteErrorResponse(options) {
341
+ const routeError = normalizeRouteError(options.error);
342
+ if (!options.routeModule?.ErrorBoundary) {
343
+ if (options.isRouteStateRequest) return withDefaultSecurityHeaders(new Response(JSON.stringify({ error: routeError }), {
344
+ status: routeError.status,
345
+ headers: { "content-type": "application/json; charset=utf-8" }
346
+ }));
347
+ const message = routeError.status >= 500 ? "Internal Server Error" : routeError.message;
348
+ return withDefaultSecurityHeaders(new Response(message, {
349
+ status: routeError.status,
350
+ headers: { "content-type": "text/plain; charset=utf-8" }
351
+ }));
352
+ }
353
+ if (options.isRouteStateRequest) return withDefaultSecurityHeaders(new Response(JSON.stringify({ error: routeError }), {
354
+ status: routeError.status,
355
+ headers: { "content-type": "application/json; charset=utf-8" }
356
+ }));
357
+ const shellModule = options.shellModule ?? (options.shellFile ? await resolveRegistryModule(options.options.registry?.shellModules, options.shellFile) : void 0);
358
+ const head = shellModule?.head ? await shellModule.head(options.routeArgs) : {};
359
+ const cssUrls = resolvePageCssUrls(options.options, options.shellFile, options.routeArgs.route.file);
360
+ const modulePreloadUrls = resolvePageJsUrls(options.options, options.shellFile, options.routeArgs.route.file);
361
+ const { renderToStringAsync } = await import("preact-render-to-string");
362
+ const ErrorBoundary = options.routeModule.ErrorBoundary;
363
+ const Shell = shellModule?.Shell;
364
+ const errorValue = deserializeRouteError$1(routeError);
365
+ const componentTree = Shell ? h(Shell, null, h(ErrorBoundary, { error: errorValue })) : h(ErrorBoundary, { error: errorValue });
366
+ return htmlResponse(buildHtmlDocument({
367
+ head,
368
+ body: await renderToStringAsync(h(PrachtRuntimeProvider, {
369
+ data: null,
370
+ routeId: options.routeId,
371
+ url: options.urlPathname
372
+ }, componentTree)),
373
+ hydrationState: {
374
+ url: options.urlPathname,
375
+ routeId: options.routeId,
376
+ data: null,
377
+ error: routeError
378
+ },
379
+ clientEntryUrl: options.options.clientEntryUrl,
380
+ cssUrls,
381
+ modulePreloadUrls
382
+ }), routeError.status);
383
+ }
384
+ async function runMiddlewareChain(options) {
385
+ let context = options.context;
386
+ for (const mwFile of options.middlewareFiles) {
387
+ const mwModule = await resolveRegistryModule(options.registry.middlewareModules, mwFile);
388
+ if (!mwModule?.middleware) continue;
389
+ const result = await mwModule.middleware({
390
+ request: options.request,
391
+ params: options.params,
392
+ context,
393
+ signal: AbortSignal.timeout(3e4),
394
+ url: options.url,
395
+ route: options.route
396
+ });
397
+ if (!result) continue;
398
+ if (result instanceof Response) return { response: withDefaultSecurityHeaders(result) };
399
+ if ("redirect" in result) return { response: withDefaultSecurityHeaders(new Response(null, {
400
+ status: 302,
401
+ headers: { location: result.redirect }
402
+ })) };
403
+ if ("context" in result) context = {
404
+ ...context,
405
+ ...result.context
406
+ };
407
+ }
408
+ return { context };
409
+ }
410
+ async function resolveDataFunctions(route, routeModule, registry) {
411
+ let loader = routeModule?.loader;
412
+ if (route.loaderFile) {
413
+ const dataModule = await resolveRegistryModule(registry.dataModules, route.loaderFile);
414
+ if (dataModule?.loader) loader = dataModule.loader;
415
+ }
416
+ return { loader };
417
+ }
418
+ async function resolveRegistryModule(modules, file) {
419
+ if (!modules) return void 0;
420
+ if (file in modules) return modules[file]();
421
+ const suffix = file.replace(/^\.\//, "");
422
+ for (const key of Object.keys(modules)) if (key.endsWith(`/${suffix}`) || key.endsWith(suffix)) return modules[key]();
423
+ }
424
+ async function mergeHeadMetadata(shellModule, routeModule, routeArgs, data) {
425
+ const shellHead = shellModule?.head ? await shellModule.head(routeArgs) : {};
426
+ const routeHead = routeModule?.head ? await routeModule.head({
427
+ ...routeArgs,
428
+ data
429
+ }) : {};
430
+ return {
431
+ title: routeHead.title ?? shellHead.title,
432
+ lang: routeHead.lang ?? shellHead.lang,
433
+ meta: [...shellHead.meta ?? [], ...routeHead.meta ?? []],
434
+ link: [...shellHead.link ?? [], ...routeHead.link ?? []]
435
+ };
436
+ }
437
+ function buildHtmlDocument(options) {
438
+ const { head, body, hydrationState, clientEntryUrl, cssUrls = [], modulePreloadUrls = [] } = options;
439
+ const titleTag = head.title ? `<title>${escapeHtml(head.title)}</title>` : "";
440
+ const metaTags = (head.meta ?? []).map((m) => `<meta ${Object.entries(m).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ")}>`).join("\n ");
441
+ const linkTags = (head.link ?? []).map((l) => `<link ${Object.entries(l).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ")}>`).join("\n ");
442
+ const cssTags = cssUrls.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join("\n ");
443
+ const modulePreloadTags = modulePreloadUrls.map((url) => `<link rel="modulepreload" href="${escapeHtml(url)}">`).join("\n ");
444
+ const stateScript = `<script id="${HYDRATION_STATE_ELEMENT_ID}" type="application/json">${serializeJsonForHtml(hydrationState)}<\/script>`;
445
+ const entryScript = clientEntryUrl ? `<script type="module" src="${escapeHtml(clientEntryUrl)}"><\/script>` : "";
446
+ return `<!DOCTYPE html>
447
+ <html${head.lang ? ` lang="${escapeHtml(head.lang)}"` : ""}>
448
+ <head>
449
+ <meta charset="utf-8">
450
+ ${titleTag}
451
+ ${metaTags}
452
+ ${linkTags}
453
+ ${cssTags}
454
+ ${modulePreloadTags}
455
+ </head>
456
+ <body>
457
+ <div id="pracht-root">${body}</div>
458
+ ${stateScript}
459
+ ${entryScript}
460
+ </body>
461
+ </html>`;
462
+ }
463
+ function htmlResponse(html, status = 200) {
464
+ return withDefaultSecurityHeaders(new Response(html, {
465
+ status,
466
+ headers: { "content-type": "text/html; charset=utf-8" }
467
+ }));
468
+ }
469
+ function applyDefaultSecurityHeaders(headers) {
470
+ if (!headers.has("permissions-policy")) headers.set("permissions-policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()");
471
+ if (!headers.has("referrer-policy")) headers.set("referrer-policy", "strict-origin-when-cross-origin");
472
+ if (!headers.has("x-content-type-options")) headers.set("x-content-type-options", "nosniff");
473
+ if (!headers.has("x-frame-options")) headers.set("x-frame-options", "SAMEORIGIN");
474
+ return headers;
475
+ }
476
+ function withDefaultSecurityHeaders(response) {
477
+ const headers = applyDefaultSecurityHeaders(new Headers(response.headers));
478
+ return new Response(response.body, {
479
+ status: response.status,
480
+ statusText: response.statusText,
481
+ headers
482
+ });
483
+ }
484
+ function escapeHtml(str) {
485
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
486
+ }
487
+ function serializeJsonForHtml(value) {
488
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
489
+ }
490
+ async function prerenderApp(options) {
491
+ const { resolveApp } = await import("./app-CAoDWWNO.mjs").then((n) => n.t);
492
+ const resolved = resolveApp(options.app);
493
+ const results = [];
494
+ const isgManifest = {};
495
+ for (const route of resolved.routes) {
496
+ if (route.render !== "ssg" && route.render !== "isg") continue;
497
+ const paths = await collectSSGPaths(route, options.registry);
498
+ for (const pathname of paths) {
499
+ const url = new URL(pathname, "http://localhost");
500
+ const request = new Request(url, { method: "GET" });
501
+ const response = await handlePrachtRequest({
502
+ app: options.app,
503
+ request,
504
+ registry: options.registry,
505
+ clientEntryUrl: options.clientEntryUrl,
506
+ cssManifest: options.cssManifest
507
+ });
508
+ if (response.status !== 200) {
509
+ console.warn(` Warning: ${route.render.toUpperCase()} route "${pathname}" returned status ${response.status}, skipping.`);
510
+ continue;
511
+ }
512
+ const html = await response.text();
513
+ results.push({
514
+ path: pathname,
515
+ html
516
+ });
517
+ if (route.render === "isg" && route.revalidate) isgManifest[pathname] = { revalidate: route.revalidate };
518
+ }
519
+ }
520
+ if (options.withISGManifest) return {
521
+ pages: results,
522
+ isgManifest
523
+ };
524
+ return results;
525
+ }
526
+ async function collectSSGPaths(route, registry) {
527
+ if (!route.segments.some((s) => s.type === "param" || s.type === "catchall")) return [route.path];
528
+ const routeModule = await resolveRegistryModule(registry?.routeModules, route.file);
529
+ if (!routeModule?.getStaticPaths) {
530
+ console.warn(` Warning: SSG route "${route.path}" has dynamic segments but no getStaticPaths() export, skipping.`);
531
+ return [];
532
+ }
533
+ const { buildPathFromSegments } = await import("./app-CAoDWWNO.mjs").then((n) => n.t);
534
+ return (await routeModule.getStaticPaths()).map((params) => buildPathFromSegments(route.segments, params));
535
+ }
536
+ //#endregion
537
+ //#region src/prefetch.ts
538
+ const CACHE_TTL_MS = 3e4;
539
+ const prefetchCache = /* @__PURE__ */ new Map();
540
+ function getCachedRouteState(url) {
541
+ const entry = prefetchCache.get(url);
542
+ if (!entry) return null;
543
+ if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
544
+ prefetchCache.delete(url);
545
+ return null;
546
+ }
547
+ return entry.promise;
548
+ }
549
+ function prefetchRouteState(url) {
550
+ const cached = getCachedRouteState(url);
551
+ if (cached) return cached;
552
+ const promise = fetchPrachtRouteState(url);
553
+ prefetchCache.set(url, {
554
+ promise,
555
+ timestamp: Date.now()
556
+ });
557
+ return promise;
558
+ }
559
+ function setupPrefetching(app) {
560
+ let hoverTimer = null;
561
+ function getInternalHref(anchor) {
562
+ const href = anchor.getAttribute("href");
563
+ if (!href || href.startsWith("#")) return null;
564
+ let url;
565
+ try {
566
+ url = new URL(href, window.location.origin);
567
+ } catch {
568
+ return null;
569
+ }
570
+ if (url.origin !== window.location.origin) return null;
571
+ return url.pathname + url.search;
572
+ }
573
+ function getPrefetchStrategy(pathname) {
574
+ const match = matchAppRoute(app, pathname);
575
+ if (!match) return "none";
576
+ if (match.route.prefetch) return match.route.prefetch;
577
+ if (match.route.render === "spa") return "none";
578
+ return "intent";
579
+ }
580
+ document.addEventListener("mouseenter", (e) => {
581
+ const anchor = e.target.closest?.("a");
582
+ if (!anchor) return;
583
+ const href = getInternalHref(anchor);
584
+ if (!href) return;
585
+ const strategy = getPrefetchStrategy(href);
586
+ if (strategy !== "hover" && strategy !== "intent") return;
587
+ if (hoverTimer) clearTimeout(hoverTimer);
588
+ hoverTimer = setTimeout(() => {
589
+ prefetchRouteState(href);
590
+ }, 50);
591
+ }, true);
592
+ document.addEventListener("mouseleave", (e) => {
593
+ if (!e.target.closest?.("a")) return;
594
+ if (hoverTimer) {
595
+ clearTimeout(hoverTimer);
596
+ hoverTimer = null;
597
+ }
598
+ }, true);
599
+ document.addEventListener("focusin", (e) => {
600
+ const anchor = e.target.closest?.("a");
601
+ if (!anchor) return;
602
+ const href = getInternalHref(anchor);
603
+ if (!href) return;
604
+ const strategy = getPrefetchStrategy(href);
605
+ if (strategy !== "hover" && strategy !== "intent") return;
606
+ prefetchRouteState(href);
607
+ }, true);
608
+ if (typeof IntersectionObserver === "undefined") return;
609
+ const observer = new IntersectionObserver((entries) => {
610
+ for (const entry of entries) {
611
+ if (!entry.isIntersecting) continue;
612
+ const anchor = entry.target;
613
+ const href = getInternalHref(anchor);
614
+ if (!href) continue;
615
+ prefetchRouteState(href);
616
+ observer.unobserve(anchor);
617
+ }
618
+ }, { rootMargin: "200px" });
619
+ function observeViewportLinks() {
620
+ const anchors = document.querySelectorAll("a[href]");
621
+ for (const anchor of anchors) {
622
+ const href = getInternalHref(anchor);
623
+ if (!href) continue;
624
+ if (getPrefetchStrategy(href) !== "viewport") continue;
625
+ observer.observe(anchor);
626
+ }
627
+ }
628
+ observeViewportLinks();
629
+ new MutationObserver(() => {
630
+ observeViewportLinks();
631
+ }).observe(document.body, {
632
+ childList: true,
633
+ subtree: true
634
+ });
635
+ }
636
+ //#endregion
637
+ //#region src/router.ts
638
+ const NavigateContext = createContext(async () => {});
639
+ function useNavigate() {
640
+ return useContext(NavigateContext);
641
+ }
642
+ async function initClientRouter(options) {
643
+ const { app, routeModules, shellModules, root, findModuleKey } = options;
644
+ async function buildRouteTree(match, state) {
645
+ const routeKey = findModuleKey(routeModules, match.route.file);
646
+ if (!routeKey) return null;
647
+ const routeMod = await routeModules[routeKey]();
648
+ let Shell = null;
649
+ if (match.route.shellFile) {
650
+ const shellKey = findModuleKey(shellModules, match.route.shellFile);
651
+ if (shellKey) Shell = (await shellModules[shellKey]()).Shell;
652
+ }
653
+ const Component = state.error ? routeMod.ErrorBoundary : routeMod.Component;
654
+ if (!Component) return null;
655
+ const props = state.error ? { error: deserializeRouteError(state.error) } : {
656
+ data: state.data,
657
+ params: match.params
658
+ };
659
+ const componentTree = Shell ? h(Shell, null, h(Component, props)) : h(Component, props);
660
+ return h(NavigateContext.Provider, { value: navigate }, h(PrachtRuntimeProvider, {
661
+ data: state.data,
662
+ params: match.params,
663
+ routeId: match.route.id ?? "",
664
+ url: match.pathname
665
+ }, componentTree));
666
+ }
667
+ async function navigate(to, opts) {
668
+ const match = matchAppRoute(app, to);
669
+ if (!match) {
670
+ window.location.href = to;
671
+ return;
672
+ }
673
+ let state = {
674
+ data: void 0,
675
+ error: null
676
+ };
677
+ try {
678
+ const result = await (getCachedRouteState(to) ?? fetchPrachtRouteState(to));
679
+ if (result.type === "redirect") {
680
+ if (result.location) {
681
+ await navigate(result.location, opts);
682
+ return;
683
+ }
684
+ window.location.href = to;
685
+ return;
686
+ }
687
+ if (result.type === "error") state = {
688
+ data: void 0,
689
+ error: result.error
690
+ };
691
+ else state = {
692
+ data: result.data,
693
+ error: null
694
+ };
695
+ } catch {
696
+ window.location.href = to;
697
+ return;
698
+ }
699
+ if (!opts?._popstate) if (opts?.replace) history.replaceState(null, "", to);
700
+ else history.pushState(null, "", to);
701
+ const tree = await buildRouteTree(match, state);
702
+ if (tree) {
703
+ render(tree, root);
704
+ window.scrollTo(0, 0);
705
+ } else window.location.href = to;
706
+ }
707
+ if (import.meta.env?.DEV) {
708
+ const prev = options.__m;
709
+ options.__m = (vnode, s) => {
710
+ const message = `Hydration mismatch in <${(typeof vnode.type === "function" ? vnode.type.displayName || vnode.type.name : vnode.type) || "Unknown"}>: ${s}`;
711
+ console.warn(`[pracht] ${message}`);
712
+ appendHydrationWarning(message);
713
+ if (prev) prev(vnode, s);
714
+ };
715
+ }
716
+ const initialMatch = matchAppRoute(app, options.initialState.url);
717
+ if (initialMatch) {
718
+ let state = {
719
+ data: options.initialState.data,
720
+ error: options.initialState.error ?? null
721
+ };
722
+ if (initialMatch.route.render === "spa" && state.data == null && !state.error) try {
723
+ const result = await fetchPrachtRouteState(options.initialState.url);
724
+ if (result.type === "redirect") {
725
+ window.location.href = result.location;
726
+ return;
727
+ }
728
+ if (result.type === "error") state = {
729
+ data: void 0,
730
+ error: result.error
731
+ };
732
+ else state = {
733
+ data: result.data,
734
+ error: null
735
+ };
736
+ } catch {
737
+ window.location.href = options.initialState.url;
738
+ return;
739
+ }
740
+ const tree = await buildRouteTree(initialMatch, state);
741
+ if (tree) if (initialMatch.route.render === "spa") render(tree, root);
742
+ else hydrate(tree, root);
743
+ }
744
+ document.addEventListener("click", (e) => {
745
+ const anchor = e.target.closest?.("a");
746
+ if (!anchor) return;
747
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
748
+ if (e.defaultPrevented) return;
749
+ if (e.button !== 0) return;
750
+ const target = anchor.getAttribute("target");
751
+ if (target && target !== "_self") return;
752
+ if (anchor.hasAttribute("download")) return;
753
+ const href = anchor.getAttribute("href");
754
+ if (!href || href.startsWith("#")) return;
755
+ let url;
756
+ try {
757
+ url = new URL(href, window.location.origin);
758
+ } catch {
759
+ return;
760
+ }
761
+ if (url.origin !== window.location.origin) return;
762
+ e.preventDefault();
763
+ navigate(url.pathname + url.search);
764
+ });
765
+ window.addEventListener("popstate", () => {
766
+ navigate(window.location.pathname + window.location.search, { _popstate: true });
767
+ });
768
+ window.__PRACHT_NAVIGATE__ = navigate;
769
+ window.__PRACHT_ROUTER_READY__ = true;
770
+ setupPrefetching(app);
771
+ }
772
+ const HYDRATION_BANNER_ID = "__pracht_hydration_warnings__";
773
+ function appendHydrationWarning(message) {
774
+ let container = document.getElementById(HYDRATION_BANNER_ID);
775
+ if (!container) {
776
+ container = document.createElement("div");
777
+ container.id = HYDRATION_BANNER_ID;
778
+ Object.assign(container.style, {
779
+ position: "fixed",
780
+ bottom: "0",
781
+ left: "0",
782
+ right: "0",
783
+ maxHeight: "30vh",
784
+ overflow: "auto",
785
+ background: "#2d1b00",
786
+ borderTop: "2px solid #f0ad4e",
787
+ color: "#ffc107",
788
+ fontFamily: "ui-monospace, Consolas, monospace",
789
+ fontSize: "13px",
790
+ padding: "12px 16px",
791
+ zIndex: "2147483647"
792
+ });
793
+ const header = document.createElement("div");
794
+ Object.assign(header.style, {
795
+ display: "flex",
796
+ justifyContent: "space-between",
797
+ alignItems: "center",
798
+ marginBottom: "8px"
799
+ });
800
+ header.innerHTML = "<strong style=\"color:#f0ad4e\">⚠ Hydration Mismatches</strong>";
801
+ const close = document.createElement("button");
802
+ close.textContent = "×";
803
+ Object.assign(close.style, {
804
+ background: "none",
805
+ border: "none",
806
+ color: "#f0ad4e",
807
+ fontSize: "18px",
808
+ cursor: "pointer"
809
+ });
810
+ close.onclick = () => container.remove();
811
+ header.appendChild(close);
812
+ container.appendChild(header);
813
+ document.body.appendChild(container);
814
+ }
815
+ const entry = document.createElement("div");
816
+ entry.textContent = message;
817
+ Object.assign(entry.style, { padding: "2px 0" });
818
+ container.appendChild(entry);
819
+ }
820
+ function deserializeRouteError(error) {
821
+ const result = new Error(error.message);
822
+ result.name = error.name;
823
+ result.status = error.status;
824
+ return result;
825
+ }
826
+ //#endregion
827
+ //#region src/types.ts
828
+ var PrachtHttpError = class extends Error {
829
+ status;
830
+ constructor(status, message) {
831
+ super(message);
832
+ this.name = "PrachtHttpError";
833
+ this.status = status;
834
+ }
835
+ };
836
+ //#endregion
837
+ export { Form, PrachtHttpError, PrachtRuntimeProvider, Suspense, applyDefaultSecurityHeaders, buildPathFromSegments, defineApp, group, handlePrachtRequest, initClientRouter, lazy, matchApiRoute, matchAppRoute, prerenderApp, readHydrationState, resolveApiRoutes, resolveApp, route, startApp, timeRevalidate, useLocation, useNavigate, useParams, useRevalidate, useRevalidateRoute, useRouteData };