@pracht/core 0.7.0 → 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,2089 +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
- hasLoader: !!loader,
26
- ...routeMeta
27
- };
28
- }
29
- function resolveModuleRef(ref) {
30
- if (ref === void 0) return void 0;
31
- if (typeof ref === "string") return ref;
32
- return "";
33
- }
34
- function group(meta, routes) {
35
- return {
36
- kind: "group",
37
- meta,
38
- routes
39
- };
40
- }
41
- function defineApp(config) {
42
- return {
43
- shells: resolveModuleRefRecord(config.shells ?? {}),
44
- middleware: resolveModuleRefRecord(config.middleware ?? {}),
45
- api: config.api ?? {},
46
- routes: config.routes
47
- };
48
- }
49
- function resolveModuleRefRecord(record) {
50
- const result = {};
51
- for (const [key, value] of Object.entries(record)) result[key] = resolveModuleRef(value);
52
- return result;
53
- }
54
- function resolveApp(app) {
55
- const routes = [];
56
- const inherited = {
57
- pathPrefix: "/",
58
- middleware: []
59
- };
60
- for (const node of app.routes) flattenRouteNode(app, node, inherited, routes);
61
- return {
62
- shells: app.shells,
63
- middleware: app.middleware,
64
- api: app.api,
65
- routes,
66
- apiRoutes: []
67
- };
68
- }
69
- function matchAppRoute(app, pathname) {
70
- const resolved = isResolvedApp(app) ? app : resolveApp(app);
71
- const normalizedPathname = normalizeRoutePath(pathname);
72
- const targetSegments = splitPathSegments(normalizedPathname);
73
- for (const currentRoute of resolved.routes) {
74
- const params = matchRouteSegments(currentRoute.segments, targetSegments);
75
- if (params) return {
76
- route: currentRoute,
77
- params,
78
- pathname: normalizedPathname
79
- };
80
- }
81
- }
82
- function flattenRouteNode(app, node, inherited, routes) {
83
- if (node.kind === "group") {
84
- const nextInherited = {
85
- pathPrefix: mergeRoutePaths(inherited.pathPrefix, node.meta.pathPrefix),
86
- shell: node.meta.shell ?? inherited.shell,
87
- render: node.meta.render ?? inherited.render,
88
- middleware: [...inherited.middleware, ...node.meta.middleware ?? []]
89
- };
90
- for (const child of node.routes) flattenRouteNode(app, child, nextInherited, routes);
91
- return;
92
- }
93
- const fullPath = mergeRoutePaths(inherited.pathPrefix, node.path);
94
- const shell = node.shell ?? inherited.shell;
95
- const middleware = [...inherited.middleware, ...node.middleware ?? []];
96
- routes.push({
97
- id: node.id ?? createRouteId(fullPath),
98
- path: fullPath,
99
- file: node.file,
100
- loaderFile: node.loaderFile,
101
- hasLoader: node.loaderFile ? true : node.hasLoader,
102
- shell,
103
- shellFile: shell ? app.shells[shell] : void 0,
104
- render: node.render ?? inherited.render,
105
- middleware,
106
- middlewareFiles: middleware.flatMap((name) => {
107
- const middlewareFile = app.middleware[name];
108
- return middlewareFile ? [middlewareFile] : [];
109
- }),
110
- revalidate: node.revalidate,
111
- segments: parseRouteSegments(fullPath)
112
- });
113
- }
114
- function isResolvedApp(app) {
115
- return app.routes.length === 0 || "segments" in app.routes[0];
116
- }
117
- function matchRouteSegments(routeSegments, targetSegments) {
118
- const params = {};
119
- let routeIndex = 0;
120
- let targetIndex = 0;
121
- while (routeIndex < routeSegments.length) {
122
- const currentSegment = routeSegments[routeIndex];
123
- if (currentSegment.type === "catchall") {
124
- try {
125
- params[currentSegment.name] = targetSegments.slice(targetIndex).map(decodeURIComponent).join("/");
126
- } catch {
127
- return null;
128
- }
129
- return params;
130
- }
131
- const targetSegment = targetSegments[targetIndex];
132
- if (typeof targetSegment === "undefined") return null;
133
- if (currentSegment.type === "static") {
134
- if (currentSegment.value !== targetSegment) return null;
135
- } else try {
136
- params[currentSegment.name] = decodeURIComponent(targetSegment);
137
- } catch {
138
- return null;
139
- }
140
- routeIndex += 1;
141
- targetIndex += 1;
142
- }
143
- return targetIndex === targetSegments.length ? params : null;
144
- }
145
- function parseRouteSegments(path) {
146
- return splitPathSegments(path).map((segment) => {
147
- if (segment === "*") return {
148
- type: "catchall",
149
- name: "*"
150
- };
151
- if (segment.startsWith(":") && segment.endsWith("*")) return {
152
- type: "catchall",
153
- name: segment.slice(1, -1) || "*"
154
- };
155
- if (segment.startsWith(":")) return {
156
- type: "param",
157
- name: segment.slice(1)
158
- };
159
- assertSafeStaticRouteSegment(segment);
160
- return {
161
- type: "static",
162
- value: segment
163
- };
164
- });
165
- }
166
- function splitPathSegments(path) {
167
- return normalizeRoutePath(path).split("/").filter(Boolean);
168
- }
169
- function assertSafeStaticRouteSegment(segment) {
170
- if (segment === "." || segment === "..") throw new Error(`Unsafe static route segment "${segment}" is not allowed.`);
171
- if (segment.includes("\0") || /[\r\n\\]/.test(segment)) throw new Error(`Unsafe static route segment "${segment}" contains a forbidden character.`);
172
- }
173
- function mergeRoutePaths(prefix, path) {
174
- if (!path) return normalizeRoutePath(prefix);
175
- const normalizedPrefix = normalizeRoutePath(prefix);
176
- const normalizedPath = normalizeRoutePath(path);
177
- if (normalizedPrefix === "/") return normalizedPath;
178
- if (normalizedPath === "/") return normalizedPrefix;
179
- return normalizeRoutePath(`${normalizedPrefix}/${normalizedPath.slice(1)}`);
180
- }
181
- function normalizeRoutePath(path) {
182
- if (!path || path === "/") return "/";
183
- const collapsed = (path.startsWith("/") ? path : `/${path}`).replace(/\/{2,}/g, "/");
184
- return collapsed.length > 1 && collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
185
- }
186
- function buildPathFromSegments(segments, params) {
187
- return normalizeRoutePath("/" + segments.map((segment) => {
188
- if (segment.type === "static") return segment.value;
189
- if (segment.type === "param") return encodeDynamicPathSegment(params[segment.name] ?? "");
190
- return (params[segment.name] ?? params["*"] ?? "").split("/").map((part) => encodeDynamicPathSegment(part)).join("/");
191
- }).join("/"));
192
- }
193
- function buildHref(routes, routeId, ...args) {
194
- return buildHrefUntyped(routes, String(routeId), args[0]);
195
- }
196
- function createHref(routes) {
197
- return ((routeId, options) => buildHrefUntyped(routes, routeId, options));
198
- }
199
- function buildHrefUntyped(routes, routeId, options = {}) {
200
- const route = routes.find((candidate) => candidate.id === routeId);
201
- if (!route) throw new Error(`Unknown pracht route id: ${routeId}.`);
202
- const segments = route.segments ?? parseRouteSegments(route.path);
203
- return `${buildPathFromSegments(segments, normalizeHrefParams(segments, options.params ?? {}))}${serializeSearch(options.search)}${serializeHash(options.hash)}`;
204
- }
205
- function normalizeHrefParams(segments, params) {
206
- const expected = new Set(segments.filter((segment) => segment.type === "param" || segment.type === "catchall").map((segment) => segment.name));
207
- for (const name of expected) if (params[name] == null) throw new Error(`Missing route param: ${name}.`);
208
- for (const name of Object.keys(params)) if (!expected.has(name)) throw new Error(`Unexpected route param: ${name}.`);
209
- const normalized = {};
210
- for (const name of expected) normalized[name] = String(params[name]);
211
- return normalized;
212
- }
213
- function serializeSearch(search) {
214
- if (search == null) return "";
215
- if (typeof search === "string") {
216
- if (!search) return "";
217
- return search.startsWith("?") ? search : `?${search}`;
218
- }
219
- const serialized = (search instanceof URLSearchParams ? search : objectToSearchParams(search)).toString();
220
- return serialized ? `?${serialized}` : "";
221
- }
222
- function objectToSearchParams(search) {
223
- const params = new URLSearchParams();
224
- for (const [key, value] of Object.entries(search)) {
225
- if (Array.isArray(value)) {
226
- for (const item of value) appendSearchValue(params, key, item);
227
- continue;
228
- }
229
- appendSearchValue(params, key, value);
230
- }
231
- return params;
232
- }
233
- function appendSearchValue(params, key, value) {
234
- if (value == null) return;
235
- params.append(key, String(value));
236
- }
237
- function serializeHash(hash) {
238
- if (!hash) return "";
239
- return hash.startsWith("#") ? hash : `#${hash}`;
240
- }
241
- /**
242
- * Encode one dynamic URL path segment for SSG/ISG output. `encodeURIComponent`
243
- * leaves unreserved characters (including `.`) intact, and even percent-encoded
244
- * dot segments are normalized by URL parsers. Reject exact `.` / `..` segments
245
- * instead of allowing them to reach filesystem output path construction.
246
- */
247
- function encodeDynamicPathSegment(part) {
248
- if (part === "." || part === "..") throw new Error(`Unsafe dynamic route param segment "${part}" is not allowed.`);
249
- return encodeURIComponent(part);
250
- }
251
- /**
252
- * Convert a list of file paths from `import.meta.glob` into resolved API routes.
253
- *
254
- * Example: `"/src/api/health.ts"` → path `/api/health`
255
- * `"/src/api/users/[id].ts"` → path `/api/users/:id`
256
- * `"/src/api/files/[...path].ts"` → path `/api/files/*`
257
- * `"/src/api/index.ts"` → path `/api`
258
- */
259
- function resolveApiRoutes(files, apiDir = "/src/api") {
260
- const normalizedDir = apiDir.replace(/\/$/, "");
261
- return files.map((file) => {
262
- let relative = file;
263
- if (relative.startsWith(normalizedDir)) relative = relative.slice(normalizedDir.length);
264
- relative = relative.replace(/\.(ts|tsx|js|jsx)$/, "");
265
- if (relative.endsWith("/index")) relative = relative.slice(0, -6) || "/";
266
- relative = relative.replace(/\[\.\.\.[^\]]+\]/g, "*");
267
- relative = relative.replace(/\[([^\]]+)\]/g, ":$1");
268
- const path = normalizeRoutePath(`/api${relative}`);
269
- return {
270
- path,
271
- file,
272
- segments: parseRouteSegments(path)
273
- };
274
- }).sort(compareResolvedApiRoutes);
275
- }
276
- function matchApiRoute(apiRoutes, pathname) {
277
- const normalizedPathname = normalizeRoutePath(pathname);
278
- const targetSegments = splitPathSegments(normalizedPathname);
279
- for (const route of apiRoutes) {
280
- const params = matchRouteSegments(route.segments, targetSegments);
281
- if (params) return {
282
- route,
283
- params,
284
- pathname: normalizedPathname
285
- };
286
- }
287
- }
288
- function createRouteId(path) {
289
- if (path === "/") return "index";
290
- return path.slice(1).split("/").map((segment) => {
291
- if (segment === "*") return "splat";
292
- return segment.startsWith(":") ? segment.slice(1) : segment;
293
- }).join("-").replace(/[^a-zA-Z0-9-]/g, "-");
294
- }
295
- function compareResolvedApiRoutes(left, right) {
296
- const length = Math.max(left.segments.length, right.segments.length);
297
- for (let index = 0; index < length; index += 1) {
298
- const leftSegment = left.segments[index];
299
- const rightSegment = right.segments[index];
300
- if (!leftSegment) return 1;
301
- if (!rightSegment) return -1;
302
- const leftScore = getRouteSegmentSpecificity(leftSegment);
303
- const rightScore = getRouteSegmentSpecificity(rightSegment);
304
- if (leftScore !== rightScore) return rightScore - leftScore;
305
- }
306
- return left.path.localeCompare(right.path);
307
- }
308
- function getRouteSegmentSpecificity(segment) {
309
- if (segment.type === "static") return 3;
310
- if (segment.type === "param") return 2;
311
- return 1;
312
- }
313
- //#endregion
314
- //#region src/forwardRef.ts
315
- let oldDiffHook = options.__b;
316
- options.__b = (vnode) => {
317
- if (vnode.type && vnode.type.__f && vnode.ref) {
318
- vnode.props.ref = vnode.ref;
319
- vnode.ref = null;
320
- }
321
- if (oldDiffHook) oldDiffHook(vnode);
322
- };
323
- /**
324
- * Pass ref down to a child. This is mainly used in libraries with HOCs that
325
- * wrap components. Using `forwardRef` there is an easy way to get a reference
326
- * of the wrapped component instead of one of the wrapper itself.
327
- */
328
- function forwardRef(fn) {
329
- function Forwarded(props) {
330
- const clone = { ...props };
331
- delete clone.ref;
332
- return fn(clone, props.ref || null);
333
- }
334
- Forwarded.__f = true;
335
- Forwarded.displayName = "ForwardRef(" + (fn.displayName || fn.name) + ")";
336
- return Forwarded;
337
- }
338
- //#endregion
339
- //#region src/hydration.ts
340
- const MODE_HYDRATE = 32;
341
- let _hydrating = false;
342
- let _suspensionCount = 0;
343
- let _hydrated = false;
344
- const oldCatchError = options.__e;
345
- options.__e = (err, newVNode, oldVNode, errorInfo) => {
346
- if (_hydrating && !_hydrated && err && err.then) {
347
- if (!!(newVNode && newVNode.__u && newVNode.__u & MODE_HYDRATE) || !!(newVNode && newVNode.__h)) {
348
- _suspensionCount++;
349
- let settled = false;
350
- const onSettled = () => {
351
- if (settled) return;
352
- settled = true;
353
- _suspensionCount--;
354
- };
355
- err.then(onSettled, onSettled);
356
- }
357
- }
358
- if (oldCatchError) oldCatchError(err, newVNode, oldVNode, errorInfo);
359
- };
360
- const oldCommit = options.__c;
361
- options.__c = (vnode, commitQueue) => {
362
- if (_hydrating && !_hydrated && _suspensionCount <= 0) {
363
- _hydrated = true;
364
- _hydrating = false;
365
- }
366
- if (oldCommit) oldCommit(vnode, commitQueue);
367
- };
368
- /**
369
- * Mark the start of a hydration pass. Call this right before `hydrate()`.
370
- */
371
- function markHydrating() {
372
- if (!_hydrated) _hydrating = true;
373
- }
374
- /**
375
- * Returns `true` once the initial hydration (including all Suspense
376
- * boundaries) has fully resolved. During SSR and hydration this returns
377
- * `false`.
378
- */
379
- function useIsHydrated() {
380
- const [hydrated, setHydrated] = useState(_hydrated);
381
- useEffect(() => {
382
- setHydrated(true);
383
- }, []);
384
- return hydrated;
385
- }
386
- //#endregion
387
- //#region src/runtime-constants.ts
388
- const SAFE_METHODS = new Set(["GET", "HEAD"]);
389
- const HYDRATION_STATE_ELEMENT_ID = "pracht-state";
390
- const ROUTE_STATE_REQUEST_HEADER = "x-pracht-route-state-request";
391
- const ROUTE_STATE_CACHE_CONTROL = "no-store";
392
- const EMPTY_ROUTE_PARAMS = {};
393
- //#endregion
394
- //#region src/runtime-errors.ts
395
- function isPrachtHttpError(error) {
396
- return error instanceof Error && error.name === "PrachtHttpError" && "status" in error;
397
- }
398
- let warnedAboutProductionDebugErrors = false;
399
- /**
400
- * `debugErrors: true` opts into surfacing stack traces, module paths,
401
- * and middleware names in error responses. That is great in dev and
402
- * dangerous in production — a misconfigured deploy would leak internals
403
- * to the public. When `NODE_ENV === "production"` we refuse to honor
404
- * the flag and emit a single console warning so the misconfiguration
405
- * is visible in logs.
406
- */
407
- function shouldExposeServerErrors(options) {
408
- if (options.debugErrors !== true) return false;
409
- if ((typeof process !== "undefined" && process.env ? process.env.NODE_ENV : typeof globalThis !== "undefined" && globalThis.process ? globalThis.process?.env?.NODE_ENV : void 0) === "production") {
410
- if (!warnedAboutProductionDebugErrors) {
411
- warnedAboutProductionDebugErrors = true;
412
- console.warn("[pracht] debugErrors is ignored in production builds. Remove it to silence this warning.");
413
- }
414
- return false;
415
- }
416
- return true;
417
- }
418
- function createSerializedRouteError(message, status, options = {}) {
419
- return {
420
- message,
421
- name: options.name ?? "Error",
422
- status,
423
- ...options.diagnostics ? { diagnostics: options.diagnostics } : {}
424
- };
425
- }
426
- function buildRuntimeDiagnostics(options) {
427
- const route = options.route;
428
- const routeId = route && "id" in route ? route.id : void 0;
429
- return {
430
- phase: options.phase,
431
- routeId,
432
- routePath: route?.path,
433
- routeFile: route?.file,
434
- loaderFile: options.loaderFile,
435
- shellFile: options.shellFile,
436
- middlewareFiles: options.middlewareFiles ? [...options.middlewareFiles] : [],
437
- status: options.status
438
- };
439
- }
440
- function normalizeRouteError(error, options) {
441
- if (isPrachtHttpError(error)) {
442
- const status = typeof error.status === "number" ? error.status : 500;
443
- if (status >= 400 && status < 500) return {
444
- message: error.message,
445
- name: error.name,
446
- status
447
- };
448
- if (options.exposeDetails) return {
449
- message: error.message || "Internal Server Error",
450
- name: error.name || "Error",
451
- status
452
- };
453
- return {
454
- message: "Internal Server Error",
455
- name: "Error",
456
- status
457
- };
458
- }
459
- if (error instanceof Error) {
460
- if (options.exposeDetails) return {
461
- message: error.message || "Internal Server Error",
462
- name: error.name || "Error",
463
- status: 500
464
- };
465
- return {
466
- message: "Internal Server Error",
467
- name: "Error",
468
- status: 500
469
- };
470
- }
471
- if (options.exposeDetails) return {
472
- message: typeof error === "string" && error ? error : "Internal Server Error",
473
- name: "Error",
474
- status: 500
475
- };
476
- return {
477
- message: "Internal Server Error",
478
- name: "Error",
479
- status: 500
480
- };
481
- }
482
- function deserializeRouteError(error) {
483
- const result = new Error(error.message);
484
- result.name = error.name;
485
- result.status = error.status;
486
- result.diagnostics = error.diagnostics;
487
- return result;
488
- }
489
- //#endregion
490
- //#region src/runtime-headers.ts
491
- const HEADER_CRLF_RE = /[\r\n]/;
492
- /**
493
- * Reject header values containing CR/LF. Some runtimes (Node `undici`
494
- * Headers) throw on their own, but Web-runtime fetch implementations
495
- * vary, and a user-supplied `headers()` value is never trusted input.
496
- * Keeping the check here means response-splitting can't slip through on
497
- * any adapter.
498
- */
499
- function assertSafeHeaderValue(name, value) {
500
- if (HEADER_CRLF_RE.test(value)) throw new Error(`Refused to set header "${name}": value contains CR or LF`);
501
- }
502
- function applyHeaders(headers, init) {
503
- for (const [key, value] of iterateHeaderInit(init)) assertSafeHeaderValue(key, value);
504
- new Headers(init).forEach((value, key) => {
505
- headers.set(key, value);
506
- });
507
- }
508
- function* iterateHeaderInit(init) {
509
- if (init instanceof Headers) {
510
- for (const entry of init.entries()) yield entry;
511
- return;
512
- }
513
- if (Array.isArray(init)) {
514
- for (const entry of init) if (entry && entry.length >= 2) yield [entry[0], entry[1]];
515
- return;
516
- }
517
- for (const [key, value] of Object.entries(init)) yield [key, value];
518
- }
519
- function applyDefaultSecurityHeaders(headers) {
520
- if (!headers.has("permissions-policy")) headers.set("permissions-policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()");
521
- if (!headers.has("referrer-policy")) headers.set("referrer-policy", "strict-origin-when-cross-origin");
522
- if (!headers.has("x-content-type-options")) headers.set("x-content-type-options", "nosniff");
523
- if (!headers.has("x-frame-options")) headers.set("x-frame-options", "SAMEORIGIN");
524
- return headers;
525
- }
526
- function applySecurityAndRouteHeaders(headers, options) {
527
- applyDefaultSecurityHeaders(headers);
528
- if (options) {
529
- appendVaryHeader(headers, ROUTE_STATE_REQUEST_HEADER);
530
- if (options.isRouteStateRequest && !headers.has("cache-control")) headers.set("cache-control", ROUTE_STATE_CACHE_CONTROL);
531
- }
532
- return headers;
533
- }
534
- function withDefaultSecurityHeaders(response) {
535
- const headers = new Headers(response.headers);
536
- applySecurityAndRouteHeaders(headers);
537
- return new Response(response.body, {
538
- status: response.status,
539
- statusText: response.statusText,
540
- headers
541
- });
542
- }
543
- function withRouteResponseHeaders(response, options) {
544
- const headers = new Headers(response.headers);
545
- applySecurityAndRouteHeaders(headers, options);
546
- return new Response(response.body, {
547
- status: response.status,
548
- statusText: response.statusText,
549
- headers
550
- });
551
- }
552
- function appendVaryHeader(headers, value) {
553
- const current = headers.get("vary");
554
- if (!current) {
555
- headers.set("vary", value);
556
- return;
557
- }
558
- const values = current.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
559
- if (values.includes("*") || values.includes(value.toLowerCase())) return;
560
- headers.set("vary", `${current}, ${value}`);
561
- }
562
- //#endregion
563
- //#region src/runtime-client-fetch.ts
564
- const SAFE_NAVIGATION_PROTOCOLS = new Set(["http:", "https:"]);
565
- /**
566
- * Parse a possibly-server-supplied redirect target against a base URL and
567
- * return it only if it uses a safe navigation scheme (`http:` or `https:`).
568
- *
569
- * `javascript:`, `data:`, `vbscript:`, `blob:`, `file:` and similar schemes
570
- * can execute script or bypass same-origin assumptions when assigned to
571
- * `window.location.href` — a server-controlled redirect (from a loader,
572
- * middleware, form action response, or API route) must never be able to
573
- * trigger them. Returns `null` for unsafe or unparseable inputs.
574
- */
575
- function parseSafeNavigationUrl(location, base) {
576
- let targetUrl;
577
- try {
578
- targetUrl = new URL(location, base);
579
- } catch {
580
- return null;
581
- }
582
- if (!SAFE_NAVIGATION_PROTOCOLS.has(targetUrl.protocol)) return null;
583
- return targetUrl;
584
- }
585
- function routeNeedsServerFetch(route) {
586
- if (route.hasLoader === false && route.middlewareFiles.length === 0) return false;
587
- return true;
588
- }
589
- function buildRouteStateUrl(url) {
590
- return `${url}${url.includes("?") ? "&" : "?"}_data=1`;
591
- }
592
- async function fetchPrachtRouteState(url, options) {
593
- const fetchUrl = options?.useDataParam ? buildRouteStateUrl(url) : url;
594
- const response = await fetch(fetchUrl, {
595
- headers: options?.useDataParam ? {} : {
596
- [ROUTE_STATE_REQUEST_HEADER]: "1",
597
- "Cache-Control": "no-cache"
598
- },
599
- redirect: "manual",
600
- signal: options?.signal
601
- });
602
- if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) return {
603
- location: response.headers.get("location") ?? url,
604
- type: "redirect"
605
- };
606
- const json = await response.json();
607
- if (json.redirect) return {
608
- location: json.redirect,
609
- type: "redirect"
610
- };
611
- if (!response.ok) {
612
- if (json.error) return {
613
- error: json.error,
614
- type: "error"
615
- };
616
- throw new Error(`Failed to fetch route state (${response.status})`);
617
- }
618
- return {
619
- data: json.data,
620
- type: "data"
621
- };
622
- }
623
- async function navigateToClientLocation(location, options) {
624
- if (typeof window === "undefined") return;
625
- const targetUrl = parseSafeNavigationUrl(location, window.location.href);
626
- if (!targetUrl) {
627
- console.error(`[pracht] refused to navigate to unsafe URL: ${location}`);
628
- return;
629
- }
630
- const target = targetUrl.pathname + targetUrl.search + targetUrl.hash;
631
- if (targetUrl.origin === window.location.origin && window.__PRACHT_NAVIGATE__) {
632
- await window.__PRACHT_NAVIGATE__(target, options);
633
- return;
634
- }
635
- if (options?.replace) {
636
- window.location.replace(targetUrl.toString());
637
- return;
638
- }
639
- window.location.href = targetUrl.toString();
640
- }
641
- //#endregion
642
- //#region src/prefetch.ts
643
- const CACHE_TTL_MS = 3e4;
644
- const MAX_PREFETCH_CACHE_ENTRIES = 100;
645
- const MAX_MATCH_CACHE_ENTRIES = 250;
646
- const EMPTY_ROUTE_STATE_PROMISE = Promise.resolve({
647
- type: "data",
648
- data: void 0
649
- });
650
- const prefetchCache = /* @__PURE__ */ new Map();
651
- function clearPrefetchCache() {
652
- prefetchCache.clear();
653
- }
654
- function getCachedRouteState(url) {
655
- const entry = prefetchCache.get(url);
656
- if (!entry) return null;
657
- if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
658
- prefetchCache.delete(url);
659
- return null;
660
- }
661
- prefetchCache.delete(url);
662
- prefetchCache.set(url, entry);
663
- return entry.promise;
664
- }
665
- function prefetchRouteState(url, route) {
666
- if (route && !routeNeedsServerFetch(route)) return EMPTY_ROUTE_STATE_PROMISE;
667
- const cached = getCachedRouteState(url);
668
- if (cached) return cached;
669
- sweepPrefetchCache();
670
- const promise = fetchPrachtRouteState(url);
671
- prefetchCache.set(url, {
672
- promise,
673
- timestamp: Date.now()
674
- });
675
- trimMapToSize(prefetchCache, MAX_PREFETCH_CACHE_ENTRIES);
676
- return promise;
677
- }
678
- function setupPrefetching(app, warmModules) {
679
- let hoverTimer = null;
680
- const observedViewportAnchors = /* @__PURE__ */ new WeakSet();
681
- const matchCache = /* @__PURE__ */ new Map();
682
- function getRoutePathname(url) {
683
- try {
684
- return new URL(url, window.location.origin).pathname;
685
- } catch {
686
- return null;
687
- }
688
- }
689
- function getInternalHref(anchor) {
690
- const href = anchor.getAttribute("href");
691
- if (!href || href.startsWith("#")) return null;
692
- let url;
693
- try {
694
- url = new URL(href, window.location.origin);
695
- } catch {
696
- return null;
697
- }
698
- if (url.origin !== window.location.origin) return null;
699
- return url.pathname + url.search;
700
- }
701
- function getMatchEntry(href) {
702
- const cached = matchCache.get(href);
703
- if (cached) {
704
- matchCache.delete(href);
705
- matchCache.set(href, cached);
706
- return cached;
707
- }
708
- const routePathname = getRoutePathname(href);
709
- const match = routePathname ? matchAppRoute(app, routePathname) ?? null : null;
710
- const entry = {
711
- match,
712
- strategy: match ? match.route.prefetch ?? "intent" : "none"
713
- };
714
- matchCache.set(href, entry);
715
- trimMapToSize(matchCache, MAX_MATCH_CACHE_ENTRIES);
716
- return entry;
717
- }
718
- function prefetchHref(href) {
719
- const match = getMatchEntry(href).match;
720
- prefetchRouteState(href, match?.route);
721
- if (warmModules && match) warmModules(match);
722
- }
723
- document.addEventListener("mouseenter", (e) => {
724
- const anchor = e.target.closest?.("a");
725
- if (!anchor) return;
726
- const href = getInternalHref(anchor);
727
- if (!href) return;
728
- const strategy = getMatchEntry(href).strategy;
729
- if (strategy !== "hover" && strategy !== "intent") return;
730
- if (hoverTimer) clearTimeout(hoverTimer);
731
- hoverTimer = setTimeout(() => {
732
- prefetchHref(href);
733
- }, 50);
734
- }, true);
735
- document.addEventListener("mouseleave", (e) => {
736
- if (!e.target.closest?.("a")) return;
737
- if (hoverTimer) {
738
- clearTimeout(hoverTimer);
739
- hoverTimer = null;
740
- }
741
- }, true);
742
- document.addEventListener("focusin", (e) => {
743
- const anchor = e.target.closest?.("a");
744
- if (!anchor) return;
745
- const href = getInternalHref(anchor);
746
- if (!href) return;
747
- const strategy = getMatchEntry(href).strategy;
748
- if (strategy !== "hover" && strategy !== "intent") return;
749
- prefetchHref(href);
750
- }, true);
751
- if (typeof IntersectionObserver === "undefined") return;
752
- const observer = new IntersectionObserver((entries) => {
753
- for (const entry of entries) {
754
- if (!entry.isIntersecting) continue;
755
- const anchor = entry.target;
756
- const href = getInternalHref(anchor);
757
- if (!href) continue;
758
- prefetchHref(href);
759
- observer.unobserve(anchor);
760
- }
761
- }, { rootMargin: "200px" });
762
- function observeAnchor(anchor) {
763
- if (observedViewportAnchors.has(anchor)) return;
764
- const href = getInternalHref(anchor);
765
- if (!href) return;
766
- if (getMatchEntry(href).strategy !== "viewport") return;
767
- observedViewportAnchors.add(anchor);
768
- observer.observe(anchor);
769
- }
770
- function observeViewportLinks(root) {
771
- if (root instanceof HTMLAnchorElement) observeAnchor(root);
772
- for (const anchor of root.querySelectorAll("a[href]")) observeAnchor(anchor);
773
- }
774
- observeViewportLinks(document.body);
775
- new MutationObserver((records) => {
776
- for (const record of records) for (const node of record.addedNodes) if (node instanceof HTMLElement || node instanceof DocumentFragment) observeViewportLinks(node);
777
- }).observe(document.body, {
778
- childList: true,
779
- subtree: true
780
- });
781
- }
782
- function sweepPrefetchCache(now = Date.now()) {
783
- for (const [url, entry] of prefetchCache) if (now - entry.timestamp > CACHE_TTL_MS) prefetchCache.delete(url);
784
- }
785
- function trimMapToSize(map, maxEntries) {
786
- while (map.size > maxEntries) {
787
- const first = map.keys().next();
788
- if (first.done) return;
789
- map.delete(first.value);
790
- }
791
- }
792
- //#endregion
793
- //#region src/runtime-hooks.ts
794
- const RouteDataContext = createContext(void 0);
795
- function PrachtRuntimeProvider({ children, data, params = EMPTY_ROUTE_PARAMS, routeId, routes, stateVersion = 0, url }) {
796
- registerRuntimeRoutes(routes);
797
- const [routeDataState, setRouteDataState] = useState({
798
- data,
799
- stateVersion
800
- });
801
- const routeData = routeDataState.stateVersion === stateVersion ? routeDataState.data : data;
802
- useEffect(() => {
803
- setRouteDataState({
804
- data,
805
- stateVersion
806
- });
807
- }, [
808
- data,
809
- routeId,
810
- stateVersion,
811
- url
812
- ]);
813
- const context = useMemo(() => ({
814
- data: routeData,
815
- params,
816
- routeId,
817
- routes,
818
- setData: (nextData) => setRouteDataState({
819
- data: nextData,
820
- stateVersion
821
- }),
822
- url
823
- }), [
824
- routeData,
825
- params,
826
- routeId,
827
- routes,
828
- stateVersion,
829
- url
830
- ]);
831
- return h(RouteDataContext.Provider, {
832
- value: context,
833
- children
834
- });
835
- }
836
- function startApp(options = {}) {
837
- if (typeof window === "undefined") return options.initialData;
838
- if (typeof options.initialData !== "undefined") return options.initialData;
839
- return readHydrationState()?.data;
840
- }
841
- function readHydrationState() {
842
- if (typeof window === "undefined") return;
843
- if (window.__PRACHT_STATE__) return window.__PRACHT_STATE__;
844
- const element = document.getElementById(HYDRATION_STATE_ELEMENT_ID);
845
- if (!(element instanceof HTMLScriptElement)) return;
846
- const raw = element.textContent;
847
- if (!raw) return;
848
- const state = JSON.parse(raw);
849
- window.__PRACHT_STATE__ = state;
850
- return state;
851
- }
852
- function useRouteData() {
853
- return useContext(RouteDataContext)?.data;
854
- }
855
- function useLocation() {
856
- return parseLocation(useContext(RouteDataContext)?.url ?? (typeof window !== "undefined" ? window.location.pathname + window.location.search : "/"));
857
- }
858
- function useParams() {
859
- return useContext(RouteDataContext)?.params ?? {};
860
- }
861
- function useRevalidate() {
862
- const runtime = useContext(RouteDataContext);
863
- return async () => {
864
- if (typeof window === "undefined") return;
865
- const result = await fetchPrachtRouteState(runtime?.url || window.location.pathname + window.location.search);
866
- if (result.type === "redirect") {
867
- await navigateToClientLocation(result.location);
868
- return;
869
- }
870
- if (result.type === "error") throw deserializeRouteError(result.error);
871
- runtime?.setData(result.data);
872
- return result.data;
873
- };
874
- }
875
- function Link(props) {
876
- const routes = useContext(RouteDataContext)?.routes ?? globalThis.__PRACHT_ROUTE_DEFINITIONS__;
877
- if (!routes) throw new Error("<Link route=...> must render inside a pracht route tree.");
878
- const { route, params, search, hash, ...anchorProps } = props;
879
- return h("a", {
880
- ...anchorProps,
881
- href: buildHref(routes, route, {
882
- params,
883
- search,
884
- hash
885
- })
886
- });
887
- }
888
- function registerRuntimeRoutes(routes) {
889
- if (!routes) return;
890
- globalThis.__PRACHT_ROUTE_DEFINITIONS__ = routes;
891
- }
892
- function Form(props) {
893
- const { onSubmit, method, ...rest } = props;
894
- return h("form", {
895
- ...rest,
896
- method,
897
- onSubmit: async (event) => {
898
- onSubmit?.(event);
899
- if (event.defaultPrevented) return;
900
- const form = event.currentTarget;
901
- if (!(form instanceof HTMLFormElement)) return;
902
- const formMethod = (method ?? form.method ?? "post").toUpperCase();
903
- if (SAFE_METHODS.has(formMethod)) return;
904
- event.preventDefault();
905
- clearPrefetchCache();
906
- const response = await fetch(props.action ?? form.action, {
907
- method: formMethod,
908
- body: new FormData(form),
909
- redirect: "manual"
910
- });
911
- if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) {
912
- const location = response.headers.get("location");
913
- if (location) {
914
- await navigateToClientLocation(location);
915
- return;
916
- }
917
- window.location.href = props.action ?? form.action;
918
- }
919
- }
920
- });
921
- }
922
- function parseLocation(value) {
923
- const url = new URL(value, "http://pracht.local");
924
- return {
925
- pathname: url.pathname,
926
- search: url.search
927
- };
928
- }
929
- //#endregion
930
- //#region src/runtime-html.ts
931
- function escapeHtml(str) {
932
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
933
- }
934
- function serializeJsonForHtml(value) {
935
- return escapeScriptText(JSON.stringify(value) ?? "null");
936
- }
937
- function escapeScriptText(value) {
938
- return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
939
- }
940
- const SAFE_ATTRIBUTE_NAME_RE = /^[A-Za-z_:][A-Za-z0-9:._-]*$/;
941
- const GLOBAL_HEAD_ATTRIBUTE_PREFIXES = ["data-", "aria-"];
942
- const META_ATTRIBUTES = new Set([
943
- "charset",
944
- "content",
945
- "http-equiv",
946
- "itemprop",
947
- "media",
948
- "name",
949
- "property"
950
- ]);
951
- const LINK_ATTRIBUTES = new Set([
952
- "as",
953
- "blocking",
954
- "color",
955
- "crossorigin",
956
- "disabled",
957
- "fetchpriority",
958
- "href",
959
- "hreflang",
960
- "imagesizes",
961
- "imagesrcset",
962
- "integrity",
963
- "media",
964
- "referrerpolicy",
965
- "rel",
966
- "sizes",
967
- "title",
968
- "type"
969
- ]);
970
- const SCRIPT_ATTRIBUTES = new Set([
971
- "async",
972
- "blocking",
973
- "class",
974
- "crossorigin",
975
- "defer",
976
- "fetchpriority",
977
- "id",
978
- "integrity",
979
- "nomodule",
980
- "nonce",
981
- "referrerpolicy",
982
- "src",
983
- "type"
984
- ]);
985
- function renderAttributes(attributes, allowedAttributes) {
986
- return Object.entries(attributes).filter(([key, value]) => isAllowedHeadAttribute(key, value, allowedAttributes)).map(([key, value]) => `${key}="${escapeHtml(value ?? "")}"`).join(" ");
987
- }
988
- function isAllowedHeadAttribute(key, value, allowedAttributes) {
989
- if (key === "children" || typeof value === "undefined" || !SAFE_ATTRIBUTE_NAME_RE.test(key)) return false;
990
- const normalized = key.toLowerCase();
991
- if (normalized.startsWith("on")) return false;
992
- return allowedAttributes.has(normalized) || GLOBAL_HEAD_ATTRIBUTE_PREFIXES.some((prefix) => normalized.startsWith(prefix));
993
- }
994
- function buildHtmlDocument(options) {
995
- const { head, body, hydrationState, clientEntryUrl, cssUrls = [], modulePreloadUrls = [], routeStatePreloadUrl } = options;
996
- const titleTag = head.title ? `<title>${escapeHtml(head.title)}</title>` : "";
997
- const metaTags = (head.meta ?? []).map((m) => renderAttributes(m, META_ATTRIBUTES)).filter(Boolean).map((attrs) => `<meta ${attrs}>`).join("\n ");
998
- const linkTags = (head.link ?? []).map((l) => renderAttributes(l, LINK_ATTRIBUTES)).filter(Boolean).map((attrs) => `<link ${attrs}>`).join("\n ");
999
- const scriptTags = (head.script ?? []).map((script) => {
1000
- const attrs = renderAttributes(script, SCRIPT_ATTRIBUTES);
1001
- const children = script.children ? escapeScriptText(script.children) : "";
1002
- return attrs ? `<script ${attrs}>${children}<\/script>` : `<script>${children}<\/script>`;
1003
- }).join("\n ");
1004
- const cssTags = cssUrls.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join("\n ");
1005
- const modulePreloadTags = modulePreloadUrls.map((url) => `<link rel="modulepreload" href="${escapeHtml(url)}">`).join("\n ");
1006
- const routeStatePreloadTag = routeStatePreloadUrl ? `<link rel="preload" as="fetch" href="${escapeHtml(routeStatePreloadUrl)}" crossorigin="anonymous">` : "";
1007
- const stateScript = `<script id="${HYDRATION_STATE_ELEMENT_ID}" type="application/json">${serializeJsonForHtml(hydrationState)}<\/script>`;
1008
- const entryScript = clientEntryUrl ? `<script type="module" src="${escapeHtml(clientEntryUrl)}"><\/script>` : "";
1009
- return `<!DOCTYPE html>
1010
- <html${head.lang ? ` lang="${escapeHtml(head.lang)}"` : ""}>
1011
- <head>
1012
- <meta charset="utf-8">
1013
- ${titleTag}
1014
- ${metaTags}
1015
- ${linkTags}
1016
- ${scriptTags}
1017
- ${cssTags}
1018
- ${modulePreloadTags}
1019
- ${routeStatePreloadTag}
1020
- </head>
1021
- <body>
1022
- <div id="pracht-root">${body}</div>
1023
- ${stateScript}
1024
- ${entryScript}
1025
- </body>
1026
- </html>`;
1027
- }
1028
- function htmlResponse(html, status = 200, initHeaders) {
1029
- const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
1030
- if (initHeaders) applyHeaders(headers, initHeaders);
1031
- applySecurityAndRouteHeaders(headers, { isRouteStateRequest: false });
1032
- return new Response(html, {
1033
- status,
1034
- headers
1035
- });
1036
- }
1037
- //#endregion
1038
- //#region src/runtime-manifest.ts
1039
- /** Strip leading `./` and `/` so all module paths share one canonical form. */
1040
- function normalizeModulePath(path) {
1041
- return path.replace(/^\.?\//, "");
1042
- }
1043
- function buildSuffixIndex(manifest) {
1044
- const index = /* @__PURE__ */ new Map();
1045
- for (const key of Object.keys(manifest)) {
1046
- const normalized = normalizeModulePath(key);
1047
- if (!normalized) continue;
1048
- if (!index.has(normalized)) index.set(normalized, key);
1049
- for (let i = normalized.indexOf("/"); i !== -1; i = normalized.indexOf("/", i + 1)) {
1050
- const suffix = normalized.slice(i + 1);
1051
- if (suffix && !index.has(suffix)) index.set(suffix, key);
1052
- }
1053
- }
1054
- return index;
1055
- }
1056
- const suffixIndexCache = /* @__PURE__ */ new WeakMap();
1057
- function getSuffixIndex(manifest) {
1058
- let index = suffixIndexCache.get(manifest);
1059
- if (index) return index;
1060
- index = buildSuffixIndex(manifest);
1061
- suffixIndexCache.set(manifest, index);
1062
- return index;
1063
- }
1064
- function resolveManifestEntries(manifest, file) {
1065
- if (file in manifest) return manifest[file];
1066
- const resolved = getSuffixIndex(manifest).get(normalizeModulePath(file));
1067
- if (resolved) return manifest[resolved];
1068
- }
1069
- function resolvePageUrlsFromManifest(manifest, shellFile, routeFile) {
1070
- const urls = /* @__PURE__ */ new Set();
1071
- const add = (file) => {
1072
- const entries = resolveManifestEntries(manifest, file);
1073
- if (entries) for (const url of entries) urls.add(url);
1074
- };
1075
- if (shellFile) add(shellFile);
1076
- add(routeFile);
1077
- return [...urls];
1078
- }
1079
- function resolvePageCssUrls(cssManifest, shellFile, routeFile) {
1080
- if (!cssManifest) return [];
1081
- return resolvePageUrlsFromManifest(cssManifest, shellFile, routeFile);
1082
- }
1083
- function resolvePageJsUrls(jsManifest, shellFile, routeFile) {
1084
- if (!jsManifest) return [];
1085
- return resolvePageUrlsFromManifest(jsManifest, shellFile, routeFile);
1086
- }
1087
- async function resolveRegistryModule(modules, file) {
1088
- if (!modules) return void 0;
1089
- if (file in modules) return modules[file]();
1090
- const resolved = getSuffixIndex(modules).get(normalizeModulePath(file));
1091
- if (resolved) return modules[resolved]();
1092
- }
1093
- async function resolveDataFunctions(route, routeModule, registry) {
1094
- let loader = routeModule?.loader;
1095
- let loaderFile = routeModule?.loader ? route.file : void 0;
1096
- if (route.loaderFile) {
1097
- const dataModule = await resolveRegistryModule(registry.dataModules, route.loaderFile);
1098
- if (dataModule?.loader) {
1099
- loader = dataModule.loader;
1100
- loaderFile = route.loaderFile;
1101
- }
1102
- }
1103
- return {
1104
- loader,
1105
- loaderFile
1106
- };
1107
- }
1108
- //#endregion
1109
- //#region src/runtime-middleware.ts
1110
- const DEFAULT_REDIRECT_STATUS_SAFE = 302;
1111
- const DEFAULT_REDIRECT_STATUS_UNSAFE = 303;
1112
- /**
1113
- * Build a safe redirect response from middleware/loader output. Rejects
1114
- * non-http(s) schemes (no `javascript:`/`data:`/etc.) and CR/LF injection
1115
- * against the `Location` header. When status is omitted, non-GET/HEAD
1116
- * requests default to 303 so the browser does not resend the body to the
1117
- * redirect target; safe methods default to 302.
1118
- *
1119
- * The original `target` string is preserved on success (relative paths
1120
- * stay relative) — we only parse it to validate scheme, not to rewrite
1121
- * it. Both the original input and its resolved URL must be CR/LF-free.
1122
- */
1123
- function buildRedirectResponse(target, options) {
1124
- if (/[\r\n]/.test(target)) throw new Error("Refused redirect target containing CR/LF");
1125
- if (!parseSafeNavigationUrl(target, options.baseUrl)) throw new Error("Refused unsafe redirect target");
1126
- const method = (options.method ?? "GET").toUpperCase();
1127
- const defaultStatus = SAFE_METHODS.has(method) ? DEFAULT_REDIRECT_STATUS_SAFE : DEFAULT_REDIRECT_STATUS_UNSAFE;
1128
- const status = options.status ?? defaultStatus;
1129
- return new Response(null, {
1130
- status,
1131
- headers: { location: target }
1132
- });
1133
- }
1134
- async function runMiddlewareChain(options) {
1135
- let context = options.context;
1136
- const modulePromises = options.middlewareFiles.map((mwFile) => resolveRegistryModule(options.registry.middlewareModules, mwFile));
1137
- for (const p of modulePromises) p.catch(() => {});
1138
- for (const modulePromise of modulePromises) {
1139
- const mwModule = await modulePromise;
1140
- if (!mwModule?.middleware) continue;
1141
- const result = await mwModule.middleware({
1142
- request: options.request,
1143
- params: options.params,
1144
- context,
1145
- signal: AbortSignal.timeout(3e4),
1146
- url: options.url,
1147
- route: options.route
1148
- });
1149
- if (!result) continue;
1150
- if (result instanceof Response) return { response: withDefaultSecurityHeaders(result) };
1151
- if ("redirect" in result) {
1152
- const status = "status" in result ? result.status : void 0;
1153
- return { response: withDefaultSecurityHeaders(buildRedirectResponse(result.redirect, {
1154
- baseUrl: options.request.url,
1155
- method: options.request.method,
1156
- status
1157
- })) };
1158
- }
1159
- if ("context" in result) context = {
1160
- ...context,
1161
- ...result.context
1162
- };
1163
- }
1164
- return { context };
1165
- }
1166
- async function mergeHeadMetadata(shellModule, routeModule, routeArgs, data) {
1167
- const [shellHead, routeHead] = await Promise.all([shellModule?.head ? shellModule.head(routeArgs) : Promise.resolve({}), routeModule?.head ? routeModule.head({
1168
- ...routeArgs,
1169
- data
1170
- }) : Promise.resolve({})]);
1171
- return {
1172
- title: routeHead.title ?? shellHead.title,
1173
- lang: routeHead.lang ?? shellHead.lang,
1174
- meta: [...shellHead.meta ?? [], ...routeHead.meta ?? []],
1175
- link: [...shellHead.link ?? [], ...routeHead.link ?? []],
1176
- script: [...shellHead.script ?? [], ...routeHead.script ?? []]
1177
- };
1178
- }
1179
- async function mergeDocumentHeaders(shellModule, routeModule, routeArgs, data) {
1180
- const headers = new Headers();
1181
- const [shellHeaders, routeHeaders] = await Promise.all([shellModule?.headers ? shellModule.headers(routeArgs) : Promise.resolve(void 0), routeModule?.headers ? routeModule.headers({
1182
- ...routeArgs,
1183
- data
1184
- }) : Promise.resolve(void 0)]);
1185
- if (shellHeaders) applyHeaders(headers, shellHeaders);
1186
- if (routeHeaders) applyHeaders(headers, routeHeaders);
1187
- return headers;
1188
- }
1189
- //#endregion
1190
- //#region src/runtime-response.ts
1191
- let _renderToStringAsync;
1192
- async function getRenderToStringAsync() {
1193
- if (_renderToStringAsync) return _renderToStringAsync;
1194
- _renderToStringAsync = (await import("preact-render-to-string")).renderToStringAsync;
1195
- return _renderToStringAsync;
1196
- }
1197
- function jsonErrorResponse(routeError, options) {
1198
- const headers = applySecurityAndRouteHeaders(new Headers({ "content-type": "application/json; charset=utf-8" }), options.isRouteStateRequest ? { isRouteStateRequest: true } : void 0);
1199
- return new Response(JSON.stringify({ error: routeError }), {
1200
- status: routeError.status,
1201
- headers
1202
- });
1203
- }
1204
- function jsonRedirectResponse(location, options) {
1205
- const headers = new Headers(options.headers);
1206
- headers.set("content-type", "application/json; charset=utf-8");
1207
- return withRouteResponseHeaders(new Response(JSON.stringify({ redirect: location }), {
1208
- status: 200,
1209
- headers
1210
- }), { isRouteStateRequest: options.isRouteStateRequest });
1211
- }
1212
- function normalizePageResponse(response, options) {
1213
- if (options.isRouteStateRequest && response.status >= 300 && response.status < 400) {
1214
- const location = response.headers.get("location");
1215
- if (location) return jsonRedirectResponse(location, {
1216
- headers: response.headers,
1217
- isRouteStateRequest: true
1218
- });
1219
- }
1220
- return withRouteResponseHeaders(response, options);
1221
- }
1222
- function renderApiErrorResponse(options) {
1223
- const exposeDetails = shouldExposeServerErrors(options.options);
1224
- const routeError = normalizeRouteError(options.error, { exposeDetails });
1225
- const routeErrorWithDiagnostics = exposeDetails ? {
1226
- ...routeError,
1227
- diagnostics: buildRuntimeDiagnostics({
1228
- middlewareFiles: options.middlewareFiles,
1229
- phase: options.phase,
1230
- route: options.route,
1231
- status: routeError.status
1232
- })
1233
- } : routeError;
1234
- if (exposeDetails) return jsonErrorResponse(routeErrorWithDiagnostics, { isRouteStateRequest: false });
1235
- const message = routeErrorWithDiagnostics.status >= 500 ? "Internal Server Error" : routeErrorWithDiagnostics.message;
1236
- return withDefaultSecurityHeaders(new Response(message, {
1237
- status: routeErrorWithDiagnostics.status,
1238
- headers: { "content-type": "text/plain; charset=utf-8" }
1239
- }));
1240
- }
1241
- async function renderRouteErrorResponse(options) {
1242
- const exposeDetails = shouldExposeServerErrors(options.options);
1243
- const routeError = normalizeRouteError(options.error, { exposeDetails });
1244
- const routeErrorWithDiagnostics = exposeDetails ? {
1245
- ...routeError,
1246
- diagnostics: buildRuntimeDiagnostics({
1247
- loaderFile: options.loaderFile,
1248
- middlewareFiles: options.routeArgs.route.middlewareFiles,
1249
- phase: options.phase,
1250
- route: options.routeArgs.route,
1251
- shellFile: options.shellFile,
1252
- status: routeError.status
1253
- })
1254
- } : routeError;
1255
- if (options.isRouteStateRequest) return jsonErrorResponse(routeErrorWithDiagnostics, { isRouteStateRequest: true });
1256
- const shellModule = options.shellModule ?? (options.shellFile ? await resolveRegistryModule(options.options.registry?.shellModules, options.shellFile) : void 0);
1257
- const ErrorBoundary = options.routeModule?.ErrorBoundary ?? shellModule?.ErrorBoundary;
1258
- if (!ErrorBoundary) {
1259
- const message = routeErrorWithDiagnostics.status >= 500 && !exposeDetails ? "Internal Server Error" : routeErrorWithDiagnostics.message;
1260
- const diagnostics = exposeDetails && routeErrorWithDiagnostics.diagnostics ? `\n\n${JSON.stringify(routeErrorWithDiagnostics.diagnostics, null, 2)}` : "";
1261
- return withDefaultSecurityHeaders(new Response(`${message}${diagnostics}`, {
1262
- status: routeErrorWithDiagnostics.status,
1263
- headers: { "content-type": "text/plain; charset=utf-8" }
1264
- }));
1265
- }
1266
- const head = shellModule?.head ? await shellModule.head(options.routeArgs) : {};
1267
- const documentHeaders = await mergeDocumentHeaders(shellModule, void 0, options.routeArgs, void 0);
1268
- const cssUrls = resolvePageCssUrls(options.options.cssManifest, options.shellFile, options.routeArgs.route.file);
1269
- const modulePreloadUrls = resolvePageJsUrls(options.options.jsManifest, options.shellFile, options.routeArgs.route.file);
1270
- const renderToString = await getRenderToStringAsync();
1271
- const Boundary = ErrorBoundary;
1272
- const Shell = shellModule?.Shell;
1273
- const errorValue = deserializeRouteError(routeErrorWithDiagnostics);
1274
- const componentTree = Shell ? h(Shell, null, h(Boundary, { error: errorValue })) : h(Boundary, { error: errorValue });
1275
- return htmlResponse(buildHtmlDocument({
1276
- head,
1277
- body: await renderToString(h(PrachtRuntimeProvider, {
1278
- data: null,
1279
- routeId: options.routeId,
1280
- routes: options.routes,
1281
- url: options.requestPath
1282
- }, componentTree)),
1283
- hydrationState: {
1284
- url: options.requestPath,
1285
- routeId: options.routeId,
1286
- data: null,
1287
- error: routeErrorWithDiagnostics
1288
- },
1289
- clientEntryUrl: options.options.clientEntryUrl,
1290
- cssUrls,
1291
- modulePreloadUrls
1292
- }), routeErrorWithDiagnostics.status, documentHeaders);
1293
- }
1294
- //#endregion
1295
- //#region src/runtime-negotiation.ts
1296
- const MARKDOWN_MEDIA_TYPE = "text/markdown";
1297
- function parseAccept(header) {
1298
- if (!header) return [];
1299
- const entries = [];
1300
- for (const raw of header.split(",")) {
1301
- const parts = raw.trim().split(";");
1302
- const type = parts.shift()?.trim().toLowerCase();
1303
- if (!type) continue;
1304
- let quality = 1;
1305
- for (const param of parts) {
1306
- const [key, value] = param.split("=").map((p) => p.trim());
1307
- if (key === "q" && value != null) {
1308
- const parsed = Number.parseFloat(value);
1309
- if (!Number.isNaN(parsed)) quality = parsed;
1310
- }
1311
- }
1312
- entries.push({
1313
- type,
1314
- quality
1315
- });
1316
- }
1317
- return entries;
1318
- }
1319
- function prefersMarkdown(accept) {
1320
- const entries = parseAccept(accept);
1321
- if (!entries.length) return false;
1322
- const md = entries.find((e) => e.type === MARKDOWN_MEDIA_TYPE);
1323
- if (!md || md.quality === 0) return false;
1324
- const html = entries.find((e) => e.type === "text/html");
1325
- if (!html) return true;
1326
- return md.quality >= html.quality;
1327
- }
1328
- function markdownResponse(source) {
1329
- const headers = new Headers({
1330
- "content-type": "text/markdown; charset=utf-8",
1331
- "cache-control": "public, max-age=0, must-revalidate"
1332
- });
1333
- appendVaryHeader(headers, "Accept");
1334
- applyDefaultSecurityHeaders(headers);
1335
- return new Response(source, {
1336
- status: 200,
1337
- headers
1338
- });
1339
- }
1340
- //#endregion
1341
- //#region src/runtime.ts
1342
- const SAME_ORIGIN_FETCH_SITE = "same-origin";
1343
- /**
1344
- * Stricter variant of first-party detection used for CSRF protection on
1345
- * state-changing API requests. It rejects any browser signal that points
1346
- * outside this exact origin — a cross-origin form POST will send `Origin`
1347
- * from the attacker, and `Sec-Fetch-Site: same-site` is not enough because
1348
- * sibling subdomains can be attacker-controlled. Requests with no browser
1349
- * provenance headers are treated as non-browser callers.
1350
- */
1351
- function isSameOriginMutation(request, url) {
1352
- const site = request.headers.get("sec-fetch-site");
1353
- if (site && site !== SAME_ORIGIN_FETCH_SITE) return false;
1354
- const origin = request.headers.get("origin");
1355
- if (origin) try {
1356
- return new URL(origin).origin === url.origin;
1357
- } catch {
1358
- return false;
1359
- }
1360
- if (site === SAME_ORIGIN_FETCH_SITE) return true;
1361
- const referer = request.headers.get("referer");
1362
- if (referer) try {
1363
- return new URL(referer).origin === url.origin;
1364
- } catch {
1365
- return false;
1366
- }
1367
- return true;
1368
- }
1369
- /**
1370
- * Heuristic "this request came from our own page" check. Used to gate
1371
- * the `_data=1` query-param form of the route-state endpoint, which is
1372
- * otherwise reachable via any cross-origin `<a href>` / redirect.
1373
- *
1374
- * Accepts a request as first-party when:
1375
- * - Sec-Fetch-Site is `same-origin` (modern browsers),
1376
- * - OR Sec-Fetch-Site is absent AND the Origin header matches the
1377
- * request URL's origin (older clients that still send Origin),
1378
- * - OR Sec-Fetch-Site/Origin are absent AND Referer matches the request
1379
- * URL's origin,
1380
- * - OR no Origin/Sec-Fetch-Site/Referer is present (non-browser clients like
1381
- * curl — CSRF is not the threat model there; blocking would break
1382
- * tests and CLIs).
1383
- */
1384
- function isFirstPartyFetch(request) {
1385
- const site = request.headers.get("sec-fetch-site");
1386
- if (site && site !== SAME_ORIGIN_FETCH_SITE) return false;
1387
- const origin = request.headers.get("origin");
1388
- if (origin) try {
1389
- return new URL(origin).origin === new URL(request.url).origin;
1390
- } catch {
1391
- return false;
1392
- }
1393
- if (site === SAME_ORIGIN_FETCH_SITE) return true;
1394
- const referer = request.headers.get("referer");
1395
- if (referer) try {
1396
- return new URL(referer).origin === new URL(request.url).origin;
1397
- } catch {
1398
- return false;
1399
- }
1400
- return true;
1401
- }
1402
- async function handlePrachtRequest(options) {
1403
- const url = new URL(options.request.url);
1404
- const hasDataParam = url.searchParams.get("_data") === "1";
1405
- if (hasDataParam) url.searchParams.delete("_data");
1406
- const requestPath = getRequestPath(url);
1407
- const registry = options.registry ?? {};
1408
- const resolvedApp = getResolvedApp(options.app);
1409
- const headerSignalsRouteState = options.request.headers.get(ROUTE_STATE_REQUEST_HEADER) === "1";
1410
- const dataParamIsFirstParty = hasDataParam && isFirstPartyFetch(options.request);
1411
- const isRouteStateRequest = headerSignalsRouteState || dataParamIsFirstParty;
1412
- const exposeDiagnostics = shouldExposeServerErrors(options);
1413
- if (options.apiRoutes?.length) {
1414
- const apiMatch = matchApiRoute(options.apiRoutes, url.pathname);
1415
- if (apiMatch) {
1416
- const apiMiddlewareFiles = (options.app.api.middleware ?? []).flatMap((name) => {
1417
- const middlewareFile = options.app.middleware[name];
1418
- return middlewareFile ? [middlewareFile] : [];
1419
- });
1420
- let currentPhase = "middleware";
1421
- if ((options.app.api.requireSameOrigin ?? true) && !SAFE_METHODS.has(options.request.method) && !isSameOriginMutation(options.request, url)) return withDefaultSecurityHeaders(new Response("Cross-origin request blocked", {
1422
- status: 403,
1423
- headers: { "content-type": "text/plain; charset=utf-8" }
1424
- }));
1425
- try {
1426
- const middlewareResult = await runMiddlewareChain({
1427
- context: options.context ?? {},
1428
- middlewareFiles: apiMiddlewareFiles,
1429
- params: apiMatch.params,
1430
- registry,
1431
- request: options.request,
1432
- route: apiMatch.route,
1433
- url
1434
- });
1435
- if (middlewareResult.response) return middlewareResult.response;
1436
- currentPhase = "api";
1437
- const apiModule = await resolveRegistryModule(registry.apiModules, apiMatch.route.file);
1438
- if (!apiModule) throw new Error("API route module not found");
1439
- const handler = apiModule[options.request.method.toUpperCase()] ?? apiModule.default;
1440
- if (!handler) return withDefaultSecurityHeaders(new Response("Method not allowed", {
1441
- status: 405,
1442
- headers: { "content-type": "text/plain; charset=utf-8" }
1443
- }));
1444
- return withDefaultSecurityHeaders(await handler({
1445
- request: options.request,
1446
- params: apiMatch.params,
1447
- context: middlewareResult.context,
1448
- signal: AbortSignal.timeout(3e4),
1449
- url,
1450
- route: apiMatch.route
1451
- }));
1452
- } catch (error) {
1453
- return renderApiErrorResponse({
1454
- error,
1455
- middlewareFiles: apiMiddlewareFiles,
1456
- options,
1457
- phase: currentPhase,
1458
- route: apiMatch.route
1459
- });
1460
- }
1461
- }
1462
- }
1463
- const match = matchAppRoute(resolvedApp, url.pathname);
1464
- if (!match) {
1465
- if (isRouteStateRequest) return jsonErrorResponse(createSerializedRouteError("Not found", 404, {
1466
- diagnostics: exposeDiagnostics ? buildRuntimeDiagnostics({
1467
- phase: "match",
1468
- status: 404
1469
- }) : void 0,
1470
- name: "Error"
1471
- }), { isRouteStateRequest: true });
1472
- return withDefaultSecurityHeaders(new Response("Not found", {
1473
- status: 404,
1474
- headers: { "content-type": "text/plain; charset=utf-8" }
1475
- }));
1476
- }
1477
- if (!SAFE_METHODS.has(options.request.method)) {
1478
- if (isRouteStateRequest) return jsonErrorResponse(createSerializedRouteError("Method not allowed", 405, {
1479
- diagnostics: exposeDiagnostics ? buildRuntimeDiagnostics({
1480
- middlewareFiles: match.route.middlewareFiles,
1481
- phase: "action",
1482
- route: match.route,
1483
- shellFile: match.route.shellFile,
1484
- status: 405
1485
- }) : void 0,
1486
- name: "Error"
1487
- }), { isRouteStateRequest: true });
1488
- return withRouteResponseHeaders(new Response("Method not allowed", {
1489
- status: 405,
1490
- headers: { "content-type": "text/plain; charset=utf-8" }
1491
- }), { isRouteStateRequest });
1492
- }
1493
- let routeArgs = {
1494
- request: options.request,
1495
- params: match.params,
1496
- context: options.context ?? {},
1497
- signal: AbortSignal.timeout(3e4),
1498
- url,
1499
- route: match.route
1500
- };
1501
- let routeModule;
1502
- let shellModule;
1503
- let loaderFile;
1504
- let currentPhase = "middleware";
1505
- try {
1506
- const middlewarePromise = runMiddlewareChain({
1507
- context: routeArgs.context,
1508
- middlewareFiles: match.route.middlewareFiles,
1509
- params: match.params,
1510
- registry,
1511
- request: options.request,
1512
- route: match.route,
1513
- url
1514
- });
1515
- const routeModulePromise = resolveRegistryModule(registry.routeModules, match.route.file);
1516
- const shellModulePromise = match.route.shellFile ? resolveRegistryModule(registry.shellModules, match.route.shellFile) : Promise.resolve(void 0);
1517
- const dataFunctionsPromise = routeModulePromise.then((mod) => resolveDataFunctions(match.route, mod, registry));
1518
- routeModulePromise.catch(() => {});
1519
- shellModulePromise.catch(() => {});
1520
- dataFunctionsPromise.catch(() => {});
1521
- const middlewareResult = await middlewarePromise;
1522
- if (middlewareResult.response) return normalizePageResponse(middlewareResult.response, { isRouteStateRequest });
1523
- routeArgs = {
1524
- ...routeArgs,
1525
- context: middlewareResult.context
1526
- };
1527
- currentPhase = "render";
1528
- routeModule = await routeModulePromise;
1529
- if (!routeModule) throw new Error("Route module not found");
1530
- if (!isRouteStateRequest && typeof routeModule.markdown === "string" && prefersMarkdown(options.request.headers.get("accept"))) return markdownResponse(routeModule.markdown);
1531
- currentPhase = "loader";
1532
- const { loader, loaderFile: resolvedLoaderFile } = await dataFunctionsPromise;
1533
- loaderFile = resolvedLoaderFile;
1534
- const loaderResult = loader ? await loader(routeArgs) : void 0;
1535
- if (loaderResult instanceof Response) return normalizePageResponse(loaderResult, { isRouteStateRequest });
1536
- const data = loaderResult;
1537
- if (isRouteStateRequest) return withRouteResponseHeaders(Response.json({ data }), { isRouteStateRequest: true });
1538
- currentPhase = "render";
1539
- shellModule = await shellModulePromise;
1540
- const [head, documentHeaders] = await Promise.all([mergeHeadMetadata(shellModule, routeModule, routeArgs, data), mergeDocumentHeaders(shellModule, routeModule, routeArgs, data)]);
1541
- const cssUrls = resolvePageCssUrls(options.cssManifest, match.route.shellFile, match.route.file);
1542
- const modulePreloadUrls = resolvePageJsUrls(options.jsManifest, match.route.shellFile, match.route.file);
1543
- if (match.route.render === "spa") {
1544
- let body = "";
1545
- const Shell = shellModule?.Shell;
1546
- const Loading = shellModule?.Loading;
1547
- const loadingTree = Shell != null ? h(Shell, null, Loading ? h(Loading, null) : null) : Loading ? h(Loading, null) : null;
1548
- if (loadingTree) {
1549
- const tree = h(PrachtRuntimeProvider, {
1550
- data: null,
1551
- params: match.params,
1552
- routeId: match.route.id ?? "",
1553
- routes: resolvedApp.routes,
1554
- url: requestPath
1555
- }, loadingTree);
1556
- body = await (await getRenderToStringAsync())(tree);
1557
- }
1558
- return htmlResponse(buildHtmlDocument({
1559
- head,
1560
- body,
1561
- hydrationState: {
1562
- url: requestPath,
1563
- routeId: match.route.id ?? "",
1564
- data: null,
1565
- error: null,
1566
- pending: true
1567
- },
1568
- clientEntryUrl: options.clientEntryUrl,
1569
- cssUrls,
1570
- modulePreloadUrls,
1571
- routeStatePreloadUrl: loader ? buildRouteStateUrl(requestPath) : void 0
1572
- }), 200, documentHeaders);
1573
- }
1574
- const DefaultComponent = typeof routeModule.default === "function" ? routeModule.default : void 0;
1575
- const Component = routeModule.Component ?? DefaultComponent;
1576
- if (!Component) throw new Error("Route has no Component or default export");
1577
- const Shell = shellModule?.Shell;
1578
- const Comp = Component;
1579
- const componentProps = {
1580
- data,
1581
- params: match.params
1582
- };
1583
- const componentTree = Shell ? h(Shell, null, h(Comp, componentProps)) : h(Comp, componentProps);
1584
- const tree = h(PrachtRuntimeProvider, {
1585
- data,
1586
- params: match.params,
1587
- routeId: match.route.id ?? "",
1588
- routes: resolvedApp.routes,
1589
- url: requestPath
1590
- }, componentTree);
1591
- return htmlResponse(buildHtmlDocument({
1592
- head,
1593
- body: await (await getRenderToStringAsync())(tree),
1594
- hydrationState: {
1595
- url: requestPath,
1596
- routeId: match.route.id ?? "",
1597
- data,
1598
- error: null
1599
- },
1600
- clientEntryUrl: options.clientEntryUrl,
1601
- cssUrls,
1602
- modulePreloadUrls
1603
- }), 200, documentHeaders);
1604
- } catch (error) {
1605
- return renderRouteErrorResponse({
1606
- error,
1607
- isRouteStateRequest,
1608
- loaderFile,
1609
- options,
1610
- phase: currentPhase,
1611
- routeArgs,
1612
- routeId: match.route.id ?? "",
1613
- routeModule,
1614
- routes: resolvedApp.routes,
1615
- shellFile: match.route.shellFile,
1616
- shellModule,
1617
- requestPath
1618
- });
1619
- }
1620
- }
1621
- function getRequestPath(url) {
1622
- return `${url.pathname}${url.search}`;
1623
- }
1624
- function getResolvedApp(app) {
1625
- const routes = app.routes;
1626
- if (routes.length === 0 || isHrefRouteDefinition(routes[0])) return app;
1627
- return resolveApp(app);
1628
- }
1629
- function isHrefRouteDefinition(value) {
1630
- return Boolean(value && typeof value === "object" && "path" in value && "segments" in value && Array.isArray(value.segments));
1631
- }
1632
- //#endregion
1633
- //#region src/prerender.ts
1634
- const DANGEROUS_PRERENDER_HEADER_NAMES = new Set([
1635
- "authorization",
1636
- "proxy-authenticate",
1637
- "proxy-authorization",
1638
- "set-cookie",
1639
- "www-authenticate"
1640
- ]);
1641
- const SECRET_SHAPED_PRERENDER_HEADER_RE = /^x-.*(?:api[-_]?key|client[-_]?secret|credential|jwt[-_]?secret|password|private[-_]?key|refresh[-_]?token|secret|session[-_]?secret|token|webhook[-_]?secret)(?:$|[-_])/i;
1642
- async function prerenderApp(options) {
1643
- const resolved = resolveApp(options.app);
1644
- const results = [];
1645
- const isgManifest = {};
1646
- const work = [];
1647
- for (const route of resolved.routes) {
1648
- if (route.render !== "ssg" && route.render !== "isg") continue;
1649
- const paths = await collectSSGPaths(route, options.registry);
1650
- for (const pathname of paths) work.push({
1651
- pathname,
1652
- render: route.render,
1653
- revalidate: route.revalidate
1654
- });
1655
- }
1656
- const concurrency = options.concurrency ?? 10;
1657
- if (!Number.isInteger(concurrency) || concurrency <= 0) throw new Error("prerenderApp({ concurrency }) expects a positive integer.");
1658
- for (let i = 0; i < work.length; i += concurrency) {
1659
- const batch = work.slice(i, i + concurrency);
1660
- const batchResults = await Promise.all(batch.map(async (item) => {
1661
- const url = new URL(item.pathname, "http://localhost");
1662
- const request = new Request(url, { method: "GET" });
1663
- const response = await handlePrachtRequest({
1664
- app: options.app,
1665
- request,
1666
- registry: options.registry,
1667
- clientEntryUrl: options.clientEntryUrl,
1668
- cssManifest: options.cssManifest,
1669
- jsManifest: options.jsManifest
1670
- });
1671
- if (response.status !== 200) {
1672
- console.warn(` Warning: ${item.render.toUpperCase()} route "${item.pathname}" returned status ${response.status}, skipping.`);
1673
- return null;
1674
- }
1675
- assertSafePrerenderHeaders(response.headers, item);
1676
- const html = await response.text();
1677
- return {
1678
- headers: Object.fromEntries(response.headers),
1679
- html,
1680
- item
1681
- };
1682
- }));
1683
- for (const result of batchResults) {
1684
- if (!result) continue;
1685
- results.push({
1686
- path: result.item.pathname,
1687
- html: result.html,
1688
- headers: result.headers
1689
- });
1690
- if (result.item.render === "isg" && result.item.revalidate) isgManifest[result.item.pathname] = { revalidate: result.item.revalidate };
1691
- }
1692
- }
1693
- if (options.withISGManifest) return {
1694
- pages: results,
1695
- isgManifest
1696
- };
1697
- return results;
1698
- }
1699
- function assertSafePrerenderHeaders(headers, item) {
1700
- const dangerous = [...headers.keys()].filter(isDangerousPrerenderHeader);
1701
- if (dangerous.length === 0) return;
1702
- const names = dangerous.map((name) => `"${name}"`).join(", ");
1703
- throw new Error(`Refusing to prerender ${item.render.toUpperCase()} route "${item.pathname}" because its document headers include ${names}. SSG/ISG document headers are serialized into public static output and replayed for every visitor. Move cookies/authentication headers to API routes, loaders, middleware responses, or SSR-only routes.`);
1704
- }
1705
- function isDangerousPrerenderHeader(name) {
1706
- const normalized = name.toLowerCase();
1707
- return DANGEROUS_PRERENDER_HEADER_NAMES.has(normalized) || SECRET_SHAPED_PRERENDER_HEADER_RE.test(normalized);
1708
- }
1709
- async function collectSSGPaths(route, registry) {
1710
- if (!route.segments.some((s) => s.type === "param" || s.type === "catchall")) return [route.path];
1711
- const routeModule = await resolveRegistryModule(registry?.routeModules, route.file);
1712
- if (!routeModule?.getStaticPaths) {
1713
- console.warn(` Warning: SSG route "${route.path}" has dynamic segments but no getStaticPaths() export, skipping.`);
1714
- return [];
1715
- }
1716
- return (await routeModule.getStaticPaths()).map((params) => buildPathFromSegments(route.segments, params));
1717
- }
1718
- //#endregion
1719
- //#region src/hydration-mismatch.ts
1720
- const HYDRATION_BANNER_ID = "__pracht_hydration_mismatch__";
1721
- let installed = false;
1722
- function installHydrationMismatchWarning() {
1723
- if (installed) return;
1724
- installed = true;
1725
- const prev = options.__m;
1726
- options.__m = function(vnode) {
1727
- appendHydrationWarning(vnode);
1728
- if (prev) prev(vnode);
1729
- };
1730
- }
1731
- function appendHydrationWarning(vnode) {
1732
- if (typeof document === "undefined") return;
1733
- const componentName = getVNodeName(vnode);
1734
- let banner = document.getElementById(HYDRATION_BANNER_ID);
1735
- const message = `Hydration mismatch detected on <${componentName}>. The server-rendered HTML did not match the client.`;
1736
- if (banner) {
1737
- const list = banner.querySelector(`[data-pracht-mismatch-list]`);
1738
- if (list) {
1739
- const item = document.createElement("li");
1740
- item.textContent = message;
1741
- list.appendChild(item);
1742
- }
1743
- return;
1744
- }
1745
- banner = document.createElement("div");
1746
- banner.id = HYDRATION_BANNER_ID;
1747
- banner.setAttribute("role", "alert");
1748
- banner.style.cssText = [
1749
- "position:fixed",
1750
- "top:0",
1751
- "left:0",
1752
- "right:0",
1753
- "z-index:2147483647",
1754
- "background:#1a1a2e",
1755
- "color:#ff6b6b",
1756
- "padding:12px 16px",
1757
- "font:12px/1.5 ui-monospace,Menlo,Consolas,monospace",
1758
- "border-bottom:2px solid #e74c3c",
1759
- "box-shadow:0 2px 8px rgba(0,0,0,0.3)"
1760
- ].join(";");
1761
- const title = document.createElement("strong");
1762
- title.textContent = "pracht: hydration mismatch";
1763
- title.style.cssText = "display:block;margin-bottom:4px;color:#fff";
1764
- banner.appendChild(title);
1765
- const list = document.createElement("ul");
1766
- list.setAttribute("data-pracht-mismatch-list", "");
1767
- list.style.cssText = "margin:0;padding-left:18px";
1768
- const item = document.createElement("li");
1769
- item.textContent = message;
1770
- list.appendChild(item);
1771
- banner.appendChild(list);
1772
- document.body.appendChild(banner);
1773
- }
1774
- function getVNodeName(vnode) {
1775
- if (!vnode) return "Unknown";
1776
- const type = vnode.type;
1777
- if (typeof type === "string") return type;
1778
- if (typeof type === "function") {
1779
- const fn = type;
1780
- return fn.displayName || fn.name || "Component";
1781
- }
1782
- return "Unknown";
1783
- }
1784
- //#endregion
1785
- //#region src/router.ts
1786
- const NavigateContext = createContext(async () => {});
1787
- function useNavigate() {
1788
- return useContext(NavigateContext);
1789
- }
1790
- async function initClientRouter(options) {
1791
- const { app, routeModules, shellModules, root, findModuleKey } = options;
1792
- if (import.meta.env?.DEV) installHydrationMismatchWarning();
1793
- const moduleCache = /* @__PURE__ */ new Map();
1794
- function loadModule(modules, key) {
1795
- let cached = moduleCache.get(key);
1796
- if (!cached) {
1797
- cached = modules[key]();
1798
- moduleCache.set(key, cached);
1799
- }
1800
- return cached;
1801
- }
1802
- function startRouteImport(match) {
1803
- const routeKey = findModuleKey(routeModules, match.route.file);
1804
- if (!routeKey) return null;
1805
- return loadModule(routeModules, routeKey);
1806
- }
1807
- function startShellImport(match) {
1808
- if (!match.route.shellFile) return null;
1809
- const shellKey = findModuleKey(shellModules, match.route.shellFile);
1810
- if (!shellKey) return null;
1811
- return loadModule(shellModules, shellKey);
1812
- }
1813
- let updateRouteState = null;
1814
- let routeStateVersion = 0;
1815
- let latestNavigationId = 0;
1816
- let activeNavigationAbort = null;
1817
- function RouterRoot({ initialState }) {
1818
- const [routeState, setRouteState] = useState(initialState);
1819
- updateRouteState = setRouteState;
1820
- const navigateValue = useMemo(() => navigate, []);
1821
- const { Shell, Component, componentProps, data, params, routeId, url, version } = routeState;
1822
- const componentTree = Shell ? h(Shell, null, h(Component, componentProps)) : h(Component, componentProps);
1823
- return h(NavigateContext.Provider, { value: navigateValue }, h(PrachtRuntimeProvider, {
1824
- data,
1825
- params,
1826
- routeId,
1827
- routes: app.routes,
1828
- stateVersion: version,
1829
- url
1830
- }, componentTree));
1831
- }
1832
- function applyRouteState(routeState) {
1833
- if (updateRouteState) {
1834
- updateRouteState(routeState);
1835
- return;
1836
- }
1837
- render(h(RouterRoot, { initialState: routeState }), root);
1838
- }
1839
- async function resolveRouteState(match, state, currentUrl, routeModPromise, shellModPromise) {
1840
- const routeMod = await (routeModPromise ?? startRouteImport(match));
1841
- if (!routeMod) return null;
1842
- let Shell = null;
1843
- const resolvedShell = await (shellModPromise ?? startShellImport(match));
1844
- if (resolvedShell) Shell = resolvedShell.Shell;
1845
- const DefaultComponent = typeof routeMod.default === "function" ? routeMod.default : void 0;
1846
- const ErrorBoundary = routeMod.ErrorBoundary ?? resolvedShell?.ErrorBoundary;
1847
- const Component = state.error ? ErrorBoundary : routeMod.Component ?? DefaultComponent;
1848
- if (!Component) return null;
1849
- const componentProps = state.error ? { error: deserializeRouteError(state.error) } : {
1850
- data: state.data,
1851
- params: match.params
1852
- };
1853
- return {
1854
- Shell,
1855
- Component,
1856
- componentProps,
1857
- data: state.data,
1858
- params: match.params,
1859
- routeId: match.route.id ?? "",
1860
- url: currentUrl,
1861
- version: ++routeStateVersion
1862
- };
1863
- }
1864
- async function resolveSpaPendingState(match, currentUrl, shellModPromise) {
1865
- const resolvedShell = await (shellModPromise ?? startShellImport(match));
1866
- if (!resolvedShell) return null;
1867
- const Shell = resolvedShell.Shell ?? null;
1868
- const Loading = resolvedShell.Loading;
1869
- if (!Shell && !Loading) return null;
1870
- return {
1871
- Shell,
1872
- Component: Loading ?? (() => null),
1873
- componentProps: {},
1874
- data: void 0,
1875
- params: match.params,
1876
- routeId: match.route.id ?? "",
1877
- url: currentUrl,
1878
- version: ++routeStateVersion
1879
- };
1880
- }
1881
- function resolveRedirectTarget(location) {
1882
- const targetUrl = parseSafeNavigationUrl(location, window.location.href);
1883
- if (!targetUrl) return {
1884
- isCurrentLocation: false,
1885
- unsafe: true
1886
- };
1887
- const fullInternalTarget = targetUrl.pathname + targetUrl.search + targetUrl.hash;
1888
- const internalPath = targetUrl.pathname + targetUrl.search;
1889
- const currentPath = window.location.pathname + window.location.search + window.location.hash;
1890
- const isCurrentLocation = targetUrl.origin === window.location.origin && fullInternalTarget === currentPath;
1891
- if (targetUrl.origin !== window.location.origin) return {
1892
- externalUrl: targetUrl.toString(),
1893
- isCurrentLocation: false
1894
- };
1895
- if (targetUrl.hash) return {
1896
- documentUrl: targetUrl.toString(),
1897
- isCurrentLocation
1898
- };
1899
- return {
1900
- internalPath,
1901
- isCurrentLocation
1902
- };
1903
- }
1904
- async function navigate(to, opts) {
1905
- const navigationId = ++latestNavigationId;
1906
- activeNavigationAbort?.abort();
1907
- const abortController = new AbortController();
1908
- activeNavigationAbort = abortController;
1909
- const navigationTarget = typeof to === "string" ? to : buildHref(app.routes, to.route, to);
1910
- const target = resolveBrowserRouteTarget(navigationTarget);
1911
- if (!target) {
1912
- window.location.href = navigationTarget;
1913
- return;
1914
- }
1915
- const match = matchAppRoute(app, target.pathname);
1916
- if (!match) {
1917
- window.location.href = target.browserUrl;
1918
- return;
1919
- }
1920
- let statePromise;
1921
- if (routeNeedsServerFetch(match.route)) statePromise = getCachedRouteState(target.requestUrl) ?? fetchPrachtRouteState(target.requestUrl, { signal: abortController.signal });
1922
- else statePromise = Promise.resolve({
1923
- type: "data",
1924
- data: void 0
1925
- });
1926
- const routeModPromise = startRouteImport(match);
1927
- const shellModPromise = startShellImport(match);
1928
- let state = {
1929
- data: void 0,
1930
- error: null
1931
- };
1932
- try {
1933
- const result = await statePromise;
1934
- if (navigationId !== latestNavigationId) return;
1935
- if (result.type === "redirect") {
1936
- if (result.location) {
1937
- const redirect = resolveRedirectTarget(result.location);
1938
- if (redirect.unsafe) {
1939
- console.error(`[pracht] refused to navigate to unsafe URL: ${result.location}`);
1940
- return;
1941
- }
1942
- if (redirect.externalUrl) {
1943
- window.location.href = redirect.externalUrl;
1944
- return;
1945
- }
1946
- if (redirect.isCurrentLocation) return;
1947
- if (redirect.documentUrl) {
1948
- window.location.href = redirect.documentUrl;
1949
- return;
1950
- }
1951
- if (redirect.internalPath) {
1952
- await navigate(redirect.internalPath, opts);
1953
- return;
1954
- }
1955
- window.location.href = target.browserUrl;
1956
- return;
1957
- }
1958
- window.location.href = target.browserUrl;
1959
- return;
1960
- }
1961
- if (result.type === "error") state = {
1962
- data: void 0,
1963
- error: result.error
1964
- };
1965
- else state = {
1966
- data: result.data,
1967
- error: null
1968
- };
1969
- } catch {
1970
- if (abortController.signal.aborted || navigationId !== latestNavigationId) return;
1971
- window.location.href = target.browserUrl;
1972
- return;
1973
- }
1974
- if (navigationId !== latestNavigationId) return;
1975
- if (!opts?._popstate) if (opts?.replace) history.replaceState(null, "", target.browserUrl);
1976
- else history.pushState(null, "", target.browserUrl);
1977
- const routeState = await resolveRouteState(match, state, target.requestUrl, routeModPromise, shellModPromise);
1978
- if (navigationId !== latestNavigationId) return;
1979
- if (routeState) {
1980
- applyRouteState(routeState);
1981
- window.scrollTo(0, 0);
1982
- } else window.location.href = target.browserUrl;
1983
- }
1984
- const initialTarget = resolveBrowserRouteTarget(options.initialState.url);
1985
- const initialRequestUrl = initialTarget?.requestUrl ?? options.initialState.url;
1986
- const initialBrowserUrl = initialTarget?.browserUrl ?? options.initialState.url;
1987
- const initialMatch = matchAppRoute(app, initialTarget?.pathname ?? options.initialState.url);
1988
- if (initialMatch) {
1989
- const initialShellPromise = initialMatch.route.render === "spa" && options.initialState.pending ? startShellImport(initialMatch) : null;
1990
- let state = {
1991
- data: options.initialState.data,
1992
- error: options.initialState.error ?? null
1993
- };
1994
- if (initialMatch.route.render === "spa" && options.initialState.pending) {
1995
- const dataPromise = fetchPrachtRouteState(initialRequestUrl, { useDataParam: true });
1996
- const pendingState = await resolveSpaPendingState(initialMatch, initialRequestUrl, initialShellPromise);
1997
- if (pendingState) hydrate(h(RouterRoot, { initialState: pendingState }), root);
1998
- try {
1999
- const result = await dataPromise;
2000
- if (result.type === "redirect") {
2001
- const safeRedirect = parseSafeNavigationUrl(result.location, window.location.href);
2002
- if (!safeRedirect) {
2003
- console.error(`[pracht] refused to navigate to unsafe URL: ${result.location}`);
2004
- return;
2005
- }
2006
- window.location.href = safeRedirect.toString();
2007
- return;
2008
- }
2009
- if (result.type === "error") state = {
2010
- data: void 0,
2011
- error: result.error
2012
- };
2013
- else state = {
2014
- data: result.data,
2015
- error: null
2016
- };
2017
- } catch {
2018
- window.location.href = initialBrowserUrl;
2019
- return;
2020
- }
2021
- const resolvedState = await resolveRouteState(initialMatch, state, initialRequestUrl, void 0, initialShellPromise);
2022
- if (resolvedState) applyRouteState(resolvedState);
2023
- } else {
2024
- const initialRouteState = await resolveRouteState(initialMatch, state, initialRequestUrl, void 0, initialShellPromise);
2025
- if (initialRouteState) if (initialMatch.route.render === "spa") render(h(RouterRoot, { initialState: initialRouteState }), root);
2026
- else {
2027
- markHydrating();
2028
- hydrate(h(RouterRoot, { initialState: initialRouteState }), root);
2029
- }
2030
- }
2031
- }
2032
- document.addEventListener("click", (e) => {
2033
- const anchor = e.target.closest?.("a");
2034
- if (!anchor) return;
2035
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
2036
- if (e.defaultPrevented) return;
2037
- if (e.button !== 0) return;
2038
- const target = anchor.getAttribute("target");
2039
- if (target && target !== "_self") return;
2040
- if (anchor.hasAttribute("download")) return;
2041
- const href = anchor.getAttribute("href");
2042
- if (!href || href.startsWith("#")) return;
2043
- let url;
2044
- try {
2045
- url = new URL(href, window.location.origin);
2046
- } catch {
2047
- return;
2048
- }
2049
- if (url.origin !== window.location.origin) return;
2050
- e.preventDefault();
2051
- navigate(url.pathname + url.search + url.hash);
2052
- });
2053
- window.addEventListener("popstate", () => {
2054
- navigate(window.location.pathname + window.location.search + window.location.hash, { _popstate: true });
2055
- });
2056
- window.__PRACHT_NAVIGATE__ = navigate;
2057
- window.__PRACHT_ROUTER_READY__ = true;
2058
- const warmModules = (match) => {
2059
- startRouteImport(match);
2060
- startShellImport(match);
2061
- };
2062
- setupPrefetching(app, warmModules);
2063
- }
2064
- function resolveBrowserRouteTarget(to) {
2065
- if (typeof window === "undefined") return null;
2066
- try {
2067
- const url = new URL(to, window.location.href);
2068
- if (url.origin !== window.location.origin) return null;
2069
- return {
2070
- browserUrl: url.pathname + url.search + url.hash,
2071
- pathname: url.pathname,
2072
- requestUrl: url.pathname + url.search
2073
- };
2074
- } catch {
2075
- return null;
2076
- }
2077
- }
2078
- //#endregion
2079
- //#region src/types.ts
2080
- var PrachtHttpError = class extends Error {
2081
- status;
2082
- constructor(status, message) {
2083
- super(message);
2084
- this.name = "PrachtHttpError";
2085
- this.status = status;
2086
- }
2087
- };
2088
- //#endregion
2089
- export { Form, Link, PrachtHttpError, PrachtRuntimeProvider, Suspense, applyDefaultSecurityHeaders, buildHref, buildPathFromSegments, createHref, defineApp, forwardRef, group, handlePrachtRequest, initClientRouter, lazy, matchApiRoute, matchAppRoute, prerenderApp, readHydrationState, resolveApiRoutes, resolveApp, route, startApp, timeRevalidate, useIsHydrated, useLocation, useNavigate, useParams, useRevalidate, useRouteData };
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 };