@pracht/core 0.6.1 → 0.8.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 CHANGED
@@ -1,1966 +1,8 @@
1
- import { createContext, h, hydrate, options, render } from "preact";
2
- import { useContext, useEffect, useMemo, useState } from "preact/hooks";
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, t as buildHref, u as timeRevalidate } from "./app-BvC1uQG5.mjs";
2
+ import { S as createHref, _ as applyDefaultSecurityHeaders, a as useParams, i as useLocation, n as Form, o as useRevalidate, r as Link, s as useRouteData, t as PrachtHttpError, u as redirect } from "./types-idmK5omD.mjs";
3
+ import { t as forwardRef } from "./forwardRef-grZ6t4GS.mjs";
4
+ import { n as useNavigate, r as useIsHydrated, t as initClientRouter } from "./router-B7J4YYwg.mjs";
5
+ import { i as startApp, r as readHydrationState, t as PrachtRuntimeProvider } from "./runtime-context-B5pREhcM.mjs";
6
+ import { n as handlePrachtRequest, t as prerenderApp } from "./prerender-D3E2H96p.mjs";
3
7
  import { Suspense, lazy } from "preact-suspense";
4
- //#region src/app.ts
5
- function timeRevalidate(seconds) {
6
- if (!Number.isInteger(seconds) || seconds <= 0) throw new Error("timeRevalidate expects a positive integer number of seconds.");
7
- return {
8
- kind: "time",
9
- seconds
10
- };
11
- }
12
- function route(path, fileOrConfig, meta = {}) {
13
- if (typeof fileOrConfig === "string" || typeof fileOrConfig === "function") return {
14
- kind: "route",
15
- path: normalizeRoutePath(path),
16
- file: resolveModuleRef(fileOrConfig),
17
- ...meta
18
- };
19
- const { component, loader, ...routeMeta } = fileOrConfig;
20
- return {
21
- kind: "route",
22
- path: normalizeRoutePath(path),
23
- file: resolveModuleRef(component),
24
- loaderFile: resolveModuleRef(loader),
25
- ...routeMeta
26
- };
27
- }
28
- function resolveModuleRef(ref) {
29
- if (ref === void 0) return void 0;
30
- if (typeof ref === "string") return ref;
31
- return "";
32
- }
33
- function group(meta, routes) {
34
- return {
35
- kind: "group",
36
- meta,
37
- routes
38
- };
39
- }
40
- function defineApp(config) {
41
- return {
42
- shells: resolveModuleRefRecord(config.shells ?? {}),
43
- middleware: resolveModuleRefRecord(config.middleware ?? {}),
44
- api: config.api ?? {},
45
- routes: config.routes
46
- };
47
- }
48
- function resolveModuleRefRecord(record) {
49
- const result = {};
50
- for (const [key, value] of Object.entries(record)) result[key] = resolveModuleRef(value);
51
- return result;
52
- }
53
- function resolveApp(app) {
54
- const routes = [];
55
- const inherited = {
56
- pathPrefix: "/",
57
- middleware: []
58
- };
59
- for (const node of app.routes) flattenRouteNode(app, node, inherited, routes);
60
- return {
61
- shells: app.shells,
62
- middleware: app.middleware,
63
- api: app.api,
64
- routes,
65
- apiRoutes: []
66
- };
67
- }
68
- function matchAppRoute(app, pathname) {
69
- const resolved = isResolvedApp(app) ? app : resolveApp(app);
70
- const normalizedPathname = normalizeRoutePath(pathname);
71
- const targetSegments = splitPathSegments(normalizedPathname);
72
- for (const currentRoute of resolved.routes) {
73
- const params = matchRouteSegments(currentRoute.segments, targetSegments);
74
- if (params) return {
75
- route: currentRoute,
76
- params,
77
- pathname: normalizedPathname
78
- };
79
- }
80
- }
81
- function flattenRouteNode(app, node, inherited, routes) {
82
- if (node.kind === "group") {
83
- const nextInherited = {
84
- pathPrefix: mergeRoutePaths(inherited.pathPrefix, node.meta.pathPrefix),
85
- shell: node.meta.shell ?? inherited.shell,
86
- render: node.meta.render ?? inherited.render,
87
- middleware: [...inherited.middleware, ...node.meta.middleware ?? []]
88
- };
89
- for (const child of node.routes) flattenRouteNode(app, child, nextInherited, routes);
90
- return;
91
- }
92
- const fullPath = mergeRoutePaths(inherited.pathPrefix, node.path);
93
- const shell = node.shell ?? inherited.shell;
94
- const middleware = [...inherited.middleware, ...node.middleware ?? []];
95
- routes.push({
96
- id: node.id ?? createRouteId(fullPath),
97
- path: fullPath,
98
- file: node.file,
99
- loaderFile: node.loaderFile,
100
- shell,
101
- shellFile: shell ? app.shells[shell] : void 0,
102
- render: node.render ?? inherited.render,
103
- middleware,
104
- middlewareFiles: middleware.flatMap((name) => {
105
- const middlewareFile = app.middleware[name];
106
- return middlewareFile ? [middlewareFile] : [];
107
- }),
108
- revalidate: node.revalidate,
109
- segments: parseRouteSegments(fullPath)
110
- });
111
- }
112
- function isResolvedApp(app) {
113
- return app.routes.length === 0 || "segments" in app.routes[0];
114
- }
115
- function matchRouteSegments(routeSegments, targetSegments) {
116
- const params = {};
117
- let routeIndex = 0;
118
- let targetIndex = 0;
119
- while (routeIndex < routeSegments.length) {
120
- const currentSegment = routeSegments[routeIndex];
121
- if (currentSegment.type === "catchall") {
122
- try {
123
- params[currentSegment.name] = targetSegments.slice(targetIndex).map(decodeURIComponent).join("/");
124
- } catch {
125
- return null;
126
- }
127
- return params;
128
- }
129
- const targetSegment = targetSegments[targetIndex];
130
- if (typeof targetSegment === "undefined") return null;
131
- if (currentSegment.type === "static") {
132
- if (currentSegment.value !== targetSegment) return null;
133
- } else try {
134
- params[currentSegment.name] = decodeURIComponent(targetSegment);
135
- } catch {
136
- return null;
137
- }
138
- routeIndex += 1;
139
- targetIndex += 1;
140
- }
141
- return targetIndex === targetSegments.length ? params : null;
142
- }
143
- function parseRouteSegments(path) {
144
- return splitPathSegments(path).map((segment) => {
145
- if (segment === "*") return {
146
- type: "catchall",
147
- name: "*"
148
- };
149
- if (segment.startsWith(":") && segment.endsWith("*")) return {
150
- type: "catchall",
151
- name: segment.slice(1, -1) || "*"
152
- };
153
- if (segment.startsWith(":")) return {
154
- type: "param",
155
- name: segment.slice(1)
156
- };
157
- assertSafeStaticRouteSegment(segment);
158
- return {
159
- type: "static",
160
- value: segment
161
- };
162
- });
163
- }
164
- function splitPathSegments(path) {
165
- return normalizeRoutePath(path).split("/").filter(Boolean);
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
- }
171
- function mergeRoutePaths(prefix, path) {
172
- if (!path) return normalizeRoutePath(prefix);
173
- const normalizedPrefix = normalizeRoutePath(prefix);
174
- const normalizedPath = normalizeRoutePath(path);
175
- if (normalizedPrefix === "/") return normalizedPath;
176
- if (normalizedPath === "/") return normalizedPrefix;
177
- return normalizeRoutePath(`${normalizedPrefix}/${normalizedPath.slice(1)}`);
178
- }
179
- function normalizeRoutePath(path) {
180
- if (!path || path === "/") return "/";
181
- const collapsed = (path.startsWith("/") ? path : `/${path}`).replace(/\/{2,}/g, "/");
182
- return collapsed.length > 1 && collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
183
- }
184
- function buildPathFromSegments(segments, params) {
185
- return normalizeRoutePath("/" + segments.map((segment) => {
186
- if (segment.type === "static") return segment.value;
187
- if (segment.type === "param") return encodeDynamicPathSegment(params[segment.name] ?? "");
188
- return (params[segment.name] ?? params["*"] ?? "").split("/").map((part) => encodeDynamicPathSegment(part)).join("/");
189
- }).join("/"));
190
- }
191
- /**
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.
196
- */
197
- function encodeDynamicPathSegment(part) {
198
- if (part === "." || part === "..") throw new Error(`Unsafe dynamic route param segment "${part}" is not allowed.`);
199
- return encodeURIComponent(part);
200
- }
201
- /**
202
- * Convert a list of file paths from `import.meta.glob` into resolved API routes.
203
- *
204
- * Example: `"/src/api/health.ts"` → path `/api/health`
205
- * `"/src/api/users/[id].ts"` → path `/api/users/:id`
206
- * `"/src/api/files/[...path].ts"` → path `/api/files/*`
207
- * `"/src/api/index.ts"` → path `/api`
208
- */
209
- function resolveApiRoutes(files, apiDir = "/src/api") {
210
- const normalizedDir = apiDir.replace(/\/$/, "");
211
- return files.map((file) => {
212
- let relative = file;
213
- if (relative.startsWith(normalizedDir)) relative = relative.slice(normalizedDir.length);
214
- relative = relative.replace(/\.(ts|tsx|js|jsx)$/, "");
215
- if (relative.endsWith("/index")) relative = relative.slice(0, -6) || "/";
216
- relative = relative.replace(/\[\.\.\.[^\]]+\]/g, "*");
217
- relative = relative.replace(/\[([^\]]+)\]/g, ":$1");
218
- const path = normalizeRoutePath(`/api${relative}`);
219
- return {
220
- path,
221
- file,
222
- segments: parseRouteSegments(path)
223
- };
224
- }).sort(compareResolvedApiRoutes);
225
- }
226
- function matchApiRoute(apiRoutes, pathname) {
227
- const normalizedPathname = normalizeRoutePath(pathname);
228
- const targetSegments = splitPathSegments(normalizedPathname);
229
- for (const route of apiRoutes) {
230
- const params = matchRouteSegments(route.segments, targetSegments);
231
- if (params) return {
232
- route,
233
- params,
234
- pathname: normalizedPathname
235
- };
236
- }
237
- }
238
- function createRouteId(path) {
239
- if (path === "/") return "index";
240
- return path.slice(1).split("/").map((segment) => {
241
- if (segment === "*") return "splat";
242
- return segment.startsWith(":") ? segment.slice(1) : segment;
243
- }).join("-").replace(/[^a-zA-Z0-9-]/g, "-");
244
- }
245
- function compareResolvedApiRoutes(left, right) {
246
- const length = Math.max(left.segments.length, right.segments.length);
247
- for (let index = 0; index < length; index += 1) {
248
- const leftSegment = left.segments[index];
249
- const rightSegment = right.segments[index];
250
- if (!leftSegment) return 1;
251
- if (!rightSegment) return -1;
252
- const leftScore = getRouteSegmentSpecificity(leftSegment);
253
- const rightScore = getRouteSegmentSpecificity(rightSegment);
254
- if (leftScore !== rightScore) return rightScore - leftScore;
255
- }
256
- return left.path.localeCompare(right.path);
257
- }
258
- function getRouteSegmentSpecificity(segment) {
259
- if (segment.type === "static") return 3;
260
- if (segment.type === "param") return 2;
261
- return 1;
262
- }
263
- //#endregion
264
- //#region src/forwardRef.ts
265
- let oldDiffHook = options.__b;
266
- options.__b = (vnode) => {
267
- if (vnode.type && vnode.type.__f && vnode.ref) {
268
- vnode.props.ref = vnode.ref;
269
- vnode.ref = null;
270
- }
271
- if (oldDiffHook) oldDiffHook(vnode);
272
- };
273
- /**
274
- * Pass ref down to a child. This is mainly used in libraries with HOCs that
275
- * wrap components. Using `forwardRef` there is an easy way to get a reference
276
- * of the wrapped component instead of one of the wrapper itself.
277
- */
278
- function forwardRef(fn) {
279
- function Forwarded(props) {
280
- const clone = { ...props };
281
- delete clone.ref;
282
- return fn(clone, props.ref || null);
283
- }
284
- Forwarded.__f = true;
285
- Forwarded.displayName = "ForwardRef(" + (fn.displayName || fn.name) + ")";
286
- return Forwarded;
287
- }
288
- //#endregion
289
- //#region src/hydration.ts
290
- const MODE_HYDRATE = 32;
291
- let _hydrating = false;
292
- let _suspensionCount = 0;
293
- let _hydrated = false;
294
- const oldCatchError = options.__e;
295
- options.__e = (err, newVNode, oldVNode, errorInfo) => {
296
- if (_hydrating && !_hydrated && err && err.then) {
297
- if (!!(newVNode && newVNode.__u && newVNode.__u & MODE_HYDRATE) || !!(newVNode && newVNode.__h)) {
298
- _suspensionCount++;
299
- let settled = false;
300
- const onSettled = () => {
301
- if (settled) return;
302
- settled = true;
303
- _suspensionCount--;
304
- };
305
- err.then(onSettled, onSettled);
306
- }
307
- }
308
- if (oldCatchError) oldCatchError(err, newVNode, oldVNode, errorInfo);
309
- };
310
- const oldCommit = options.__c;
311
- options.__c = (vnode, commitQueue) => {
312
- if (_hydrating && !_hydrated && _suspensionCount <= 0) {
313
- _hydrated = true;
314
- _hydrating = false;
315
- }
316
- if (oldCommit) oldCommit(vnode, commitQueue);
317
- };
318
- /**
319
- * Mark the start of a hydration pass. Call this right before `hydrate()`.
320
- */
321
- function markHydrating() {
322
- if (!_hydrated) _hydrating = true;
323
- }
324
- /**
325
- * Returns `true` once the initial hydration (including all Suspense
326
- * boundaries) has fully resolved. During SSR and hydration this returns
327
- * `false`.
328
- */
329
- function useIsHydrated() {
330
- const [hydrated, setHydrated] = useState(_hydrated);
331
- useEffect(() => {
332
- setHydrated(true);
333
- }, []);
334
- return hydrated;
335
- }
336
- //#endregion
337
- //#region src/runtime-constants.ts
338
- const SAFE_METHODS = new Set(["GET", "HEAD"]);
339
- const HYDRATION_STATE_ELEMENT_ID = "pracht-state";
340
- const ROUTE_STATE_REQUEST_HEADER = "x-pracht-route-state-request";
341
- const ROUTE_STATE_CACHE_CONTROL = "no-store";
342
- const EMPTY_ROUTE_PARAMS = {};
343
- //#endregion
344
- //#region src/runtime-errors.ts
345
- function isPrachtHttpError(error) {
346
- return error instanceof Error && error.name === "PrachtHttpError" && "status" in error;
347
- }
348
- let warnedAboutProductionDebugErrors = false;
349
- /**
350
- * `debugErrors: true` opts into surfacing stack traces, module paths,
351
- * and middleware names in error responses. That is great in dev and
352
- * dangerous in production — a misconfigured deploy would leak internals
353
- * to the public. When `NODE_ENV === "production"` we refuse to honor
354
- * the flag and emit a single console warning so the misconfiguration
355
- * is visible in logs.
356
- */
357
- function shouldExposeServerErrors(options) {
358
- if (options.debugErrors !== true) return false;
359
- if ((typeof process !== "undefined" && process.env ? process.env.NODE_ENV : typeof globalThis !== "undefined" && globalThis.process ? globalThis.process?.env?.NODE_ENV : void 0) === "production") {
360
- if (!warnedAboutProductionDebugErrors) {
361
- warnedAboutProductionDebugErrors = true;
362
- console.warn("[pracht] debugErrors is ignored in production builds. Remove it to silence this warning.");
363
- }
364
- return false;
365
- }
366
- return true;
367
- }
368
- function createSerializedRouteError(message, status, options = {}) {
369
- return {
370
- message,
371
- name: options.name ?? "Error",
372
- status,
373
- ...options.diagnostics ? { diagnostics: options.diagnostics } : {}
374
- };
375
- }
376
- function buildRuntimeDiagnostics(options) {
377
- const route = options.route;
378
- const routeId = route && "id" in route ? route.id : void 0;
379
- return {
380
- phase: options.phase,
381
- routeId,
382
- routePath: route?.path,
383
- routeFile: route?.file,
384
- loaderFile: options.loaderFile,
385
- shellFile: options.shellFile,
386
- middlewareFiles: options.middlewareFiles ? [...options.middlewareFiles] : [],
387
- status: options.status
388
- };
389
- }
390
- function normalizeRouteError(error, options) {
391
- if (isPrachtHttpError(error)) {
392
- const status = typeof error.status === "number" ? error.status : 500;
393
- if (status >= 400 && status < 500) return {
394
- message: error.message,
395
- name: error.name,
396
- status
397
- };
398
- if (options.exposeDetails) return {
399
- message: error.message || "Internal Server Error",
400
- name: error.name || "Error",
401
- status
402
- };
403
- return {
404
- message: "Internal Server Error",
405
- name: "Error",
406
- status
407
- };
408
- }
409
- if (error instanceof Error) {
410
- if (options.exposeDetails) return {
411
- message: error.message || "Internal Server Error",
412
- name: error.name || "Error",
413
- status: 500
414
- };
415
- return {
416
- message: "Internal Server Error",
417
- name: "Error",
418
- status: 500
419
- };
420
- }
421
- if (options.exposeDetails) return {
422
- message: typeof error === "string" && error ? error : "Internal Server Error",
423
- name: "Error",
424
- status: 500
425
- };
426
- return {
427
- message: "Internal Server Error",
428
- name: "Error",
429
- status: 500
430
- };
431
- }
432
- function deserializeRouteError(error) {
433
- const result = new Error(error.message);
434
- result.name = error.name;
435
- result.status = error.status;
436
- result.diagnostics = error.diagnostics;
437
- return result;
438
- }
439
- //#endregion
440
- //#region src/runtime-headers.ts
441
- const HEADER_CRLF_RE = /[\r\n]/;
442
- /**
443
- * Reject header values containing CR/LF. Some runtimes (Node `undici`
444
- * Headers) throw on their own, but Web-runtime fetch implementations
445
- * vary, and a user-supplied `headers()` value is never trusted input.
446
- * Keeping the check here means response-splitting can't slip through on
447
- * any adapter.
448
- */
449
- function assertSafeHeaderValue(name, value) {
450
- if (HEADER_CRLF_RE.test(value)) throw new Error(`Refused to set header "${name}": value contains CR or LF`);
451
- }
452
- function applyHeaders(headers, init) {
453
- for (const [key, value] of iterateHeaderInit(init)) assertSafeHeaderValue(key, value);
454
- new Headers(init).forEach((value, key) => {
455
- headers.set(key, value);
456
- });
457
- }
458
- function* iterateHeaderInit(init) {
459
- if (init instanceof Headers) {
460
- for (const entry of init.entries()) yield entry;
461
- return;
462
- }
463
- if (Array.isArray(init)) {
464
- for (const entry of init) if (entry && entry.length >= 2) yield [entry[0], entry[1]];
465
- return;
466
- }
467
- for (const [key, value] of Object.entries(init)) yield [key, value];
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 applySecurityAndRouteHeaders(headers, options) {
477
- applyDefaultSecurityHeaders(headers);
478
- if (options) {
479
- appendVaryHeader(headers, ROUTE_STATE_REQUEST_HEADER);
480
- if (options.isRouteStateRequest && !headers.has("cache-control")) headers.set("cache-control", ROUTE_STATE_CACHE_CONTROL);
481
- }
482
- return headers;
483
- }
484
- function withDefaultSecurityHeaders(response) {
485
- const headers = new Headers(response.headers);
486
- applySecurityAndRouteHeaders(headers);
487
- return new Response(response.body, {
488
- status: response.status,
489
- statusText: response.statusText,
490
- headers
491
- });
492
- }
493
- function withRouteResponseHeaders(response, options) {
494
- const headers = new Headers(response.headers);
495
- applySecurityAndRouteHeaders(headers, options);
496
- return new Response(response.body, {
497
- status: response.status,
498
- statusText: response.statusText,
499
- headers
500
- });
501
- }
502
- function appendVaryHeader(headers, value) {
503
- const current = headers.get("vary");
504
- if (!current) {
505
- headers.set("vary", value);
506
- return;
507
- }
508
- const values = current.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
509
- if (values.includes("*") || values.includes(value.toLowerCase())) return;
510
- headers.set("vary", `${current}, ${value}`);
511
- }
512
- //#endregion
513
- //#region src/runtime-client-fetch.ts
514
- const SAFE_NAVIGATION_PROTOCOLS = new Set(["http:", "https:"]);
515
- /**
516
- * Parse a possibly-server-supplied redirect target against a base URL and
517
- * return it only if it uses a safe navigation scheme (`http:` or `https:`).
518
- *
519
- * `javascript:`, `data:`, `vbscript:`, `blob:`, `file:` and similar schemes
520
- * can execute script or bypass same-origin assumptions when assigned to
521
- * `window.location.href` — a server-controlled redirect (from a loader,
522
- * middleware, form action response, or API route) must never be able to
523
- * trigger them. Returns `null` for unsafe or unparseable inputs.
524
- */
525
- function parseSafeNavigationUrl(location, base) {
526
- let targetUrl;
527
- try {
528
- targetUrl = new URL(location, base);
529
- } catch {
530
- return null;
531
- }
532
- if (!SAFE_NAVIGATION_PROTOCOLS.has(targetUrl.protocol)) return null;
533
- return targetUrl;
534
- }
535
- function buildRouteStateUrl(url) {
536
- return `${url}${url.includes("?") ? "&" : "?"}_data=1`;
537
- }
538
- async function fetchPrachtRouteState(url, options) {
539
- const fetchUrl = options?.useDataParam ? buildRouteStateUrl(url) : url;
540
- const response = await fetch(fetchUrl, {
541
- headers: options?.useDataParam ? {} : {
542
- [ROUTE_STATE_REQUEST_HEADER]: "1",
543
- "Cache-Control": "no-cache"
544
- },
545
- redirect: "manual",
546
- signal: options?.signal
547
- });
548
- if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) return {
549
- location: response.headers.get("location") ?? url,
550
- type: "redirect"
551
- };
552
- const json = await response.json();
553
- if (json.redirect) return {
554
- location: json.redirect,
555
- type: "redirect"
556
- };
557
- if (!response.ok) {
558
- if (json.error) return {
559
- error: json.error,
560
- type: "error"
561
- };
562
- throw new Error(`Failed to fetch route state (${response.status})`);
563
- }
564
- return {
565
- data: json.data,
566
- type: "data"
567
- };
568
- }
569
- async function navigateToClientLocation(location, options) {
570
- if (typeof window === "undefined") return;
571
- const targetUrl = parseSafeNavigationUrl(location, window.location.href);
572
- if (!targetUrl) {
573
- console.error(`[pracht] refused to navigate to unsafe URL: ${location}`);
574
- return;
575
- }
576
- const target = targetUrl.pathname + targetUrl.search + targetUrl.hash;
577
- if (targetUrl.origin === window.location.origin && window.__PRACHT_NAVIGATE__) {
578
- await window.__PRACHT_NAVIGATE__(target, options);
579
- return;
580
- }
581
- if (options?.replace) {
582
- window.location.replace(targetUrl.toString());
583
- return;
584
- }
585
- window.location.href = targetUrl.toString();
586
- }
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
735
- //#region src/runtime-hooks.ts
736
- const RouteDataContext = createContext(void 0);
737
- function PrachtRuntimeProvider({ children, data, params = EMPTY_ROUTE_PARAMS, routeId, stateVersion = 0, url }) {
738
- const [routeDataState, setRouteDataState] = useState({
739
- data,
740
- stateVersion
741
- });
742
- const routeData = routeDataState.stateVersion === stateVersion ? routeDataState.data : data;
743
- useEffect(() => {
744
- setRouteDataState({
745
- data,
746
- stateVersion
747
- });
748
- }, [
749
- data,
750
- routeId,
751
- stateVersion,
752
- url
753
- ]);
754
- const context = useMemo(() => ({
755
- data: routeData,
756
- params,
757
- routeId,
758
- setData: (nextData) => setRouteDataState({
759
- data: nextData,
760
- stateVersion
761
- }),
762
- url
763
- }), [
764
- routeData,
765
- params,
766
- routeId,
767
- stateVersion,
768
- url
769
- ]);
770
- return h(RouteDataContext.Provider, {
771
- value: context,
772
- children
773
- });
774
- }
775
- function startApp(options = {}) {
776
- if (typeof window === "undefined") return options.initialData;
777
- if (typeof options.initialData !== "undefined") return options.initialData;
778
- return readHydrationState()?.data;
779
- }
780
- function readHydrationState() {
781
- if (typeof window === "undefined") return;
782
- if (window.__PRACHT_STATE__) return window.__PRACHT_STATE__;
783
- const element = document.getElementById(HYDRATION_STATE_ELEMENT_ID);
784
- if (!(element instanceof HTMLScriptElement)) return;
785
- const raw = element.textContent;
786
- if (!raw) return;
787
- const state = JSON.parse(raw);
788
- window.__PRACHT_STATE__ = state;
789
- return state;
790
- }
791
- function useRouteData() {
792
- return useContext(RouteDataContext)?.data;
793
- }
794
- function useLocation() {
795
- return parseLocation(useContext(RouteDataContext)?.url ?? (typeof window !== "undefined" ? window.location.pathname + window.location.search : "/"));
796
- }
797
- function useParams() {
798
- return useContext(RouteDataContext)?.params ?? {};
799
- }
800
- function useRevalidate() {
801
- const runtime = useContext(RouteDataContext);
802
- return async () => {
803
- if (typeof window === "undefined") return;
804
- const result = await fetchPrachtRouteState(runtime?.url || window.location.pathname + window.location.search);
805
- if (result.type === "redirect") {
806
- await navigateToClientLocation(result.location);
807
- return;
808
- }
809
- if (result.type === "error") throw deserializeRouteError(result.error);
810
- runtime?.setData(result.data);
811
- return result.data;
812
- };
813
- }
814
- function Form(props) {
815
- const { onSubmit, method, ...rest } = props;
816
- return h("form", {
817
- ...rest,
818
- method,
819
- onSubmit: async (event) => {
820
- onSubmit?.(event);
821
- if (event.defaultPrevented) return;
822
- const form = event.currentTarget;
823
- if (!(form instanceof HTMLFormElement)) return;
824
- const formMethod = (method ?? form.method ?? "post").toUpperCase();
825
- if (SAFE_METHODS.has(formMethod)) return;
826
- event.preventDefault();
827
- clearPrefetchCache();
828
- const response = await fetch(props.action ?? form.action, {
829
- method: formMethod,
830
- body: new FormData(form),
831
- redirect: "manual"
832
- });
833
- if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) {
834
- const location = response.headers.get("location");
835
- if (location) {
836
- await navigateToClientLocation(location);
837
- return;
838
- }
839
- window.location.href = props.action ?? form.action;
840
- }
841
- }
842
- });
843
- }
844
- function parseLocation(value) {
845
- const url = new URL(value, "http://pracht.local");
846
- return {
847
- pathname: url.pathname,
848
- search: url.search
849
- };
850
- }
851
- //#endregion
852
- //#region src/runtime-html.ts
853
- function escapeHtml(str) {
854
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
855
- }
856
- function serializeJsonForHtml(value) {
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));
915
- }
916
- function buildHtmlDocument(options) {
917
- const { head, body, hydrationState, clientEntryUrl, cssUrls = [], modulePreloadUrls = [], routeStatePreloadUrl } = options;
918
- const titleTag = head.title ? `<title>${escapeHtml(head.title)}</title>` : "";
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 ");
926
- const cssTags = cssUrls.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join("\n ");
927
- const modulePreloadTags = modulePreloadUrls.map((url) => `<link rel="modulepreload" href="${escapeHtml(url)}">`).join("\n ");
928
- const routeStatePreloadTag = routeStatePreloadUrl ? `<link rel="preload" as="fetch" href="${escapeHtml(routeStatePreloadUrl)}" crossorigin="anonymous">` : "";
929
- const stateScript = `<script id="${HYDRATION_STATE_ELEMENT_ID}" type="application/json">${serializeJsonForHtml(hydrationState)}<\/script>`;
930
- const entryScript = clientEntryUrl ? `<script type="module" src="${escapeHtml(clientEntryUrl)}"><\/script>` : "";
931
- return `<!DOCTYPE html>
932
- <html${head.lang ? ` lang="${escapeHtml(head.lang)}"` : ""}>
933
- <head>
934
- <meta charset="utf-8">
935
- ${titleTag}
936
- ${metaTags}
937
- ${linkTags}
938
- ${scriptTags}
939
- ${cssTags}
940
- ${modulePreloadTags}
941
- ${routeStatePreloadTag}
942
- </head>
943
- <body>
944
- <div id="pracht-root">${body}</div>
945
- ${stateScript}
946
- ${entryScript}
947
- </body>
948
- </html>`;
949
- }
950
- function htmlResponse(html, status = 200, initHeaders) {
951
- const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
952
- if (initHeaders) applyHeaders(headers, initHeaders);
953
- applySecurityAndRouteHeaders(headers, { isRouteStateRequest: false });
954
- return new Response(html, {
955
- status,
956
- headers
957
- });
958
- }
959
- //#endregion
960
- //#region src/runtime-manifest.ts
961
- /** Strip leading `./` and `/` so all module paths share one canonical form. */
962
- function normalizeModulePath(path) {
963
- return path.replace(/^\.?\//, "");
964
- }
965
- function buildSuffixIndex(manifest) {
966
- const index = /* @__PURE__ */ new Map();
967
- for (const key of Object.keys(manifest)) {
968
- const normalized = normalizeModulePath(key);
969
- if (!normalized) continue;
970
- if (!index.has(normalized)) index.set(normalized, key);
971
- for (let i = normalized.indexOf("/"); i !== -1; i = normalized.indexOf("/", i + 1)) {
972
- const suffix = normalized.slice(i + 1);
973
- if (suffix && !index.has(suffix)) index.set(suffix, key);
974
- }
975
- }
976
- return index;
977
- }
978
- const suffixIndexCache = /* @__PURE__ */ new WeakMap();
979
- function getSuffixIndex(manifest) {
980
- let index = suffixIndexCache.get(manifest);
981
- if (index) return index;
982
- index = buildSuffixIndex(manifest);
983
- suffixIndexCache.set(manifest, index);
984
- return index;
985
- }
986
- function resolveManifestEntries(manifest, file) {
987
- if (file in manifest) return manifest[file];
988
- const resolved = getSuffixIndex(manifest).get(normalizeModulePath(file));
989
- if (resolved) return manifest[resolved];
990
- }
991
- function resolvePageUrlsFromManifest(manifest, shellFile, routeFile) {
992
- const urls = /* @__PURE__ */ new Set();
993
- const add = (file) => {
994
- const entries = resolveManifestEntries(manifest, file);
995
- if (entries) for (const url of entries) urls.add(url);
996
- };
997
- if (shellFile) add(shellFile);
998
- add(routeFile);
999
- return [...urls];
1000
- }
1001
- function resolvePageCssUrls(cssManifest, shellFile, routeFile) {
1002
- if (!cssManifest) return [];
1003
- return resolvePageUrlsFromManifest(cssManifest, shellFile, routeFile);
1004
- }
1005
- function resolvePageJsUrls(jsManifest, shellFile, routeFile) {
1006
- if (!jsManifest) return [];
1007
- return resolvePageUrlsFromManifest(jsManifest, shellFile, routeFile);
1008
- }
1009
- async function resolveRegistryModule(modules, file) {
1010
- if (!modules) return void 0;
1011
- if (file in modules) return modules[file]();
1012
- const resolved = getSuffixIndex(modules).get(normalizeModulePath(file));
1013
- if (resolved) return modules[resolved]();
1014
- }
1015
- async function resolveDataFunctions(route, routeModule, registry) {
1016
- let loader = routeModule?.loader;
1017
- let loaderFile = routeModule?.loader ? route.file : void 0;
1018
- if (route.loaderFile) {
1019
- const dataModule = await resolveRegistryModule(registry.dataModules, route.loaderFile);
1020
- if (dataModule?.loader) {
1021
- loader = dataModule.loader;
1022
- loaderFile = route.loaderFile;
1023
- }
1024
- }
1025
- return {
1026
- loader,
1027
- loaderFile
1028
- };
1029
- }
1030
- //#endregion
1031
- //#region src/runtime-middleware.ts
1032
- const DEFAULT_REDIRECT_STATUS_SAFE = 302;
1033
- const DEFAULT_REDIRECT_STATUS_UNSAFE = 303;
1034
- /**
1035
- * Build a safe redirect response from middleware/loader output. Rejects
1036
- * non-http(s) schemes (no `javascript:`/`data:`/etc.) and CR/LF injection
1037
- * against the `Location` header. When status is omitted, non-GET/HEAD
1038
- * requests default to 303 so the browser does not resend the body to the
1039
- * redirect target; safe methods default to 302.
1040
- *
1041
- * The original `target` string is preserved on success (relative paths
1042
- * stay relative) — we only parse it to validate scheme, not to rewrite
1043
- * it. Both the original input and its resolved URL must be CR/LF-free.
1044
- */
1045
- function buildRedirectResponse(target, options) {
1046
- if (/[\r\n]/.test(target)) throw new Error("Refused redirect target containing CR/LF");
1047
- if (!parseSafeNavigationUrl(target, options.baseUrl)) throw new Error("Refused unsafe redirect target");
1048
- const method = (options.method ?? "GET").toUpperCase();
1049
- const defaultStatus = SAFE_METHODS.has(method) ? DEFAULT_REDIRECT_STATUS_SAFE : DEFAULT_REDIRECT_STATUS_UNSAFE;
1050
- const status = options.status ?? defaultStatus;
1051
- return new Response(null, {
1052
- status,
1053
- headers: { location: target }
1054
- });
1055
- }
1056
- async function runMiddlewareChain(options) {
1057
- let context = options.context;
1058
- const modulePromises = options.middlewareFiles.map((mwFile) => resolveRegistryModule(options.registry.middlewareModules, mwFile));
1059
- for (const p of modulePromises) p.catch(() => {});
1060
- for (const modulePromise of modulePromises) {
1061
- const mwModule = await modulePromise;
1062
- if (!mwModule?.middleware) continue;
1063
- const result = await mwModule.middleware({
1064
- request: options.request,
1065
- params: options.params,
1066
- context,
1067
- signal: AbortSignal.timeout(3e4),
1068
- url: options.url,
1069
- route: options.route
1070
- });
1071
- if (!result) continue;
1072
- if (result instanceof Response) return { response: withDefaultSecurityHeaders(result) };
1073
- if ("redirect" in result) {
1074
- const status = "status" in result ? result.status : void 0;
1075
- return { response: withDefaultSecurityHeaders(buildRedirectResponse(result.redirect, {
1076
- baseUrl: options.request.url,
1077
- method: options.request.method,
1078
- status
1079
- })) };
1080
- }
1081
- if ("context" in result) context = {
1082
- ...context,
1083
- ...result.context
1084
- };
1085
- }
1086
- return { context };
1087
- }
1088
- async function mergeHeadMetadata(shellModule, routeModule, routeArgs, data) {
1089
- const [shellHead, routeHead] = await Promise.all([shellModule?.head ? shellModule.head(routeArgs) : Promise.resolve({}), routeModule?.head ? routeModule.head({
1090
- ...routeArgs,
1091
- data
1092
- }) : Promise.resolve({})]);
1093
- return {
1094
- title: routeHead.title ?? shellHead.title,
1095
- lang: routeHead.lang ?? shellHead.lang,
1096
- meta: [...shellHead.meta ?? [], ...routeHead.meta ?? []],
1097
- link: [...shellHead.link ?? [], ...routeHead.link ?? []],
1098
- script: [...shellHead.script ?? [], ...routeHead.script ?? []]
1099
- };
1100
- }
1101
- async function mergeDocumentHeaders(shellModule, routeModule, routeArgs, data) {
1102
- const headers = new Headers();
1103
- const [shellHeaders, routeHeaders] = await Promise.all([shellModule?.headers ? shellModule.headers(routeArgs) : Promise.resolve(void 0), routeModule?.headers ? routeModule.headers({
1104
- ...routeArgs,
1105
- data
1106
- }) : Promise.resolve(void 0)]);
1107
- if (shellHeaders) applyHeaders(headers, shellHeaders);
1108
- if (routeHeaders) applyHeaders(headers, routeHeaders);
1109
- return headers;
1110
- }
1111
- //#endregion
1112
- //#region src/runtime-response.ts
1113
- let _renderToStringAsync;
1114
- async function getRenderToStringAsync() {
1115
- if (_renderToStringAsync) return _renderToStringAsync;
1116
- _renderToStringAsync = (await import("preact-render-to-string")).renderToStringAsync;
1117
- return _renderToStringAsync;
1118
- }
1119
- function jsonErrorResponse(routeError, options) {
1120
- const headers = applySecurityAndRouteHeaders(new Headers({ "content-type": "application/json; charset=utf-8" }), options.isRouteStateRequest ? { isRouteStateRequest: true } : void 0);
1121
- return new Response(JSON.stringify({ error: routeError }), {
1122
- status: routeError.status,
1123
- headers
1124
- });
1125
- }
1126
- function jsonRedirectResponse(location, options) {
1127
- const headers = new Headers(options.headers);
1128
- headers.set("content-type", "application/json; charset=utf-8");
1129
- return withRouteResponseHeaders(new Response(JSON.stringify({ redirect: location }), {
1130
- status: 200,
1131
- headers
1132
- }), { isRouteStateRequest: options.isRouteStateRequest });
1133
- }
1134
- function normalizePageResponse(response, options) {
1135
- if (options.isRouteStateRequest && response.status >= 300 && response.status < 400) {
1136
- const location = response.headers.get("location");
1137
- if (location) return jsonRedirectResponse(location, {
1138
- headers: response.headers,
1139
- isRouteStateRequest: true
1140
- });
1141
- }
1142
- return withRouteResponseHeaders(response, options);
1143
- }
1144
- function renderApiErrorResponse(options) {
1145
- const exposeDetails = shouldExposeServerErrors(options.options);
1146
- const routeError = normalizeRouteError(options.error, { exposeDetails });
1147
- const routeErrorWithDiagnostics = exposeDetails ? {
1148
- ...routeError,
1149
- diagnostics: buildRuntimeDiagnostics({
1150
- middlewareFiles: options.middlewareFiles,
1151
- phase: options.phase,
1152
- route: options.route,
1153
- status: routeError.status
1154
- })
1155
- } : routeError;
1156
- if (exposeDetails) return jsonErrorResponse(routeErrorWithDiagnostics, { isRouteStateRequest: false });
1157
- const message = routeErrorWithDiagnostics.status >= 500 ? "Internal Server Error" : routeErrorWithDiagnostics.message;
1158
- return withDefaultSecurityHeaders(new Response(message, {
1159
- status: routeErrorWithDiagnostics.status,
1160
- headers: { "content-type": "text/plain; charset=utf-8" }
1161
- }));
1162
- }
1163
- async function renderRouteErrorResponse(options) {
1164
- const exposeDetails = shouldExposeServerErrors(options.options);
1165
- const routeError = normalizeRouteError(options.error, { exposeDetails });
1166
- const routeErrorWithDiagnostics = exposeDetails ? {
1167
- ...routeError,
1168
- diagnostics: buildRuntimeDiagnostics({
1169
- loaderFile: options.loaderFile,
1170
- middlewareFiles: options.routeArgs.route.middlewareFiles,
1171
- phase: options.phase,
1172
- route: options.routeArgs.route,
1173
- shellFile: options.shellFile,
1174
- status: routeError.status
1175
- })
1176
- } : routeError;
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}`, {
1184
- status: routeErrorWithDiagnostics.status,
1185
- headers: { "content-type": "text/plain; charset=utf-8" }
1186
- }));
1187
- }
1188
- const head = shellModule?.head ? await shellModule.head(options.routeArgs) : {};
1189
- const documentHeaders = await mergeDocumentHeaders(shellModule, void 0, options.routeArgs, void 0);
1190
- const cssUrls = resolvePageCssUrls(options.options.cssManifest, options.shellFile, options.routeArgs.route.file);
1191
- const modulePreloadUrls = resolvePageJsUrls(options.options.jsManifest, options.shellFile, options.routeArgs.route.file);
1192
- const renderToString = await getRenderToStringAsync();
1193
- const Boundary = ErrorBoundary;
1194
- const Shell = shellModule?.Shell;
1195
- const errorValue = deserializeRouteError(routeErrorWithDiagnostics);
1196
- const componentTree = Shell ? h(Shell, null, h(Boundary, { error: errorValue })) : h(Boundary, { error: errorValue });
1197
- return htmlResponse(buildHtmlDocument({
1198
- head,
1199
- body: await renderToString(h(PrachtRuntimeProvider, {
1200
- data: null,
1201
- routeId: options.routeId,
1202
- url: options.requestPath
1203
- }, componentTree)),
1204
- hydrationState: {
1205
- url: options.requestPath,
1206
- routeId: options.routeId,
1207
- data: null,
1208
- error: routeErrorWithDiagnostics
1209
- },
1210
- clientEntryUrl: options.options.clientEntryUrl,
1211
- cssUrls,
1212
- modulePreloadUrls
1213
- }), routeErrorWithDiagnostics.status, documentHeaders);
1214
- }
1215
- //#endregion
1216
- //#region src/runtime-negotiation.ts
1217
- const MARKDOWN_MEDIA_TYPE = "text/markdown";
1218
- function parseAccept(header) {
1219
- if (!header) return [];
1220
- const entries = [];
1221
- for (const raw of header.split(",")) {
1222
- const parts = raw.trim().split(";");
1223
- const type = parts.shift()?.trim().toLowerCase();
1224
- if (!type) continue;
1225
- let quality = 1;
1226
- for (const param of parts) {
1227
- const [key, value] = param.split("=").map((p) => p.trim());
1228
- if (key === "q" && value != null) {
1229
- const parsed = Number.parseFloat(value);
1230
- if (!Number.isNaN(parsed)) quality = parsed;
1231
- }
1232
- }
1233
- entries.push({
1234
- type,
1235
- quality
1236
- });
1237
- }
1238
- return entries;
1239
- }
1240
- function prefersMarkdown(accept) {
1241
- const entries = parseAccept(accept);
1242
- if (!entries.length) return false;
1243
- const md = entries.find((e) => e.type === MARKDOWN_MEDIA_TYPE);
1244
- if (!md || md.quality === 0) return false;
1245
- const html = entries.find((e) => e.type === "text/html");
1246
- if (!html) return true;
1247
- return md.quality >= html.quality;
1248
- }
1249
- function markdownResponse(source) {
1250
- const headers = new Headers({
1251
- "content-type": "text/markdown; charset=utf-8",
1252
- "cache-control": "public, max-age=0, must-revalidate"
1253
- });
1254
- appendVaryHeader(headers, "Accept");
1255
- applyDefaultSecurityHeaders(headers);
1256
- return new Response(source, {
1257
- status: 200,
1258
- headers
1259
- });
1260
- }
1261
- //#endregion
1262
- //#region src/runtime.ts
1263
- const SAME_ORIGIN_FETCH_SITE = "same-origin";
1264
- /**
1265
- * Stricter variant of first-party detection used for CSRF protection on
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.
1271
- */
1272
- function isSameOriginMutation(request, url) {
1273
- const site = request.headers.get("sec-fetch-site");
1274
- if (site && site !== SAME_ORIGIN_FETCH_SITE) return false;
1275
- const origin = request.headers.get("origin");
1276
- if (origin) try {
1277
- return new URL(origin).origin === url.origin;
1278
- } catch {
1279
- return false;
1280
- }
1281
- if (site === SAME_ORIGIN_FETCH_SITE) return true;
1282
- const referer = request.headers.get("referer");
1283
- if (referer) try {
1284
- return new URL(referer).origin === url.origin;
1285
- } catch {
1286
- return false;
1287
- }
1288
- return true;
1289
- }
1290
- /**
1291
- * Heuristic "this request came from our own page" check. Used to gate
1292
- * the `_data=1` query-param form of the route-state endpoint, which is
1293
- * otherwise reachable via any cross-origin `<a href>` / redirect.
1294
- *
1295
- * Accepts a request as first-party when:
1296
- * - Sec-Fetch-Site is `same-origin` (modern browsers),
1297
- * - OR Sec-Fetch-Site is absent AND the Origin header matches the
1298
- * request URL's origin (older clients that still send Origin),
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).
1304
- */
1305
- function isFirstPartyFetch(request) {
1306
- const site = request.headers.get("sec-fetch-site");
1307
- if (site && site !== SAME_ORIGIN_FETCH_SITE) return false;
1308
- const origin = request.headers.get("origin");
1309
- if (origin) try {
1310
- return new URL(origin).origin === new URL(request.url).origin;
1311
- } catch {
1312
- return false;
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
- }
1321
- return true;
1322
- }
1323
- async function handlePrachtRequest(options) {
1324
- const url = new URL(options.request.url);
1325
- const hasDataParam = url.searchParams.get("_data") === "1";
1326
- if (hasDataParam) url.searchParams.delete("_data");
1327
- const requestPath = getRequestPath(url);
1328
- const registry = options.registry ?? {};
1329
- const headerSignalsRouteState = options.request.headers.get(ROUTE_STATE_REQUEST_HEADER) === "1";
1330
- const dataParamIsFirstParty = hasDataParam && isFirstPartyFetch(options.request);
1331
- const isRouteStateRequest = headerSignalsRouteState || dataParamIsFirstParty;
1332
- const exposeDiagnostics = shouldExposeServerErrors(options);
1333
- if (options.apiRoutes?.length) {
1334
- const apiMatch = matchApiRoute(options.apiRoutes, url.pathname);
1335
- if (apiMatch) {
1336
- const apiMiddlewareFiles = (options.app.api.middleware ?? []).flatMap((name) => {
1337
- const middlewareFile = options.app.middleware[name];
1338
- return middlewareFile ? [middlewareFile] : [];
1339
- });
1340
- let currentPhase = "middleware";
1341
- if ((options.app.api.requireSameOrigin ?? true) && !SAFE_METHODS.has(options.request.method) && !isSameOriginMutation(options.request, url)) return withDefaultSecurityHeaders(new Response("Cross-origin request blocked", {
1342
- status: 403,
1343
- headers: { "content-type": "text/plain; charset=utf-8" }
1344
- }));
1345
- try {
1346
- const middlewareResult = await runMiddlewareChain({
1347
- context: options.context ?? {},
1348
- middlewareFiles: apiMiddlewareFiles,
1349
- params: apiMatch.params,
1350
- registry,
1351
- request: options.request,
1352
- route: apiMatch.route,
1353
- url
1354
- });
1355
- if (middlewareResult.response) return middlewareResult.response;
1356
- currentPhase = "api";
1357
- const apiModule = await resolveRegistryModule(registry.apiModules, apiMatch.route.file);
1358
- if (!apiModule) throw new Error("API route module not found");
1359
- const handler = apiModule[options.request.method.toUpperCase()] ?? apiModule.default;
1360
- if (!handler) return withDefaultSecurityHeaders(new Response("Method not allowed", {
1361
- status: 405,
1362
- headers: { "content-type": "text/plain; charset=utf-8" }
1363
- }));
1364
- return withDefaultSecurityHeaders(await handler({
1365
- request: options.request,
1366
- params: apiMatch.params,
1367
- context: middlewareResult.context,
1368
- signal: AbortSignal.timeout(3e4),
1369
- url,
1370
- route: apiMatch.route
1371
- }));
1372
- } catch (error) {
1373
- return renderApiErrorResponse({
1374
- error,
1375
- middlewareFiles: apiMiddlewareFiles,
1376
- options,
1377
- phase: currentPhase,
1378
- route: apiMatch.route
1379
- });
1380
- }
1381
- }
1382
- }
1383
- const match = matchAppRoute(options.app, url.pathname);
1384
- if (!match) {
1385
- if (isRouteStateRequest) return jsonErrorResponse(createSerializedRouteError("Not found", 404, {
1386
- diagnostics: exposeDiagnostics ? buildRuntimeDiagnostics({
1387
- phase: "match",
1388
- status: 404
1389
- }) : void 0,
1390
- name: "Error"
1391
- }), { isRouteStateRequest: true });
1392
- return withDefaultSecurityHeaders(new Response("Not found", {
1393
- status: 404,
1394
- headers: { "content-type": "text/plain; charset=utf-8" }
1395
- }));
1396
- }
1397
- if (!SAFE_METHODS.has(options.request.method)) {
1398
- if (isRouteStateRequest) return jsonErrorResponse(createSerializedRouteError("Method not allowed", 405, {
1399
- diagnostics: exposeDiagnostics ? buildRuntimeDiagnostics({
1400
- middlewareFiles: match.route.middlewareFiles,
1401
- phase: "action",
1402
- route: match.route,
1403
- shellFile: match.route.shellFile,
1404
- status: 405
1405
- }) : void 0,
1406
- name: "Error"
1407
- }), { isRouteStateRequest: true });
1408
- return withRouteResponseHeaders(new Response("Method not allowed", {
1409
- status: 405,
1410
- headers: { "content-type": "text/plain; charset=utf-8" }
1411
- }), { isRouteStateRequest });
1412
- }
1413
- let routeArgs = {
1414
- request: options.request,
1415
- params: match.params,
1416
- context: options.context ?? {},
1417
- signal: AbortSignal.timeout(3e4),
1418
- url,
1419
- route: match.route
1420
- };
1421
- let routeModule;
1422
- let shellModule;
1423
- let loaderFile;
1424
- let currentPhase = "middleware";
1425
- try {
1426
- const middlewarePromise = runMiddlewareChain({
1427
- context: routeArgs.context,
1428
- middlewareFiles: match.route.middlewareFiles,
1429
- params: match.params,
1430
- registry,
1431
- request: options.request,
1432
- route: match.route,
1433
- url
1434
- });
1435
- const routeModulePromise = resolveRegistryModule(registry.routeModules, match.route.file);
1436
- const shellModulePromise = match.route.shellFile ? resolveRegistryModule(registry.shellModules, match.route.shellFile) : Promise.resolve(void 0);
1437
- const dataFunctionsPromise = routeModulePromise.then((mod) => resolveDataFunctions(match.route, mod, registry));
1438
- routeModulePromise.catch(() => {});
1439
- shellModulePromise.catch(() => {});
1440
- dataFunctionsPromise.catch(() => {});
1441
- const middlewareResult = await middlewarePromise;
1442
- if (middlewareResult.response) return normalizePageResponse(middlewareResult.response, { isRouteStateRequest });
1443
- routeArgs = {
1444
- ...routeArgs,
1445
- context: middlewareResult.context
1446
- };
1447
- currentPhase = "render";
1448
- routeModule = await routeModulePromise;
1449
- if (!routeModule) throw new Error("Route module not found");
1450
- if (!isRouteStateRequest && typeof routeModule.markdown === "string" && prefersMarkdown(options.request.headers.get("accept"))) return markdownResponse(routeModule.markdown);
1451
- currentPhase = "loader";
1452
- const { loader, loaderFile: resolvedLoaderFile } = await dataFunctionsPromise;
1453
- loaderFile = resolvedLoaderFile;
1454
- const loaderResult = loader ? await loader(routeArgs) : void 0;
1455
- if (loaderResult instanceof Response) return normalizePageResponse(loaderResult, { isRouteStateRequest });
1456
- const data = loaderResult;
1457
- if (isRouteStateRequest) return withRouteResponseHeaders(Response.json({ data }), { isRouteStateRequest: true });
1458
- currentPhase = "render";
1459
- shellModule = await shellModulePromise;
1460
- const [head, documentHeaders] = await Promise.all([mergeHeadMetadata(shellModule, routeModule, routeArgs, data), mergeDocumentHeaders(shellModule, routeModule, routeArgs, data)]);
1461
- const cssUrls = resolvePageCssUrls(options.cssManifest, match.route.shellFile, match.route.file);
1462
- const modulePreloadUrls = resolvePageJsUrls(options.jsManifest, match.route.shellFile, match.route.file);
1463
- if (match.route.render === "spa") {
1464
- 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);
1470
- }
1471
- return htmlResponse(buildHtmlDocument({
1472
- head,
1473
- body,
1474
- hydrationState: {
1475
- url: requestPath,
1476
- routeId: match.route.id ?? "",
1477
- data: null,
1478
- error: null,
1479
- pending: true
1480
- },
1481
- clientEntryUrl: options.clientEntryUrl,
1482
- cssUrls,
1483
- modulePreloadUrls,
1484
- routeStatePreloadUrl: loader ? buildRouteStateUrl(requestPath) : void 0
1485
- }), 200, documentHeaders);
1486
- }
1487
- const DefaultComponent = typeof routeModule.default === "function" ? routeModule.default : void 0;
1488
- const Component = routeModule.Component ?? DefaultComponent;
1489
- if (!Component) throw new Error("Route has no Component or default export");
1490
- const Shell = shellModule?.Shell;
1491
- const Comp = Component;
1492
- const componentProps = {
1493
- data,
1494
- params: match.params
1495
- };
1496
- const componentTree = Shell ? h(Shell, null, h(Comp, componentProps)) : h(Comp, componentProps);
1497
- const tree = h(PrachtRuntimeProvider, {
1498
- data,
1499
- params: match.params,
1500
- routeId: match.route.id ?? "",
1501
- url: requestPath
1502
- }, componentTree);
1503
- return htmlResponse(buildHtmlDocument({
1504
- head,
1505
- body: await (await getRenderToStringAsync())(tree),
1506
- hydrationState: {
1507
- url: requestPath,
1508
- routeId: match.route.id ?? "",
1509
- data,
1510
- error: null
1511
- },
1512
- clientEntryUrl: options.clientEntryUrl,
1513
- cssUrls,
1514
- modulePreloadUrls
1515
- }), 200, documentHeaders);
1516
- } catch (error) {
1517
- return renderRouteErrorResponse({
1518
- error,
1519
- isRouteStateRequest,
1520
- loaderFile,
1521
- options,
1522
- phase: currentPhase,
1523
- routeArgs,
1524
- routeId: match.route.id ?? "",
1525
- routeModule,
1526
- shellFile: match.route.shellFile,
1527
- shellModule,
1528
- requestPath
1529
- });
1530
- }
1531
- }
1532
- function getRequestPath(url) {
1533
- return `${url.pathname}${url.search}`;
1534
- }
1535
- //#endregion
1536
- //#region src/prerender.ts
1537
- async function prerenderApp(options) {
1538
- const resolved = resolveApp(options.app);
1539
- const results = [];
1540
- const isgManifest = {};
1541
- const work = [];
1542
- for (const route of resolved.routes) {
1543
- if (route.render !== "ssg" && route.render !== "isg") continue;
1544
- const paths = await collectSSGPaths(route, options.registry);
1545
- for (const pathname of paths) work.push({
1546
- pathname,
1547
- render: route.render,
1548
- revalidate: route.revalidate
1549
- });
1550
- }
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);
1555
- const batchResults = await Promise.all(batch.map(async (item) => {
1556
- const url = new URL(item.pathname, "http://localhost");
1557
- const request = new Request(url, { method: "GET" });
1558
- const response = await handlePrachtRequest({
1559
- app: options.app,
1560
- request,
1561
- registry: options.registry,
1562
- clientEntryUrl: options.clientEntryUrl,
1563
- cssManifest: options.cssManifest,
1564
- jsManifest: options.jsManifest
1565
- });
1566
- if (response.status !== 200) {
1567
- console.warn(` Warning: ${item.render.toUpperCase()} route "${item.pathname}" returned status ${response.status}, skipping.`);
1568
- return null;
1569
- }
1570
- const html = await response.text();
1571
- return {
1572
- headers: Object.fromEntries(response.headers),
1573
- html,
1574
- item
1575
- };
1576
- }));
1577
- for (const result of batchResults) {
1578
- if (!result) continue;
1579
- results.push({
1580
- path: result.item.pathname,
1581
- html: result.html,
1582
- headers: result.headers
1583
- });
1584
- if (result.item.render === "isg" && result.item.revalidate) isgManifest[result.item.pathname] = { revalidate: result.item.revalidate };
1585
- }
1586
- }
1587
- if (options.withISGManifest) return {
1588
- pages: results,
1589
- isgManifest
1590
- };
1591
- return results;
1592
- }
1593
- async function collectSSGPaths(route, registry) {
1594
- if (!route.segments.some((s) => s.type === "param" || s.type === "catchall")) return [route.path];
1595
- const routeModule = await resolveRegistryModule(registry?.routeModules, route.file);
1596
- if (!routeModule?.getStaticPaths) {
1597
- console.warn(` Warning: SSG route "${route.path}" has dynamic segments but no getStaticPaths() export, skipping.`);
1598
- return [];
1599
- }
1600
- return (await routeModule.getStaticPaths()).map((params) => buildPathFromSegments(route.segments, params));
1601
- }
1602
- //#endregion
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
- };
1614
- }
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);
1626
- }
1627
- return;
1628
- }
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";
1665
- }
1666
- return "Unknown";
1667
- }
1668
- //#endregion
1669
- //#region src/router.ts
1670
- const NavigateContext = createContext(async () => {});
1671
- function useNavigate() {
1672
- return useContext(NavigateContext);
1673
- }
1674
- async function initClientRouter(options) {
1675
- const { app, routeModules, shellModules, root, findModuleKey } = options;
1676
- if (import.meta.env?.DEV) installHydrationMismatchWarning();
1677
- const moduleCache = /* @__PURE__ */ new Map();
1678
- function loadModule(modules, key) {
1679
- let cached = moduleCache.get(key);
1680
- if (!cached) {
1681
- cached = modules[key]();
1682
- moduleCache.set(key, cached);
1683
- }
1684
- return cached;
1685
- }
1686
- function startRouteImport(match) {
1687
- const routeKey = findModuleKey(routeModules, match.route.file);
1688
- if (!routeKey) return null;
1689
- return loadModule(routeModules, routeKey);
1690
- }
1691
- function startShellImport(match) {
1692
- if (!match.route.shellFile) return null;
1693
- const shellKey = findModuleKey(shellModules, match.route.shellFile);
1694
- if (!shellKey) return null;
1695
- return loadModule(shellModules, shellKey);
1696
- }
1697
- let updateRouteState = null;
1698
- let routeStateVersion = 0;
1699
- let latestNavigationId = 0;
1700
- let activeNavigationAbort = null;
1701
- function RouterRoot({ initialState }) {
1702
- const [routeState, setRouteState] = useState(initialState);
1703
- updateRouteState = setRouteState;
1704
- const navigateValue = useMemo(() => navigate, []);
1705
- const { Shell, Component, componentProps, data, params, routeId, url, version } = routeState;
1706
- const componentTree = Shell ? h(Shell, null, h(Component, componentProps)) : h(Component, componentProps);
1707
- return h(NavigateContext.Provider, { value: navigateValue }, h(PrachtRuntimeProvider, {
1708
- data,
1709
- params,
1710
- routeId,
1711
- stateVersion: version,
1712
- url
1713
- }, componentTree));
1714
- }
1715
- function applyRouteState(routeState) {
1716
- if (updateRouteState) {
1717
- updateRouteState(routeState);
1718
- return;
1719
- }
1720
- render(h(RouterRoot, { initialState: routeState }), root);
1721
- }
1722
- async function resolveRouteState(match, state, currentUrl, routeModPromise, shellModPromise) {
1723
- const routeMod = await (routeModPromise ?? startRouteImport(match));
1724
- if (!routeMod) return null;
1725
- let Shell = null;
1726
- const resolvedShell = await (shellModPromise ?? startShellImport(match));
1727
- if (resolvedShell) Shell = resolvedShell.Shell;
1728
- const DefaultComponent = typeof routeMod.default === "function" ? routeMod.default : void 0;
1729
- const ErrorBoundary = routeMod.ErrorBoundary ?? resolvedShell?.ErrorBoundary;
1730
- const Component = state.error ? ErrorBoundary : routeMod.Component ?? DefaultComponent;
1731
- if (!Component) return null;
1732
- const componentProps = state.error ? { error: deserializeRouteError(state.error) } : {
1733
- data: state.data,
1734
- params: match.params
1735
- };
1736
- return {
1737
- Shell,
1738
- Component,
1739
- componentProps,
1740
- data: state.data,
1741
- params: match.params,
1742
- routeId: match.route.id ?? "",
1743
- url: currentUrl,
1744
- version: ++routeStateVersion
1745
- };
1746
- }
1747
- async function resolveSpaPendingState(match, currentUrl, shellModPromise) {
1748
- const resolvedShell = await (shellModPromise ?? startShellImport(match));
1749
- if (!resolvedShell) return null;
1750
- const Shell = resolvedShell.Shell ?? null;
1751
- const Loading = resolvedShell.Loading;
1752
- if (!Shell && !Loading) return null;
1753
- return {
1754
- Shell,
1755
- Component: Loading ?? (() => null),
1756
- componentProps: {},
1757
- data: void 0,
1758
- params: match.params,
1759
- routeId: match.route.id ?? "",
1760
- url: currentUrl,
1761
- version: ++routeStateVersion
1762
- };
1763
- }
1764
- function resolveRedirectTarget(location) {
1765
- const targetUrl = parseSafeNavigationUrl(location, window.location.href);
1766
- if (!targetUrl) return {
1767
- isCurrentLocation: false,
1768
- unsafe: true
1769
- };
1770
- const fullInternalTarget = targetUrl.pathname + targetUrl.search + targetUrl.hash;
1771
- const internalPath = targetUrl.pathname + targetUrl.search;
1772
- const currentPath = window.location.pathname + window.location.search + window.location.hash;
1773
- const isCurrentLocation = targetUrl.origin === window.location.origin && fullInternalTarget === currentPath;
1774
- if (targetUrl.origin !== window.location.origin) return {
1775
- externalUrl: targetUrl.toString(),
1776
- isCurrentLocation: false
1777
- };
1778
- if (targetUrl.hash) return {
1779
- documentUrl: targetUrl.toString(),
1780
- isCurrentLocation
1781
- };
1782
- return {
1783
- internalPath,
1784
- isCurrentLocation
1785
- };
1786
- }
1787
- async function navigate(to, opts) {
1788
- const navigationId = ++latestNavigationId;
1789
- activeNavigationAbort?.abort();
1790
- const abortController = new AbortController();
1791
- activeNavigationAbort = abortController;
1792
- const target = resolveBrowserRouteTarget(to);
1793
- if (!target) {
1794
- window.location.href = to;
1795
- return;
1796
- }
1797
- const match = matchAppRoute(app, target.pathname);
1798
- if (!match) {
1799
- window.location.href = target.browserUrl;
1800
- return;
1801
- }
1802
- const statePromise = getCachedRouteState(target.requestUrl) ?? fetchPrachtRouteState(target.requestUrl, { signal: abortController.signal });
1803
- const routeModPromise = startRouteImport(match);
1804
- const shellModPromise = startShellImport(match);
1805
- let state = {
1806
- data: void 0,
1807
- error: null
1808
- };
1809
- try {
1810
- const result = await statePromise;
1811
- if (navigationId !== latestNavigationId) return;
1812
- if (result.type === "redirect") {
1813
- if (result.location) {
1814
- const redirect = resolveRedirectTarget(result.location);
1815
- if (redirect.unsafe) {
1816
- console.error(`[pracht] refused to navigate to unsafe URL: ${result.location}`);
1817
- return;
1818
- }
1819
- if (redirect.externalUrl) {
1820
- window.location.href = redirect.externalUrl;
1821
- return;
1822
- }
1823
- if (redirect.isCurrentLocation) return;
1824
- if (redirect.documentUrl) {
1825
- window.location.href = redirect.documentUrl;
1826
- return;
1827
- }
1828
- if (redirect.internalPath) {
1829
- await navigate(redirect.internalPath, opts);
1830
- return;
1831
- }
1832
- window.location.href = target.browserUrl;
1833
- return;
1834
- }
1835
- window.location.href = target.browserUrl;
1836
- return;
1837
- }
1838
- if (result.type === "error") state = {
1839
- data: void 0,
1840
- error: result.error
1841
- };
1842
- else state = {
1843
- data: result.data,
1844
- error: null
1845
- };
1846
- } catch {
1847
- if (abortController.signal.aborted || navigationId !== latestNavigationId) return;
1848
- window.location.href = target.browserUrl;
1849
- return;
1850
- }
1851
- if (navigationId !== latestNavigationId) return;
1852
- if (!opts?._popstate) if (opts?.replace) history.replaceState(null, "", target.browserUrl);
1853
- else history.pushState(null, "", target.browserUrl);
1854
- const routeState = await resolveRouteState(match, state, target.requestUrl, routeModPromise, shellModPromise);
1855
- if (navigationId !== latestNavigationId) return;
1856
- if (routeState) {
1857
- applyRouteState(routeState);
1858
- window.scrollTo(0, 0);
1859
- } else window.location.href = target.browserUrl;
1860
- }
1861
- const initialTarget = resolveBrowserRouteTarget(options.initialState.url);
1862
- const initialRequestUrl = initialTarget?.requestUrl ?? options.initialState.url;
1863
- const initialBrowserUrl = initialTarget?.browserUrl ?? options.initialState.url;
1864
- const initialMatch = matchAppRoute(app, initialTarget?.pathname ?? options.initialState.url);
1865
- if (initialMatch) {
1866
- const initialShellPromise = initialMatch.route.render === "spa" && options.initialState.pending ? startShellImport(initialMatch) : null;
1867
- let state = {
1868
- data: options.initialState.data,
1869
- error: options.initialState.error ?? null
1870
- };
1871
- if (initialMatch.route.render === "spa" && options.initialState.pending) {
1872
- const dataPromise = fetchPrachtRouteState(initialRequestUrl, { useDataParam: true });
1873
- const pendingState = await resolveSpaPendingState(initialMatch, initialRequestUrl, initialShellPromise);
1874
- if (pendingState) hydrate(h(RouterRoot, { initialState: pendingState }), root);
1875
- try {
1876
- const result = await dataPromise;
1877
- if (result.type === "redirect") {
1878
- const safeRedirect = parseSafeNavigationUrl(result.location, window.location.href);
1879
- if (!safeRedirect) {
1880
- console.error(`[pracht] refused to navigate to unsafe URL: ${result.location}`);
1881
- return;
1882
- }
1883
- window.location.href = safeRedirect.toString();
1884
- return;
1885
- }
1886
- if (result.type === "error") state = {
1887
- data: void 0,
1888
- error: result.error
1889
- };
1890
- else state = {
1891
- data: result.data,
1892
- error: null
1893
- };
1894
- } catch {
1895
- window.location.href = initialBrowserUrl;
1896
- return;
1897
- }
1898
- const resolvedState = await resolveRouteState(initialMatch, state, initialRequestUrl, void 0, initialShellPromise);
1899
- if (resolvedState) applyRouteState(resolvedState);
1900
- } else {
1901
- const initialRouteState = await resolveRouteState(initialMatch, state, initialRequestUrl, void 0, initialShellPromise);
1902
- if (initialRouteState) if (initialMatch.route.render === "spa") render(h(RouterRoot, { initialState: initialRouteState }), root);
1903
- else {
1904
- markHydrating();
1905
- hydrate(h(RouterRoot, { initialState: initialRouteState }), root);
1906
- }
1907
- }
1908
- }
1909
- document.addEventListener("click", (e) => {
1910
- const anchor = e.target.closest?.("a");
1911
- if (!anchor) return;
1912
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
1913
- if (e.defaultPrevented) return;
1914
- if (e.button !== 0) return;
1915
- const target = anchor.getAttribute("target");
1916
- if (target && target !== "_self") return;
1917
- if (anchor.hasAttribute("download")) return;
1918
- const href = anchor.getAttribute("href");
1919
- if (!href || href.startsWith("#")) return;
1920
- let url;
1921
- try {
1922
- url = new URL(href, window.location.origin);
1923
- } catch {
1924
- return;
1925
- }
1926
- if (url.origin !== window.location.origin) return;
1927
- e.preventDefault();
1928
- navigate(url.pathname + url.search + url.hash);
1929
- });
1930
- window.addEventListener("popstate", () => {
1931
- navigate(window.location.pathname + window.location.search + window.location.hash, { _popstate: true });
1932
- });
1933
- window.__PRACHT_NAVIGATE__ = navigate;
1934
- window.__PRACHT_ROUTER_READY__ = true;
1935
- const warmModules = (match) => {
1936
- startRouteImport(match);
1937
- startShellImport(match);
1938
- };
1939
- setupPrefetching(app, warmModules);
1940
- }
1941
- function resolveBrowserRouteTarget(to) {
1942
- if (typeof window === "undefined") return null;
1943
- try {
1944
- const url = new URL(to, window.location.href);
1945
- if (url.origin !== window.location.origin) return null;
1946
- return {
1947
- browserUrl: url.pathname + url.search + url.hash,
1948
- pathname: url.pathname,
1949
- requestUrl: url.pathname + url.search
1950
- };
1951
- } catch {
1952
- return null;
1953
- }
1954
- }
1955
- //#endregion
1956
- //#region src/types.ts
1957
- var PrachtHttpError = class extends Error {
1958
- status;
1959
- constructor(status, message) {
1960
- super(message);
1961
- this.name = "PrachtHttpError";
1962
- this.status = status;
1963
- }
1964
- };
1965
- //#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 };
8
+ export { Form, Link, PrachtHttpError, PrachtRuntimeProvider, Suspense, applyDefaultSecurityHeaders, buildHref, buildPathFromSegments, createHref, defineApp, forwardRef, group, handlePrachtRequest, initClientRouter, lazy, matchApiRoute, matchAppRoute, prerenderApp, readHydrationState, redirect, resolveApiRoutes, resolveApp, route, startApp, timeRevalidate, useIsHydrated, useLocation, useNavigate, useParams, useRevalidate, useRouteData };