@pracht/core 0.2.6 → 0.3.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,7 +1,252 @@
1
- import { a as matchApiRoute, c as resolveApp, i as group, l as route, n as buildPathFromSegments, o as matchAppRoute, r as defineApp, s as resolveApiRoutes, u as timeRevalidate } from "./app-CUL3q2ZA.mjs";
2
1
  import { createContext, h, hydrate, options, render } from "preact";
3
2
  import { useContext, useEffect, useMemo, useState } from "preact/hooks";
4
3
  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
+ params[currentSegment.name] = targetSegments.slice(targetIndex).join("/");
123
+ return params;
124
+ }
125
+ const targetSegment = targetSegments[targetIndex];
126
+ if (typeof targetSegment === "undefined") return null;
127
+ if (currentSegment.type === "static") {
128
+ if (currentSegment.value !== targetSegment) return null;
129
+ } else try {
130
+ params[currentSegment.name] = decodeURIComponent(targetSegment);
131
+ } catch {
132
+ return null;
133
+ }
134
+ routeIndex += 1;
135
+ targetIndex += 1;
136
+ }
137
+ return targetIndex === targetSegments.length ? params : null;
138
+ }
139
+ function parseRouteSegments(path) {
140
+ return splitPathSegments(path).map((segment) => {
141
+ if (segment === "*") return {
142
+ type: "catchall",
143
+ name: "*"
144
+ };
145
+ if (segment.startsWith(":")) return {
146
+ type: "param",
147
+ name: segment.slice(1)
148
+ };
149
+ return {
150
+ type: "static",
151
+ value: segment
152
+ };
153
+ });
154
+ }
155
+ function splitPathSegments(path) {
156
+ return normalizeRoutePath(path).split("/").filter(Boolean);
157
+ }
158
+ function mergeRoutePaths(prefix, path) {
159
+ if (!path) return normalizeRoutePath(prefix);
160
+ const normalizedPrefix = normalizeRoutePath(prefix);
161
+ const normalizedPath = normalizeRoutePath(path);
162
+ if (normalizedPrefix === "/") return normalizedPath;
163
+ if (normalizedPath === "/") return normalizedPrefix;
164
+ return normalizeRoutePath(`${normalizedPrefix}/${normalizedPath.slice(1)}`);
165
+ }
166
+ function normalizeRoutePath(path) {
167
+ if (!path || path === "/") return "/";
168
+ const collapsed = (path.startsWith("/") ? path : `/${path}`).replace(/\/{2,}/g, "/");
169
+ return collapsed.length > 1 && collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
170
+ }
171
+ function buildPathFromSegments(segments, params) {
172
+ return normalizeRoutePath("/" + segments.map((segment) => {
173
+ if (segment.type === "static") return segment.value;
174
+ if (segment.type === "param") return encodeURIComponent(params[segment.name] ?? "");
175
+ return (params["*"] ?? "").split("/").map((part) => encodeCatchAllSegment(part)).join("/");
176
+ }).join("/"));
177
+ }
178
+ /**
179
+ * Encode a single path segment for a catch-all route. `encodeURIComponent`
180
+ * leaves unreserved characters (including `.`) intact, so `..` would
181
+ * round-trip unchanged and still resolve as parent-dir in `path.join`.
182
+ * Explicitly percent-encode `.` / `..` segments to neutralise them.
183
+ */
184
+ function encodeCatchAllSegment(part) {
185
+ if (part === ".") return "%2E";
186
+ if (part === "..") return "%2E%2E";
187
+ return encodeURIComponent(part);
188
+ }
189
+ /**
190
+ * Convert a list of file paths from `import.meta.glob` into resolved API routes.
191
+ *
192
+ * Example: `"/src/api/health.ts"` → path `/api/health`
193
+ * `"/src/api/users/[id].ts"` → path `/api/users/:id`
194
+ * `"/src/api/index.ts"` → path `/api`
195
+ */
196
+ function resolveApiRoutes(files, apiDir = "/src/api") {
197
+ const normalizedDir = apiDir.replace(/\/$/, "");
198
+ return files.map((file) => {
199
+ let relative = file;
200
+ if (relative.startsWith(normalizedDir)) relative = relative.slice(normalizedDir.length);
201
+ relative = relative.replace(/\.(ts|tsx|js|jsx)$/, "");
202
+ if (relative.endsWith("/index")) relative = relative.slice(0, -6) || "/";
203
+ relative = relative.replace(/\[([^\]]+)\]/g, ":$1");
204
+ const path = normalizeRoutePath(`/api${relative}`);
205
+ return {
206
+ path,
207
+ file,
208
+ segments: parseRouteSegments(path)
209
+ };
210
+ }).sort(compareResolvedApiRoutes);
211
+ }
212
+ function matchApiRoute(apiRoutes, pathname) {
213
+ const normalizedPathname = normalizeRoutePath(pathname);
214
+ const targetSegments = splitPathSegments(normalizedPathname);
215
+ for (const route of apiRoutes) {
216
+ const params = matchRouteSegments(route.segments, targetSegments);
217
+ if (params) return {
218
+ route,
219
+ params,
220
+ pathname: normalizedPathname
221
+ };
222
+ }
223
+ }
224
+ function createRouteId(path) {
225
+ if (path === "/") return "index";
226
+ return path.slice(1).split("/").map((segment) => {
227
+ if (segment === "*") return "splat";
228
+ return segment.startsWith(":") ? segment.slice(1) : segment;
229
+ }).join("-").replace(/[^a-zA-Z0-9-]/g, "-");
230
+ }
231
+ function compareResolvedApiRoutes(left, right) {
232
+ const length = Math.max(left.segments.length, right.segments.length);
233
+ for (let index = 0; index < length; index += 1) {
234
+ const leftSegment = left.segments[index];
235
+ const rightSegment = right.segments[index];
236
+ if (!leftSegment) return 1;
237
+ if (!rightSegment) return -1;
238
+ const leftScore = getRouteSegmentSpecificity(leftSegment);
239
+ const rightScore = getRouteSegmentSpecificity(rightSegment);
240
+ if (leftScore !== rightScore) return rightScore - leftScore;
241
+ }
242
+ return left.path.localeCompare(right.path);
243
+ }
244
+ function getRouteSegmentSpecificity(segment) {
245
+ if (segment.type === "static") return 3;
246
+ if (segment.type === "param") return 2;
247
+ return 1;
248
+ }
249
+ //#endregion
5
250
  //#region src/forwardRef.ts
6
251
  let oldDiffHook = options.__b;
7
252
  options.__b = (vnode) => {
@@ -75,356 +320,418 @@ function useIsHydrated() {
75
320
  return hydrated;
76
321
  }
77
322
  //#endregion
78
- //#region src/runtime.ts
323
+ //#region src/runtime-constants.ts
79
324
  const SAFE_METHODS = new Set(["GET", "HEAD"]);
80
325
  const HYDRATION_STATE_ELEMENT_ID = "pracht-state";
81
326
  const ROUTE_STATE_REQUEST_HEADER = "x-pracht-route-state-request";
82
327
  const ROUTE_STATE_CACHE_CONTROL = "no-store";
83
- let _renderToStringAsync;
84
- async function getRenderToStringAsync() {
85
- if (_renderToStringAsync) return _renderToStringAsync;
86
- _renderToStringAsync = (await import("preact-render-to-string")).renderToStringAsync;
87
- return _renderToStringAsync;
88
- }
89
- const RouteDataContext = createContext(void 0);
90
- function PrachtRuntimeProvider({ children, data, params = {}, routeId, url }) {
91
- const [routeData, setRouteData] = useState(data);
92
- useEffect(() => {
93
- setRouteData(data);
94
- }, [
95
- data,
96
- routeId,
97
- url
98
- ]);
99
- const context = useMemo(() => ({
100
- data: routeData,
101
- params,
102
- routeId,
103
- setData: setRouteData,
104
- url
105
- }), [
106
- routeData,
107
- params,
108
- routeId,
109
- url
110
- ]);
111
- return h(RouteDataContext.Provider, {
112
- value: context,
113
- children
114
- });
115
- }
116
- function startApp(options = {}) {
117
- if (typeof window === "undefined") return options.initialData;
118
- if (typeof options.initialData !== "undefined") return options.initialData;
119
- return readHydrationState()?.data;
328
+ const EMPTY_ROUTE_PARAMS = {};
329
+ //#endregion
330
+ //#region src/runtime-errors.ts
331
+ function isPrachtHttpError(error) {
332
+ return error instanceof Error && error.name === "PrachtHttpError" && "status" in error;
120
333
  }
121
- function readHydrationState() {
122
- if (typeof window === "undefined") return;
123
- if (window.__PRACHT_STATE__) return window.__PRACHT_STATE__;
124
- const element = document.getElementById(HYDRATION_STATE_ELEMENT_ID);
125
- if (!(element instanceof HTMLScriptElement)) return;
126
- const raw = element.textContent;
127
- if (!raw) return;
128
- try {
129
- const state = JSON.parse(raw);
130
- window.__PRACHT_STATE__ = state;
131
- return state;
132
- } catch {
133
- return;
334
+ let warnedAboutProductionDebugErrors = false;
335
+ /**
336
+ * `debugErrors: true` opts into surfacing stack traces, module paths,
337
+ * and middleware names in error responses. That is great in dev and
338
+ * dangerous in production — a misconfigured deploy would leak internals
339
+ * to the public. When `NODE_ENV === "production"` we refuse to honor
340
+ * the flag and emit a single console warning so the misconfiguration
341
+ * is visible in logs.
342
+ */
343
+ function shouldExposeServerErrors(options) {
344
+ if (options.debugErrors !== true) return false;
345
+ if ((typeof process !== "undefined" && process.env ? process.env.NODE_ENV : typeof globalThis !== "undefined" && globalThis.process ? globalThis.process?.env?.NODE_ENV : void 0) === "production") {
346
+ if (!warnedAboutProductionDebugErrors) {
347
+ warnedAboutProductionDebugErrors = true;
348
+ console.warn("[pracht] debugErrors is ignored in production builds. Remove it to silence this warning.");
349
+ }
350
+ return false;
134
351
  }
352
+ return true;
135
353
  }
136
- function useRouteData() {
137
- return useContext(RouteDataContext)?.data;
138
- }
139
- function useLocation() {
140
- return parseLocation(useContext(RouteDataContext)?.url ?? (typeof window !== "undefined" ? window.location.pathname + window.location.search : "/"));
141
- }
142
- function useParams() {
143
- return useContext(RouteDataContext)?.params ?? {};
144
- }
145
- function useRevalidate() {
146
- const runtime = useContext(RouteDataContext);
147
- return async () => {
148
- if (typeof window === "undefined") return;
149
- const result = await fetchPrachtRouteState(runtime?.url || window.location.pathname + window.location.search);
150
- if (result.type === "redirect") {
151
- await navigateToClientLocation(result.location);
152
- return;
153
- }
154
- if (result.type === "error") throw deserializeRouteError$1(result.error);
155
- runtime?.setData(result.data);
156
- return result.data;
354
+ function createSerializedRouteError(message, status, options = {}) {
355
+ return {
356
+ message,
357
+ name: options.name ?? "Error",
358
+ status,
359
+ ...options.diagnostics ? { diagnostics: options.diagnostics } : {}
157
360
  };
158
361
  }
159
- /** @deprecated Use useRevalidate instead. */
160
- const useRevalidateRoute = useRevalidate;
161
- function Form(props) {
162
- const { onSubmit, method, ...rest } = props;
163
- return h("form", {
164
- ...rest,
165
- method,
166
- onSubmit: async (event) => {
167
- onSubmit?.(event);
168
- if (event.defaultPrevented) return;
169
- const form = event.currentTarget;
170
- if (!(form instanceof HTMLFormElement)) return;
171
- const formMethod = (method ?? form.method ?? "post").toUpperCase();
172
- if (SAFE_METHODS.has(formMethod)) return;
173
- event.preventDefault();
174
- const response = await fetch(props.action ?? form.action, {
175
- method: formMethod,
176
- body: new FormData(form),
177
- redirect: "manual"
178
- });
179
- if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) {
180
- const location = response.headers.get("location");
181
- if (location) {
182
- await navigateToClientLocation(location);
183
- return;
184
- }
185
- window.location.href = props.action ?? form.action;
186
- }
187
- }
188
- });
189
- }
190
- async function fetchPrachtRouteState(url) {
191
- const response = await fetch(url, {
192
- headers: {
193
- [ROUTE_STATE_REQUEST_HEADER]: "1",
194
- "Cache-Control": "no-cache"
195
- },
196
- redirect: "manual"
197
- });
198
- if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) return {
199
- location: response.headers.get("location") ?? url,
200
- type: "redirect"
201
- };
202
- const json = await response.json();
203
- if (json.redirect) return {
204
- location: json.redirect,
205
- type: "redirect"
362
+ function buildRuntimeDiagnostics(options) {
363
+ const route = options.route;
364
+ const routeId = route && "id" in route ? route.id : void 0;
365
+ return {
366
+ phase: options.phase,
367
+ routeId,
368
+ routePath: route?.path,
369
+ routeFile: route?.file,
370
+ loaderFile: options.loaderFile,
371
+ shellFile: options.shellFile,
372
+ middlewareFiles: options.middlewareFiles ? [...options.middlewareFiles] : [],
373
+ status: options.status
206
374
  };
207
- if (!response.ok) {
208
- if (json.error) return {
209
- error: json.error,
210
- type: "error"
375
+ }
376
+ function normalizeRouteError(error, options) {
377
+ if (isPrachtHttpError(error)) {
378
+ const status = typeof error.status === "number" ? error.status : 500;
379
+ if (status >= 400 && status < 500) return {
380
+ message: error.message,
381
+ name: error.name,
382
+ status
383
+ };
384
+ if (options.exposeDetails) return {
385
+ message: error.message || "Internal Server Error",
386
+ name: error.name || "Error",
387
+ status
388
+ };
389
+ return {
390
+ message: "Internal Server Error",
391
+ name: "Error",
392
+ status
393
+ };
394
+ }
395
+ if (error instanceof Error) {
396
+ if (options.exposeDetails) return {
397
+ message: error.message || "Internal Server Error",
398
+ name: error.name || "Error",
399
+ status: 500
400
+ };
401
+ return {
402
+ message: "Internal Server Error",
403
+ name: "Error",
404
+ status: 500
211
405
  };
212
- throw new Error(`Failed to fetch route state (${response.status})`);
213
406
  }
407
+ if (options.exposeDetails) return {
408
+ message: typeof error === "string" && error ? error : "Internal Server Error",
409
+ name: "Error",
410
+ status: 500
411
+ };
214
412
  return {
215
- data: json.data,
216
- type: "data"
413
+ message: "Internal Server Error",
414
+ name: "Error",
415
+ status: 500
217
416
  };
218
417
  }
219
- async function handlePrachtRequest(options) {
220
- const url = new URL(options.request.url);
221
- const requestPath = getRequestPath(url);
222
- const registry = options.registry ?? {};
223
- const isRouteStateRequest = options.request.headers.get(ROUTE_STATE_REQUEST_HEADER) === "1";
224
- const exposeDiagnostics = shouldExposeServerErrors(options);
225
- if (options.apiRoutes?.length) {
226
- const apiMatch = matchApiRoute(options.apiRoutes, url.pathname);
227
- if (apiMatch) {
228
- const apiMiddlewareFiles = (options.app.api.middleware ?? []).flatMap((name) => {
229
- const middlewareFile = options.app.middleware[name];
230
- return middlewareFile ? [middlewareFile] : [];
231
- });
232
- let currentPhase = "middleware";
233
- try {
234
- const middlewareResult = await runMiddlewareChain({
235
- context: options.context ?? {},
236
- middlewareFiles: apiMiddlewareFiles,
237
- params: apiMatch.params,
238
- registry,
239
- request: options.request,
240
- route: apiMatch.route,
241
- url
242
- });
243
- if (middlewareResult.response) return middlewareResult.response;
244
- currentPhase = "api";
245
- const apiModule = await resolveRegistryModule(registry.apiModules, apiMatch.route.file);
246
- if (!apiModule) throw new Error("API route module not found");
247
- const handler = apiModule[options.request.method.toUpperCase()] ?? apiModule.default;
248
- if (!handler) return withDefaultSecurityHeaders(new Response("Method not allowed", {
249
- status: 405,
250
- headers: { "content-type": "text/plain; charset=utf-8" }
251
- }));
252
- return withDefaultSecurityHeaders(await handler({
253
- request: options.request,
254
- params: apiMatch.params,
255
- context: middlewareResult.context,
256
- signal: AbortSignal.timeout(3e4),
257
- url,
258
- route: apiMatch.route
259
- }));
260
- } catch (error) {
261
- return renderApiErrorResponse({
262
- error,
263
- middlewareFiles: apiMiddlewareFiles,
264
- options,
265
- phase: currentPhase,
266
- route: apiMatch.route
267
- });
268
- }
269
- }
418
+ function deserializeRouteError(error) {
419
+ const result = new Error(error.message);
420
+ result.name = error.name;
421
+ result.status = error.status;
422
+ result.diagnostics = error.diagnostics;
423
+ return result;
424
+ }
425
+ //#endregion
426
+ //#region src/runtime-headers.ts
427
+ const HEADER_CRLF_RE = /[\r\n]/;
428
+ /**
429
+ * Reject header values containing CR/LF. Some runtimes (Node `undici`
430
+ * Headers) throw on their own, but Web-runtime fetch implementations
431
+ * vary, and a user-supplied `headers()` value is never trusted input.
432
+ * Keeping the check here means response-splitting can't slip through on
433
+ * any adapter.
434
+ */
435
+ function assertSafeHeaderValue(name, value) {
436
+ if (HEADER_CRLF_RE.test(value)) throw new Error(`Refused to set header "${name}": value contains CR or LF`);
437
+ }
438
+ function applyHeaders(headers, init) {
439
+ for (const [key, value] of iterateHeaderInit(init)) assertSafeHeaderValue(key, value);
440
+ new Headers(init).forEach((value, key) => {
441
+ headers.set(key, value);
442
+ });
443
+ }
444
+ function* iterateHeaderInit(init) {
445
+ if (init instanceof Headers) {
446
+ for (const entry of init.entries()) yield entry;
447
+ return;
270
448
  }
271
- const match = matchAppRoute(options.app, url.pathname);
272
- if (!match) {
273
- if (isRouteStateRequest) return jsonErrorResponse(createSerializedRouteError("Not found", 404, {
274
- diagnostics: exposeDiagnostics ? buildRuntimeDiagnostics({
275
- phase: "match",
276
- status: 404
277
- }) : void 0,
278
- name: "Error"
279
- }), { isRouteStateRequest: true });
280
- return withDefaultSecurityHeaders(new Response("Not found", {
281
- status: 404,
282
- headers: { "content-type": "text/plain; charset=utf-8" }
283
- }));
449
+ if (Array.isArray(init)) {
450
+ for (const entry of init) if (entry && entry.length >= 2) yield [entry[0], entry[1]];
451
+ return;
284
452
  }
285
- if (!SAFE_METHODS.has(options.request.method)) {
286
- if (isRouteStateRequest) return jsonErrorResponse(createSerializedRouteError("Method not allowed", 405, {
287
- diagnostics: exposeDiagnostics ? buildRuntimeDiagnostics({
288
- middlewareFiles: match.route.middlewareFiles,
289
- phase: "action",
290
- route: match.route,
291
- shellFile: match.route.shellFile,
292
- status: 405
293
- }) : void 0,
294
- name: "Error"
295
- }), { isRouteStateRequest: true });
296
- return withRouteResponseHeaders(new Response("Method not allowed", {
297
- status: 405,
298
- headers: { "content-type": "text/plain; charset=utf-8" }
299
- }), { isRouteStateRequest });
453
+ for (const [key, value] of Object.entries(init)) yield [key, value];
454
+ }
455
+ function applyDefaultSecurityHeaders(headers) {
456
+ if (!headers.has("permissions-policy")) headers.set("permissions-policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()");
457
+ if (!headers.has("referrer-policy")) headers.set("referrer-policy", "strict-origin-when-cross-origin");
458
+ if (!headers.has("x-content-type-options")) headers.set("x-content-type-options", "nosniff");
459
+ if (!headers.has("x-frame-options")) headers.set("x-frame-options", "SAMEORIGIN");
460
+ return headers;
461
+ }
462
+ function applySecurityAndRouteHeaders(headers, options) {
463
+ applyDefaultSecurityHeaders(headers);
464
+ if (options) {
465
+ appendVaryHeader(headers, ROUTE_STATE_REQUEST_HEADER);
466
+ if (options.isRouteStateRequest && !headers.has("cache-control")) headers.set("cache-control", ROUTE_STATE_CACHE_CONTROL);
300
467
  }
301
- let routeArgs = {
302
- request: options.request,
303
- params: match.params,
304
- context: options.context ?? {},
305
- signal: AbortSignal.timeout(3e4),
306
- url,
307
- route: match.route
308
- };
309
- let routeModule;
310
- let shellModule;
311
- let loaderFile;
312
- let currentPhase = "middleware";
313
- try {
314
- const middlewareResult = await runMiddlewareChain({
315
- context: routeArgs.context,
316
- middlewareFiles: match.route.middlewareFiles,
317
- params: match.params,
318
- registry,
319
- request: options.request,
320
- route: match.route,
321
- url
322
- });
323
- if (middlewareResult.response) return normalizePageResponse(middlewareResult.response, { isRouteStateRequest });
324
- routeArgs = {
325
- ...routeArgs,
326
- context: middlewareResult.context
327
- };
328
- currentPhase = "render";
329
- routeModule = await resolveRegistryModule(registry.routeModules, match.route.file);
330
- if (!routeModule) throw new Error("Route module not found");
331
- currentPhase = "loader";
332
- const { loader, loaderFile: resolvedLoaderFile } = await resolveDataFunctions(match.route, routeModule, registry);
333
- loaderFile = resolvedLoaderFile;
334
- const loaderResult = loader ? await loader(routeArgs) : void 0;
335
- if (loaderResult instanceof Response) return normalizePageResponse(loaderResult, { isRouteStateRequest });
336
- const data = loaderResult;
337
- if (isRouteStateRequest) return withRouteResponseHeaders(Response.json({ data }), { isRouteStateRequest: true });
338
- currentPhase = "render";
339
- shellModule = match.route.shellFile ? await resolveRegistryModule(registry.shellModules, match.route.shellFile) : void 0;
340
- const head = await mergeHeadMetadata(shellModule, routeModule, routeArgs, data);
341
- const documentHeaders = await mergeDocumentHeaders(shellModule, routeModule, routeArgs, data);
342
- const cssUrls = resolvePageCssUrls(options, match.route.shellFile, match.route.file);
343
- const modulePreloadUrls = resolvePageJsUrls(options, match.route.shellFile, match.route.file);
344
- if (match.route.render === "spa") {
345
- let body = "";
346
- if (shellModule?.Shell || shellModule?.Loading) {
347
- const Shell = shellModule?.Shell;
348
- const Loading = shellModule?.Loading;
349
- const loadingTree = Shell != null ? h(Shell, null, Loading ? h(Loading, null) : null) : Loading ? h(Loading, null) : null;
350
- if (loadingTree) body = await (await getRenderToStringAsync())(loadingTree);
351
- }
352
- return htmlResponse(buildHtmlDocument({
353
- head,
354
- body,
355
- hydrationState: {
356
- url: requestPath,
357
- routeId: match.route.id ?? "",
358
- data: null,
359
- error: null,
360
- pending: true
361
- },
362
- clientEntryUrl: options.clientEntryUrl,
363
- cssUrls,
364
- modulePreloadUrls
365
- }), 200, documentHeaders);
366
- }
367
- const DefaultComponent = typeof routeModule.default === "function" ? routeModule.default : void 0;
368
- const Component = routeModule.Component ?? DefaultComponent;
369
- if (!Component) throw new Error("Route has no Component or default export");
370
- const Shell = shellModule?.Shell;
371
- const componentProps = {
372
- data,
373
- params: match.params
374
- };
375
- const componentTree = Shell ? h(Shell, null, h(Component, componentProps)) : h(Component, componentProps);
376
- const tree = h(PrachtRuntimeProvider, {
377
- data,
378
- params: match.params,
379
- routeId: match.route.id ?? "",
380
- url: requestPath
381
- }, componentTree);
382
- return htmlResponse(buildHtmlDocument({
383
- head,
384
- body: await (await getRenderToStringAsync())(tree),
385
- hydrationState: {
386
- url: requestPath,
387
- routeId: match.route.id ?? "",
388
- data,
389
- error: null
390
- },
391
- clientEntryUrl: options.clientEntryUrl,
392
- cssUrls,
393
- modulePreloadUrls
394
- }), 200, documentHeaders);
395
- } catch (error) {
396
- return renderRouteErrorResponse({
397
- error,
398
- isRouteStateRequest,
399
- loaderFile,
400
- options,
401
- phase: currentPhase,
402
- routeArgs,
403
- routeId: match.route.id ?? "",
404
- routeModule,
405
- shellFile: match.route.shellFile,
406
- shellModule,
407
- requestPath
408
- });
468
+ return headers;
469
+ }
470
+ function withDefaultSecurityHeaders(response) {
471
+ const headers = new Headers(response.headers);
472
+ applySecurityAndRouteHeaders(headers);
473
+ return new Response(response.body, {
474
+ status: response.status,
475
+ statusText: response.statusText,
476
+ headers
477
+ });
478
+ }
479
+ function withRouteResponseHeaders(response, options) {
480
+ const headers = new Headers(response.headers);
481
+ applySecurityAndRouteHeaders(headers, options);
482
+ return new Response(response.body, {
483
+ status: response.status,
484
+ statusText: response.statusText,
485
+ headers
486
+ });
487
+ }
488
+ function appendVaryHeader(headers, value) {
489
+ const current = headers.get("vary");
490
+ if (!current) {
491
+ headers.set("vary", value);
492
+ return;
409
493
  }
494
+ const values = current.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
495
+ if (values.includes("*") || values.includes(value.toLowerCase())) return;
496
+ headers.set("vary", `${current}, ${value}`);
410
497
  }
411
- function parseLocation(value) {
498
+ //#endregion
499
+ //#region src/runtime-client-fetch.ts
500
+ const SAFE_NAVIGATION_PROTOCOLS = new Set(["http:", "https:"]);
501
+ /**
502
+ * Parse a possibly-server-supplied redirect target against a base URL and
503
+ * return it only if it uses a safe navigation scheme (`http:` or `https:`).
504
+ *
505
+ * `javascript:`, `data:`, `vbscript:`, `blob:`, `file:` and similar schemes
506
+ * can execute script or bypass same-origin assumptions when assigned to
507
+ * `window.location.href` — a server-controlled redirect (from a loader,
508
+ * middleware, form action response, or API route) must never be able to
509
+ * trigger them. Returns `null` for unsafe or unparseable inputs.
510
+ */
511
+ function parseSafeNavigationUrl(location, base) {
512
+ let targetUrl;
412
513
  try {
413
- const url = new URL(value, "http://pracht.local");
414
- return {
415
- pathname: url.pathname,
416
- search: url.search
417
- };
514
+ targetUrl = new URL(location, base);
418
515
  } catch {
419
- return {
420
- pathname: value || "/",
421
- search: ""
516
+ return null;
517
+ }
518
+ if (!SAFE_NAVIGATION_PROTOCOLS.has(targetUrl.protocol)) return null;
519
+ return targetUrl;
520
+ }
521
+ function buildRouteStateUrl(url) {
522
+ return `${url}${url.includes("?") ? "&" : "?"}_data=1`;
523
+ }
524
+ async function fetchPrachtRouteState(url, options) {
525
+ const fetchUrl = options?.useDataParam ? buildRouteStateUrl(url) : url;
526
+ const response = await fetch(fetchUrl, {
527
+ headers: options?.useDataParam ? {} : {
528
+ [ROUTE_STATE_REQUEST_HEADER]: "1",
529
+ "Cache-Control": "no-cache"
530
+ },
531
+ redirect: "manual"
532
+ });
533
+ if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) return {
534
+ location: response.headers.get("location") ?? url,
535
+ type: "redirect"
536
+ };
537
+ const json = await response.json();
538
+ if (json.redirect) return {
539
+ location: json.redirect,
540
+ type: "redirect"
541
+ };
542
+ if (!response.ok) {
543
+ if (json.error) return {
544
+ error: json.error,
545
+ type: "error"
422
546
  };
547
+ throw new Error(`Failed to fetch route state (${response.status})`);
423
548
  }
549
+ return {
550
+ data: json.data,
551
+ type: "data"
552
+ };
424
553
  }
425
- function getRequestPath(url) {
426
- return `${url.pathname}${url.search}`;
554
+ async function navigateToClientLocation(location, options) {
555
+ if (typeof window === "undefined") return;
556
+ const targetUrl = parseSafeNavigationUrl(location, window.location.href);
557
+ if (!targetUrl) {
558
+ console.error(`[pracht] refused to navigate to unsafe URL: ${location}`);
559
+ return;
560
+ }
561
+ const target = targetUrl.pathname + targetUrl.search + targetUrl.hash;
562
+ if (targetUrl.origin === window.location.origin && window.__PRACHT_NAVIGATE__) {
563
+ await window.__PRACHT_NAVIGATE__(target, options);
564
+ return;
565
+ }
566
+ if (options?.replace) {
567
+ window.location.replace(targetUrl.toString());
568
+ return;
569
+ }
570
+ window.location.href = targetUrl.toString();
427
571
  }
572
+ //#endregion
573
+ //#region src/runtime-hooks.ts
574
+ const RouteDataContext = createContext(void 0);
575
+ function PrachtRuntimeProvider({ children, data, params = EMPTY_ROUTE_PARAMS, routeId, stateVersion = 0, url }) {
576
+ const [routeDataState, setRouteDataState] = useState({
577
+ data,
578
+ stateVersion
579
+ });
580
+ const routeData = routeDataState.stateVersion === stateVersion ? routeDataState.data : data;
581
+ useEffect(() => {
582
+ setRouteDataState({
583
+ data,
584
+ stateVersion
585
+ });
586
+ }, [
587
+ data,
588
+ routeId,
589
+ stateVersion,
590
+ url
591
+ ]);
592
+ const context = useMemo(() => ({
593
+ data: routeData,
594
+ params,
595
+ routeId,
596
+ setData: (nextData) => setRouteDataState({
597
+ data: nextData,
598
+ stateVersion
599
+ }),
600
+ url
601
+ }), [
602
+ routeData,
603
+ params,
604
+ routeId,
605
+ stateVersion,
606
+ url
607
+ ]);
608
+ return h(RouteDataContext.Provider, {
609
+ value: context,
610
+ children
611
+ });
612
+ }
613
+ function startApp(options = {}) {
614
+ if (typeof window === "undefined") return options.initialData;
615
+ if (typeof options.initialData !== "undefined") return options.initialData;
616
+ return readHydrationState()?.data;
617
+ }
618
+ function readHydrationState() {
619
+ if (typeof window === "undefined") return;
620
+ if (window.__PRACHT_STATE__) return window.__PRACHT_STATE__;
621
+ const element = document.getElementById(HYDRATION_STATE_ELEMENT_ID);
622
+ if (!(element instanceof HTMLScriptElement)) return;
623
+ const raw = element.textContent;
624
+ if (!raw) return;
625
+ const state = JSON.parse(raw);
626
+ window.__PRACHT_STATE__ = state;
627
+ return state;
628
+ }
629
+ function useRouteData() {
630
+ return useContext(RouteDataContext)?.data;
631
+ }
632
+ function useLocation() {
633
+ return parseLocation(useContext(RouteDataContext)?.url ?? (typeof window !== "undefined" ? window.location.pathname + window.location.search : "/"));
634
+ }
635
+ function useParams() {
636
+ return useContext(RouteDataContext)?.params ?? {};
637
+ }
638
+ function useRevalidate() {
639
+ const runtime = useContext(RouteDataContext);
640
+ return async () => {
641
+ if (typeof window === "undefined") return;
642
+ const result = await fetchPrachtRouteState(runtime?.url || window.location.pathname + window.location.search);
643
+ if (result.type === "redirect") {
644
+ await navigateToClientLocation(result.location);
645
+ return;
646
+ }
647
+ if (result.type === "error") throw deserializeRouteError(result.error);
648
+ runtime?.setData(result.data);
649
+ return result.data;
650
+ };
651
+ }
652
+ function Form(props) {
653
+ const { onSubmit, method, ...rest } = props;
654
+ return h("form", {
655
+ ...rest,
656
+ method,
657
+ onSubmit: async (event) => {
658
+ onSubmit?.(event);
659
+ if (event.defaultPrevented) return;
660
+ const form = event.currentTarget;
661
+ if (!(form instanceof HTMLFormElement)) return;
662
+ const formMethod = (method ?? form.method ?? "post").toUpperCase();
663
+ if (SAFE_METHODS.has(formMethod)) return;
664
+ event.preventDefault();
665
+ const response = await fetch(props.action ?? form.action, {
666
+ method: formMethod,
667
+ body: new FormData(form),
668
+ redirect: "manual"
669
+ });
670
+ if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) {
671
+ const location = response.headers.get("location");
672
+ if (location) {
673
+ await navigateToClientLocation(location);
674
+ return;
675
+ }
676
+ window.location.href = props.action ?? form.action;
677
+ }
678
+ }
679
+ });
680
+ }
681
+ function parseLocation(value) {
682
+ const url = new URL(value, "http://pracht.local");
683
+ return {
684
+ pathname: url.pathname,
685
+ search: url.search
686
+ };
687
+ }
688
+ //#endregion
689
+ //#region src/runtime-html.ts
690
+ function escapeHtml(str) {
691
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
692
+ }
693
+ function serializeJsonForHtml(value) {
694
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
695
+ }
696
+ function buildHtmlDocument(options) {
697
+ const { head, body, hydrationState, clientEntryUrl, cssUrls = [], modulePreloadUrls = [], routeStatePreloadUrl } = options;
698
+ const titleTag = head.title ? `<title>${escapeHtml(head.title)}</title>` : "";
699
+ const metaTags = (head.meta ?? []).map((m) => `<meta ${Object.entries(m).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ")}>`).join("\n ");
700
+ const linkTags = (head.link ?? []).map((l) => `<link ${Object.entries(l).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ")}>`).join("\n ");
701
+ const cssTags = cssUrls.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join("\n ");
702
+ const modulePreloadTags = modulePreloadUrls.map((url) => `<link rel="modulepreload" href="${escapeHtml(url)}">`).join("\n ");
703
+ const routeStatePreloadTag = routeStatePreloadUrl ? `<link rel="preload" as="fetch" href="${escapeHtml(routeStatePreloadUrl)}" crossorigin="anonymous">` : "";
704
+ const stateScript = `<script id="${HYDRATION_STATE_ELEMENT_ID}" type="application/json">${serializeJsonForHtml(hydrationState)}<\/script>`;
705
+ const entryScript = clientEntryUrl ? `<script type="module" src="${escapeHtml(clientEntryUrl)}"><\/script>` : "";
706
+ return `<!DOCTYPE html>
707
+ <html${head.lang ? ` lang="${escapeHtml(head.lang)}"` : ""}>
708
+ <head>
709
+ <meta charset="utf-8">
710
+ ${titleTag}
711
+ ${metaTags}
712
+ ${linkTags}
713
+ ${cssTags}
714
+ ${modulePreloadTags}
715
+ ${routeStatePreloadTag}
716
+ </head>
717
+ <body>
718
+ <div id="pracht-root">${body}</div>
719
+ ${stateScript}
720
+ ${entryScript}
721
+ </body>
722
+ </html>`;
723
+ }
724
+ function htmlResponse(html, status = 200, initHeaders) {
725
+ const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
726
+ if (initHeaders) applyHeaders(headers, initHeaders);
727
+ applySecurityAndRouteHeaders(headers, { isRouteStateRequest: false });
728
+ return new Response(html, {
729
+ status,
730
+ headers
731
+ });
732
+ }
733
+ //#endregion
734
+ //#region src/runtime-manifest.ts
428
735
  /** Strip leading `./` and `/` so all module paths share one canonical form. */
429
736
  function normalizeModulePath(path) {
430
737
  return path.replace(/^\.?\//, "");
@@ -445,128 +752,142 @@ function buildSuffixIndex(manifest) {
445
752
  const suffixIndexCache = /* @__PURE__ */ new WeakMap();
446
753
  function getSuffixIndex(manifest) {
447
754
  let index = suffixIndexCache.get(manifest);
448
- if (index) return index;
449
- index = buildSuffixIndex(manifest);
450
- suffixIndexCache.set(manifest, index);
451
- return index;
452
- }
453
- function resolveManifestEntries(manifest, file) {
454
- if (file in manifest) return manifest[file];
455
- const resolved = getSuffixIndex(manifest).get(normalizeModulePath(file));
456
- if (resolved) return manifest[resolved];
457
- }
458
- function resolvePageCssUrls(options, shellFile, routeFile) {
459
- if (!options.cssManifest) return options.cssUrls ?? [];
460
- const css = /* @__PURE__ */ new Set();
461
- function addFromManifest(file) {
462
- const entries = resolveManifestEntries(options.cssManifest, file);
463
- if (entries) for (const c of entries) css.add(c);
464
- }
465
- if (shellFile) addFromManifest(shellFile);
466
- addFromManifest(routeFile);
467
- return [...css];
468
- }
469
- function resolvePageJsUrls(options, shellFile, routeFile) {
470
- if (!options.jsManifest) return [];
471
- const js = /* @__PURE__ */ new Set();
472
- function addFromManifest(file) {
473
- const entries = resolveManifestEntries(options.jsManifest, file);
474
- if (entries) for (const j of entries) js.add(j);
475
- }
476
- if (shellFile) addFromManifest(shellFile);
477
- addFromManifest(routeFile);
478
- return [...js];
479
- }
480
- async function navigateToClientLocation(location, options) {
481
- if (typeof window === "undefined") return;
482
- const targetUrl = new URL(location, window.location.href);
483
- const target = targetUrl.pathname + targetUrl.search + targetUrl.hash;
484
- if (targetUrl.origin === window.location.origin && window.__PRACHT_NAVIGATE__) {
485
- await window.__PRACHT_NAVIGATE__(target, options);
486
- return;
487
- }
488
- if (options?.replace) {
489
- window.location.replace(targetUrl.toString());
490
- return;
491
- }
492
- window.location.href = targetUrl.toString();
493
- }
494
- function isPrachtHttpError(error) {
495
- return error instanceof Error && error.name === "PrachtHttpError" && "status" in error;
755
+ if (index) return index;
756
+ index = buildSuffixIndex(manifest);
757
+ suffixIndexCache.set(manifest, index);
758
+ return index;
496
759
  }
497
- function shouldExposeServerErrors(options) {
498
- return options.debugErrors === true;
760
+ function resolveManifestEntries(manifest, file) {
761
+ if (file in manifest) return manifest[file];
762
+ const resolved = getSuffixIndex(manifest).get(normalizeModulePath(file));
763
+ if (resolved) return manifest[resolved];
499
764
  }
500
- function createSerializedRouteError(message, status, options = {}) {
501
- return {
502
- message,
503
- name: options.name ?? "Error",
504
- status,
505
- ...options.diagnostics ? { diagnostics: options.diagnostics } : {}
765
+ function resolvePageUrlsFromManifest(manifest, shellFile, routeFile) {
766
+ const urls = /* @__PURE__ */ new Set();
767
+ const add = (file) => {
768
+ const entries = resolveManifestEntries(manifest, file);
769
+ if (entries) for (const url of entries) urls.add(url);
506
770
  };
771
+ if (shellFile) add(shellFile);
772
+ add(routeFile);
773
+ return [...urls];
507
774
  }
508
- function buildRuntimeDiagnostics(options) {
509
- const route = options.route;
510
- const routeId = route && "id" in route ? route.id : void 0;
775
+ function resolvePageCssUrls(cssManifest, shellFile, routeFile) {
776
+ if (!cssManifest) return [];
777
+ return resolvePageUrlsFromManifest(cssManifest, shellFile, routeFile);
778
+ }
779
+ function resolvePageJsUrls(jsManifest, shellFile, routeFile) {
780
+ if (!jsManifest) return [];
781
+ return resolvePageUrlsFromManifest(jsManifest, shellFile, routeFile);
782
+ }
783
+ async function resolveRegistryModule(modules, file) {
784
+ if (!modules) return void 0;
785
+ if (file in modules) return modules[file]();
786
+ const resolved = getSuffixIndex(modules).get(normalizeModulePath(file));
787
+ if (resolved) return modules[resolved]();
788
+ }
789
+ async function resolveDataFunctions(route, routeModule, registry) {
790
+ let loader = routeModule?.loader;
791
+ let loaderFile = routeModule?.loader ? route.file : void 0;
792
+ if (route.loaderFile) {
793
+ const dataModule = await resolveRegistryModule(registry.dataModules, route.loaderFile);
794
+ if (dataModule?.loader) {
795
+ loader = dataModule.loader;
796
+ loaderFile = route.loaderFile;
797
+ }
798
+ }
511
799
  return {
512
- phase: options.phase,
513
- routeId,
514
- routePath: route?.path,
515
- routeFile: route?.file,
516
- loaderFile: options.loaderFile,
517
- shellFile: options.shellFile,
518
- middlewareFiles: options.middlewareFiles ? [...options.middlewareFiles] : [],
519
- status: options.status
800
+ loader,
801
+ loaderFile
520
802
  };
521
803
  }
522
- function normalizeRouteError(error, options) {
523
- if (isPrachtHttpError(error)) {
524
- const status = typeof error.status === "number" ? error.status : 500;
525
- if (status >= 400 && status < 500) return {
526
- message: error.message,
527
- name: error.name,
528
- status
529
- };
530
- if (options.exposeDetails) return {
531
- message: error.message || "Internal Server Error",
532
- name: error.name || "Error",
533
- status
534
- };
535
- return {
536
- message: "Internal Server Error",
537
- name: "Error",
538
- status
539
- };
540
- }
541
- if (error instanceof Error) {
542
- if (options.exposeDetails) return {
543
- message: error.message || "Internal Server Error",
544
- name: error.name || "Error",
545
- status: 500
546
- };
547
- return {
548
- message: "Internal Server Error",
549
- name: "Error",
550
- status: 500
804
+ //#endregion
805
+ //#region src/runtime-middleware.ts
806
+ const DEFAULT_REDIRECT_STATUS_SAFE = 302;
807
+ const DEFAULT_REDIRECT_STATUS_UNSAFE = 303;
808
+ /**
809
+ * Build a safe redirect response from middleware/loader output. Rejects
810
+ * non-http(s) schemes (no `javascript:`/`data:`/etc.) and CR/LF injection
811
+ * against the `Location` header. When status is omitted, non-GET/HEAD
812
+ * requests default to 303 so the browser does not resend the body to the
813
+ * redirect target; safe methods default to 302.
814
+ *
815
+ * The original `target` string is preserved on success (relative paths
816
+ * stay relative) — we only parse it to validate scheme, not to rewrite
817
+ * it. Both the original input and its resolved URL must be CR/LF-free.
818
+ */
819
+ function buildRedirectResponse(target, options) {
820
+ if (/[\r\n]/.test(target)) throw new Error("Refused redirect target containing CR/LF");
821
+ if (!parseSafeNavigationUrl(target, options.baseUrl)) throw new Error("Refused unsafe redirect target");
822
+ const method = (options.method ?? "GET").toUpperCase();
823
+ const defaultStatus = SAFE_METHODS.has(method) ? DEFAULT_REDIRECT_STATUS_SAFE : DEFAULT_REDIRECT_STATUS_UNSAFE;
824
+ const status = options.status ?? defaultStatus;
825
+ return new Response(null, {
826
+ status,
827
+ headers: { location: target }
828
+ });
829
+ }
830
+ async function runMiddlewareChain(options) {
831
+ let context = options.context;
832
+ const modulePromises = options.middlewareFiles.map((mwFile) => resolveRegistryModule(options.registry.middlewareModules, mwFile));
833
+ for (const p of modulePromises) p.catch(() => {});
834
+ for (const modulePromise of modulePromises) {
835
+ const mwModule = await modulePromise;
836
+ if (!mwModule?.middleware) continue;
837
+ const result = await mwModule.middleware({
838
+ request: options.request,
839
+ params: options.params,
840
+ context,
841
+ signal: AbortSignal.timeout(3e4),
842
+ url: options.url,
843
+ route: options.route
844
+ });
845
+ if (!result) continue;
846
+ if (result instanceof Response) return { response: withDefaultSecurityHeaders(result) };
847
+ if ("redirect" in result) {
848
+ const status = "status" in result ? result.status : void 0;
849
+ return { response: withDefaultSecurityHeaders(buildRedirectResponse(result.redirect, {
850
+ baseUrl: options.request.url,
851
+ method: options.request.method,
852
+ status
853
+ })) };
854
+ }
855
+ if ("context" in result) context = {
856
+ ...context,
857
+ ...result.context
551
858
  };
552
859
  }
553
- if (options.exposeDetails) return {
554
- message: typeof error === "string" && error ? error : "Internal Server Error",
555
- name: "Error",
556
- status: 500
557
- };
860
+ return { context };
861
+ }
862
+ async function mergeHeadMetadata(shellModule, routeModule, routeArgs, data) {
863
+ const [shellHead, routeHead] = await Promise.all([shellModule?.head ? shellModule.head(routeArgs) : Promise.resolve({}), routeModule?.head ? routeModule.head({
864
+ ...routeArgs,
865
+ data
866
+ }) : Promise.resolve({})]);
558
867
  return {
559
- message: "Internal Server Error",
560
- name: "Error",
561
- status: 500
868
+ title: routeHead.title ?? shellHead.title,
869
+ lang: routeHead.lang ?? shellHead.lang,
870
+ meta: [...shellHead.meta ?? [], ...routeHead.meta ?? []],
871
+ link: [...shellHead.link ?? [], ...routeHead.link ?? []]
562
872
  };
563
873
  }
564
- function deserializeRouteError$1(error) {
565
- const result = new Error(error.message);
566
- result.name = error.name;
567
- result.status = error.status;
568
- result.diagnostics = error.diagnostics;
569
- return result;
874
+ async function mergeDocumentHeaders(shellModule, routeModule, routeArgs, data) {
875
+ const headers = new Headers();
876
+ const [shellHeaders, routeHeaders] = await Promise.all([shellModule?.headers ? shellModule.headers(routeArgs) : Promise.resolve(void 0), routeModule?.headers ? routeModule.headers({
877
+ ...routeArgs,
878
+ data
879
+ }) : Promise.resolve(void 0)]);
880
+ if (shellHeaders) applyHeaders(headers, shellHeaders);
881
+ if (routeHeaders) applyHeaders(headers, routeHeaders);
882
+ return headers;
883
+ }
884
+ //#endregion
885
+ //#region src/runtime-response.ts
886
+ let _renderToStringAsync;
887
+ async function getRenderToStringAsync() {
888
+ if (_renderToStringAsync) return _renderToStringAsync;
889
+ _renderToStringAsync = (await import("preact-render-to-string")).renderToStringAsync;
890
+ return _renderToStringAsync;
570
891
  }
571
892
  function jsonErrorResponse(routeError, options) {
572
893
  const headers = applySecurityAndRouteHeaders(new Headers({ "content-type": "application/json; charset=utf-8" }), options.isRouteStateRequest ? { isRouteStateRequest: true } : void 0);
@@ -638,12 +959,12 @@ async function renderRouteErrorResponse(options) {
638
959
  const shellModule = options.shellModule ?? (options.shellFile ? await resolveRegistryModule(options.options.registry?.shellModules, options.shellFile) : void 0);
639
960
  const head = shellModule?.head ? await shellModule.head(options.routeArgs) : {};
640
961
  const documentHeaders = await mergeDocumentHeaders(shellModule, void 0, options.routeArgs, void 0);
641
- const cssUrls = resolvePageCssUrls(options.options, options.shellFile, options.routeArgs.route.file);
642
- const modulePreloadUrls = resolvePageJsUrls(options.options, options.shellFile, options.routeArgs.route.file);
962
+ const cssUrls = resolvePageCssUrls(options.options.cssManifest, options.shellFile, options.routeArgs.route.file);
963
+ const modulePreloadUrls = resolvePageJsUrls(options.options.jsManifest, options.shellFile, options.routeArgs.route.file);
643
964
  const renderToString = await getRenderToStringAsync();
644
965
  const ErrorBoundary = options.routeModule.ErrorBoundary;
645
966
  const Shell = shellModule?.Shell;
646
- const errorValue = deserializeRouteError$1(routeErrorWithDiagnostics);
967
+ const errorValue = deserializeRouteError(routeErrorWithDiagnostics);
647
968
  const componentTree = Shell ? h(Shell, null, h(ErrorBoundary, { error: errorValue })) : h(ErrorBoundary, { error: errorValue });
648
969
  return htmlResponse(buildHtmlDocument({
649
970
  head,
@@ -663,168 +984,325 @@ async function renderRouteErrorResponse(options) {
663
984
  modulePreloadUrls
664
985
  }), routeErrorWithDiagnostics.status, documentHeaders);
665
986
  }
666
- async function runMiddlewareChain(options) {
667
- let context = options.context;
668
- for (const mwFile of options.middlewareFiles) {
669
- const mwModule = await resolveRegistryModule(options.registry.middlewareModules, mwFile);
670
- if (!mwModule?.middleware) continue;
671
- const result = await mwModule.middleware({
672
- request: options.request,
673
- params: options.params,
674
- context,
675
- signal: AbortSignal.timeout(3e4),
676
- url: options.url,
677
- route: options.route
678
- });
679
- if (!result) continue;
680
- if (result instanceof Response) return { response: withDefaultSecurityHeaders(result) };
681
- if ("redirect" in result) return { response: withDefaultSecurityHeaders(new Response(null, {
682
- status: 302,
683
- headers: { location: result.redirect }
684
- })) };
685
- if ("context" in result) context = {
686
- ...context,
687
- ...result.context
688
- };
689
- }
690
- return { context };
691
- }
692
- async function resolveDataFunctions(route, routeModule, registry) {
693
- let loader = routeModule?.loader;
694
- let loaderFile = routeModule?.loader ? route.file : void 0;
695
- if (route.loaderFile) {
696
- const dataModule = await resolveRegistryModule(registry.dataModules, route.loaderFile);
697
- if (dataModule?.loader) {
698
- loader = dataModule.loader;
699
- loaderFile = route.loaderFile;
700
- }
701
- }
702
- return {
703
- loader,
704
- loaderFile
705
- };
706
- }
707
- async function resolveRegistryModule(modules, file) {
708
- if (!modules) return void 0;
709
- if (file in modules) return modules[file]();
710
- const resolved = getSuffixIndex(modules).get(normalizeModulePath(file));
711
- if (resolved) return modules[resolved]();
712
- }
713
- async function mergeHeadMetadata(shellModule, routeModule, routeArgs, data) {
714
- const shellHead = shellModule?.head ? await shellModule.head(routeArgs) : {};
715
- const routeHead = routeModule?.head ? await routeModule.head({
716
- ...routeArgs,
717
- data
718
- }) : {};
719
- return {
720
- title: routeHead.title ?? shellHead.title,
721
- lang: routeHead.lang ?? shellHead.lang,
722
- meta: [...shellHead.meta ?? [], ...routeHead.meta ?? []],
723
- link: [...shellHead.link ?? [], ...routeHead.link ?? []]
724
- };
725
- }
726
- async function mergeDocumentHeaders(shellModule, routeModule, routeArgs, data) {
727
- const headers = new Headers();
728
- const shellHeaders = shellModule?.headers ? await shellModule.headers(routeArgs) : void 0;
729
- if (shellHeaders) applyHeaders(headers, shellHeaders);
730
- const routeHeaders = routeModule?.headers ? await routeModule.headers({
731
- ...routeArgs,
732
- data
733
- }) : void 0;
734
- if (routeHeaders) applyHeaders(headers, routeHeaders);
735
- return headers;
736
- }
737
- function applyHeaders(headers, init) {
738
- new Headers(init).forEach((value, key) => {
739
- headers.set(key, value);
740
- });
741
- }
742
- function buildHtmlDocument(options) {
743
- const { head, body, hydrationState, clientEntryUrl, cssUrls = [], modulePreloadUrls = [] } = options;
744
- const titleTag = head.title ? `<title>${escapeHtml(head.title)}</title>` : "";
745
- const metaTags = (head.meta ?? []).map((m) => `<meta ${Object.entries(m).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ")}>`).join("\n ");
746
- const linkTags = (head.link ?? []).map((l) => `<link ${Object.entries(l).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ")}>`).join("\n ");
747
- const cssTags = cssUrls.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join("\n ");
748
- const modulePreloadTags = modulePreloadUrls.map((url) => `<link rel="modulepreload" href="${escapeHtml(url)}">`).join("\n ");
749
- const stateScript = `<script id="${HYDRATION_STATE_ELEMENT_ID}" type="application/json">${serializeJsonForHtml(hydrationState)}<\/script>`;
750
- const entryScript = clientEntryUrl ? `<script type="module" src="${escapeHtml(clientEntryUrl)}"><\/script>` : "";
751
- return `<!DOCTYPE html>
752
- <html${head.lang ? ` lang="${escapeHtml(head.lang)}"` : ""}>
753
- <head>
754
- <meta charset="utf-8">
755
- ${titleTag}
756
- ${metaTags}
757
- ${linkTags}
758
- ${cssTags}
759
- ${modulePreloadTags}
760
- </head>
761
- <body>
762
- <div id="pracht-root">${body}</div>
763
- ${stateScript}
764
- ${entryScript}
765
- </body>
766
- </html>`;
767
- }
768
- function htmlResponse(html, status = 200, initHeaders) {
769
- const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
770
- if (initHeaders) applyHeaders(headers, initHeaders);
771
- applySecurityAndRouteHeaders(headers, { isRouteStateRequest: false });
772
- return new Response(html, {
773
- status,
774
- headers
775
- });
776
- }
777
- function applyDefaultSecurityHeaders(headers) {
778
- if (!headers.has("permissions-policy")) headers.set("permissions-policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()");
779
- if (!headers.has("referrer-policy")) headers.set("referrer-policy", "strict-origin-when-cross-origin");
780
- if (!headers.has("x-content-type-options")) headers.set("x-content-type-options", "nosniff");
781
- if (!headers.has("x-frame-options")) headers.set("x-frame-options", "SAMEORIGIN");
782
- return headers;
783
- }
784
- function applySecurityAndRouteHeaders(headers, options) {
785
- applyDefaultSecurityHeaders(headers);
786
- if (options) {
787
- appendVaryHeader(headers, ROUTE_STATE_REQUEST_HEADER);
788
- if (options.isRouteStateRequest && !headers.has("cache-control")) headers.set("cache-control", ROUTE_STATE_CACHE_CONTROL);
789
- }
790
- return headers;
791
- }
792
- function withDefaultSecurityHeaders(response) {
793
- const headers = new Headers(response.headers);
794
- applySecurityAndRouteHeaders(headers);
795
- return new Response(response.body, {
796
- status: response.status,
797
- statusText: response.statusText,
798
- headers
799
- });
987
+ //#endregion
988
+ //#region src/runtime-negotiation.ts
989
+ const MARKDOWN_MEDIA_TYPE = "text/markdown";
990
+ function parseAccept(header) {
991
+ if (!header) return [];
992
+ const entries = [];
993
+ for (const raw of header.split(",")) {
994
+ const parts = raw.trim().split(";");
995
+ const type = parts.shift()?.trim().toLowerCase();
996
+ if (!type) continue;
997
+ let quality = 1;
998
+ for (const param of parts) {
999
+ const [key, value] = param.split("=").map((p) => p.trim());
1000
+ if (key === "q" && value != null) {
1001
+ const parsed = Number.parseFloat(value);
1002
+ if (!Number.isNaN(parsed)) quality = parsed;
1003
+ }
1004
+ }
1005
+ entries.push({
1006
+ type,
1007
+ quality
1008
+ });
1009
+ }
1010
+ return entries;
800
1011
  }
801
- function withRouteResponseHeaders(response, options) {
802
- const headers = new Headers(response.headers);
803
- applySecurityAndRouteHeaders(headers, options);
804
- return new Response(response.body, {
805
- status: response.status,
806
- statusText: response.statusText,
1012
+ function prefersMarkdown(accept) {
1013
+ const entries = parseAccept(accept);
1014
+ if (!entries.length) return false;
1015
+ const md = entries.find((e) => e.type === MARKDOWN_MEDIA_TYPE);
1016
+ if (!md || md.quality === 0) return false;
1017
+ const html = entries.find((e) => e.type === "text/html");
1018
+ if (!html) return true;
1019
+ return md.quality >= html.quality;
1020
+ }
1021
+ function markdownResponse(source) {
1022
+ const headers = new Headers({
1023
+ "content-type": "text/markdown; charset=utf-8",
1024
+ "cache-control": "public, max-age=0, must-revalidate"
1025
+ });
1026
+ appendVaryHeader(headers, "Accept");
1027
+ applyDefaultSecurityHeaders(headers);
1028
+ return new Response(source, {
1029
+ status: 200,
807
1030
  headers
808
1031
  });
809
1032
  }
810
- function appendVaryHeader(headers, value) {
811
- const current = headers.get("vary");
812
- if (!current) {
813
- headers.set("vary", value);
814
- return;
1033
+ //#endregion
1034
+ //#region src/runtime.ts
1035
+ const FIRST_PARTY_FETCH_SITES = new Set(["same-origin", "same-site"]);
1036
+ /**
1037
+ * Stricter variant of first-party detection used for CSRF protection on
1038
+ * state-changing API requests. Unlike `isFirstPartyFetch`, this *only*
1039
+ * accepts explicit positive evidence that the request came from this
1040
+ * origin — a cross-origin form POST will send `Origin` from the
1041
+ * attacker, and a missing `Origin` on POST is unusual enough to block.
1042
+ * Non-browser callers (curl, server-to-server) should set the header
1043
+ * explicitly or pre-flight via middleware.
1044
+ */
1045
+ function isSameOriginMutation(request, url) {
1046
+ const site = request.headers.get("sec-fetch-site");
1047
+ if (site) return FIRST_PARTY_FETCH_SITES.has(site);
1048
+ const origin = request.headers.get("origin");
1049
+ if (origin) try {
1050
+ return new URL(origin).origin === url.origin;
1051
+ } catch {
1052
+ return false;
815
1053
  }
816
- const values = current.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
817
- if (values.includes("*") || values.includes(value.toLowerCase())) return;
818
- headers.set("vary", `${current}, ${value}`);
1054
+ const referer = request.headers.get("referer");
1055
+ if (referer) try {
1056
+ return new URL(referer).origin === url.origin;
1057
+ } catch {
1058
+ return false;
1059
+ }
1060
+ return true;
819
1061
  }
820
- function escapeHtml(str) {
821
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1062
+ /**
1063
+ * Heuristic "this request came from our own page" check. Used to gate
1064
+ * the `_data=1` query-param form of the route-state endpoint, which is
1065
+ * otherwise reachable via any cross-origin `<a href>` / redirect.
1066
+ *
1067
+ * Accepts a request as first-party when:
1068
+ * - Sec-Fetch-Site is `same-origin` or `same-site` (modern browsers),
1069
+ * - OR Sec-Fetch-Site is absent AND the Origin header matches the
1070
+ * request URL's origin (older clients that still send Origin),
1071
+ * - OR no Origin/Sec-Fetch-Site is present AND there is no Referer
1072
+ * (non-browser clients like curl — CSRF is not the threat model
1073
+ * there; blocking would break tests and CLIs).
1074
+ *
1075
+ * Cross-origin browser navigations set Sec-Fetch-Site to `cross-site`
1076
+ * or `none` (for user-typed URLs Sec-Fetch-Site: none, Referer absent,
1077
+ * Origin absent — handled by the "no headers → allow" branch since that
1078
+ * matches a first-party typed URL too).
1079
+ */
1080
+ function isFirstPartyFetch(request) {
1081
+ const site = request.headers.get("sec-fetch-site");
1082
+ if (site) return FIRST_PARTY_FETCH_SITES.has(site);
1083
+ const origin = request.headers.get("origin");
1084
+ if (origin) try {
1085
+ return new URL(origin).origin === new URL(request.url).origin;
1086
+ } catch {
1087
+ return false;
1088
+ }
1089
+ return true;
822
1090
  }
823
- function serializeJsonForHtml(value) {
824
- return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
1091
+ async function handlePrachtRequest(options) {
1092
+ const url = new URL(options.request.url);
1093
+ const hasDataParam = url.searchParams.get("_data") === "1";
1094
+ if (hasDataParam) url.searchParams.delete("_data");
1095
+ const requestPath = getRequestPath(url);
1096
+ const registry = options.registry ?? {};
1097
+ const headerSignalsRouteState = options.request.headers.get(ROUTE_STATE_REQUEST_HEADER) === "1";
1098
+ const dataParamIsFirstParty = hasDataParam && isFirstPartyFetch(options.request);
1099
+ const isRouteStateRequest = headerSignalsRouteState || dataParamIsFirstParty;
1100
+ const exposeDiagnostics = shouldExposeServerErrors(options);
1101
+ if (options.apiRoutes?.length) {
1102
+ const apiMatch = matchApiRoute(options.apiRoutes, url.pathname);
1103
+ if (apiMatch) {
1104
+ const apiMiddlewareFiles = (options.app.api.middleware ?? []).flatMap((name) => {
1105
+ const middlewareFile = options.app.middleware[name];
1106
+ return middlewareFile ? [middlewareFile] : [];
1107
+ });
1108
+ let currentPhase = "middleware";
1109
+ if ((options.app.api.requireSameOrigin ?? true) && !SAFE_METHODS.has(options.request.method) && !isSameOriginMutation(options.request, url)) return withDefaultSecurityHeaders(new Response("Cross-origin request blocked", {
1110
+ status: 403,
1111
+ headers: { "content-type": "text/plain; charset=utf-8" }
1112
+ }));
1113
+ try {
1114
+ const middlewareResult = await runMiddlewareChain({
1115
+ context: options.context ?? {},
1116
+ middlewareFiles: apiMiddlewareFiles,
1117
+ params: apiMatch.params,
1118
+ registry,
1119
+ request: options.request,
1120
+ route: apiMatch.route,
1121
+ url
1122
+ });
1123
+ if (middlewareResult.response) return middlewareResult.response;
1124
+ currentPhase = "api";
1125
+ const apiModule = await resolveRegistryModule(registry.apiModules, apiMatch.route.file);
1126
+ if (!apiModule) throw new Error("API route module not found");
1127
+ const handler = apiModule[options.request.method.toUpperCase()] ?? apiModule.default;
1128
+ if (!handler) return withDefaultSecurityHeaders(new Response("Method not allowed", {
1129
+ status: 405,
1130
+ headers: { "content-type": "text/plain; charset=utf-8" }
1131
+ }));
1132
+ return withDefaultSecurityHeaders(await handler({
1133
+ request: options.request,
1134
+ params: apiMatch.params,
1135
+ context: middlewareResult.context,
1136
+ signal: AbortSignal.timeout(3e4),
1137
+ url,
1138
+ route: apiMatch.route
1139
+ }));
1140
+ } catch (error) {
1141
+ return renderApiErrorResponse({
1142
+ error,
1143
+ middlewareFiles: apiMiddlewareFiles,
1144
+ options,
1145
+ phase: currentPhase,
1146
+ route: apiMatch.route
1147
+ });
1148
+ }
1149
+ }
1150
+ }
1151
+ const match = matchAppRoute(options.app, url.pathname);
1152
+ if (!match) {
1153
+ if (isRouteStateRequest) return jsonErrorResponse(createSerializedRouteError("Not found", 404, {
1154
+ diagnostics: exposeDiagnostics ? buildRuntimeDiagnostics({
1155
+ phase: "match",
1156
+ status: 404
1157
+ }) : void 0,
1158
+ name: "Error"
1159
+ }), { isRouteStateRequest: true });
1160
+ return withDefaultSecurityHeaders(new Response("Not found", {
1161
+ status: 404,
1162
+ headers: { "content-type": "text/plain; charset=utf-8" }
1163
+ }));
1164
+ }
1165
+ if (!SAFE_METHODS.has(options.request.method)) {
1166
+ if (isRouteStateRequest) return jsonErrorResponse(createSerializedRouteError("Method not allowed", 405, {
1167
+ diagnostics: exposeDiagnostics ? buildRuntimeDiagnostics({
1168
+ middlewareFiles: match.route.middlewareFiles,
1169
+ phase: "action",
1170
+ route: match.route,
1171
+ shellFile: match.route.shellFile,
1172
+ status: 405
1173
+ }) : void 0,
1174
+ name: "Error"
1175
+ }), { isRouteStateRequest: true });
1176
+ return withRouteResponseHeaders(new Response("Method not allowed", {
1177
+ status: 405,
1178
+ headers: { "content-type": "text/plain; charset=utf-8" }
1179
+ }), { isRouteStateRequest });
1180
+ }
1181
+ let routeArgs = {
1182
+ request: options.request,
1183
+ params: match.params,
1184
+ context: options.context ?? {},
1185
+ signal: AbortSignal.timeout(3e4),
1186
+ url,
1187
+ route: match.route
1188
+ };
1189
+ let routeModule;
1190
+ let shellModule;
1191
+ let loaderFile;
1192
+ let currentPhase = "middleware";
1193
+ try {
1194
+ const middlewarePromise = runMiddlewareChain({
1195
+ context: routeArgs.context,
1196
+ middlewareFiles: match.route.middlewareFiles,
1197
+ params: match.params,
1198
+ registry,
1199
+ request: options.request,
1200
+ route: match.route,
1201
+ url
1202
+ });
1203
+ const routeModulePromise = resolveRegistryModule(registry.routeModules, match.route.file);
1204
+ const shellModulePromise = match.route.shellFile ? resolveRegistryModule(registry.shellModules, match.route.shellFile) : Promise.resolve(void 0);
1205
+ const dataFunctionsPromise = routeModulePromise.then((mod) => resolveDataFunctions(match.route, mod, registry));
1206
+ routeModulePromise.catch(() => {});
1207
+ shellModulePromise.catch(() => {});
1208
+ dataFunctionsPromise.catch(() => {});
1209
+ const middlewareResult = await middlewarePromise;
1210
+ if (middlewareResult.response) return normalizePageResponse(middlewareResult.response, { isRouteStateRequest });
1211
+ routeArgs = {
1212
+ ...routeArgs,
1213
+ context: middlewareResult.context
1214
+ };
1215
+ currentPhase = "render";
1216
+ routeModule = await routeModulePromise;
1217
+ if (!routeModule) throw new Error("Route module not found");
1218
+ if (!isRouteStateRequest && typeof routeModule.markdown === "string" && prefersMarkdown(options.request.headers.get("accept"))) return markdownResponse(routeModule.markdown);
1219
+ currentPhase = "loader";
1220
+ const { loader, loaderFile: resolvedLoaderFile } = await dataFunctionsPromise;
1221
+ loaderFile = resolvedLoaderFile;
1222
+ const loaderResult = loader ? await loader(routeArgs) : void 0;
1223
+ if (loaderResult instanceof Response) return normalizePageResponse(loaderResult, { isRouteStateRequest });
1224
+ const data = loaderResult;
1225
+ if (isRouteStateRequest) return withRouteResponseHeaders(Response.json({ data }), { isRouteStateRequest: true });
1226
+ currentPhase = "render";
1227
+ shellModule = await shellModulePromise;
1228
+ const [head, documentHeaders] = await Promise.all([mergeHeadMetadata(shellModule, routeModule, routeArgs, data), mergeDocumentHeaders(shellModule, routeModule, routeArgs, data)]);
1229
+ const cssUrls = resolvePageCssUrls(options.cssManifest, match.route.shellFile, match.route.file);
1230
+ const modulePreloadUrls = resolvePageJsUrls(options.jsManifest, match.route.shellFile, match.route.file);
1231
+ if (match.route.render === "spa") {
1232
+ let body = "";
1233
+ if (shellModule?.Shell || shellModule?.Loading) {
1234
+ const Shell = shellModule?.Shell;
1235
+ const Loading = shellModule?.Loading;
1236
+ const loadingTree = Shell != null ? h(Shell, null, Loading ? h(Loading, null) : null) : Loading ? h(Loading, null) : null;
1237
+ if (loadingTree) body = await (await getRenderToStringAsync())(loadingTree);
1238
+ }
1239
+ return htmlResponse(buildHtmlDocument({
1240
+ head,
1241
+ body,
1242
+ hydrationState: {
1243
+ url: requestPath,
1244
+ routeId: match.route.id ?? "",
1245
+ data: null,
1246
+ error: null,
1247
+ pending: true
1248
+ },
1249
+ clientEntryUrl: options.clientEntryUrl,
1250
+ cssUrls,
1251
+ modulePreloadUrls,
1252
+ routeStatePreloadUrl: loader ? buildRouteStateUrl(requestPath) : void 0
1253
+ }), 200, documentHeaders);
1254
+ }
1255
+ const DefaultComponent = typeof routeModule.default === "function" ? routeModule.default : void 0;
1256
+ const Component = routeModule.Component ?? DefaultComponent;
1257
+ if (!Component) throw new Error("Route has no Component or default export");
1258
+ const Shell = shellModule?.Shell;
1259
+ const Comp = Component;
1260
+ const componentProps = {
1261
+ data,
1262
+ params: match.params
1263
+ };
1264
+ const componentTree = Shell ? h(Shell, null, h(Comp, componentProps)) : h(Comp, componentProps);
1265
+ const tree = h(PrachtRuntimeProvider, {
1266
+ data,
1267
+ params: match.params,
1268
+ routeId: match.route.id ?? "",
1269
+ url: requestPath
1270
+ }, componentTree);
1271
+ return htmlResponse(buildHtmlDocument({
1272
+ head,
1273
+ body: await (await getRenderToStringAsync())(tree),
1274
+ hydrationState: {
1275
+ url: requestPath,
1276
+ routeId: match.route.id ?? "",
1277
+ data,
1278
+ error: null
1279
+ },
1280
+ clientEntryUrl: options.clientEntryUrl,
1281
+ cssUrls,
1282
+ modulePreloadUrls
1283
+ }), 200, documentHeaders);
1284
+ } catch (error) {
1285
+ return renderRouteErrorResponse({
1286
+ error,
1287
+ isRouteStateRequest,
1288
+ loaderFile,
1289
+ options,
1290
+ phase: currentPhase,
1291
+ routeArgs,
1292
+ routeId: match.route.id ?? "",
1293
+ routeModule,
1294
+ shellFile: match.route.shellFile,
1295
+ shellModule,
1296
+ requestPath
1297
+ });
1298
+ }
1299
+ }
1300
+ function getRequestPath(url) {
1301
+ return `${url.pathname}${url.search}`;
825
1302
  }
1303
+ //#endregion
1304
+ //#region src/prerender.ts
826
1305
  async function prerenderApp(options) {
827
- const { resolveApp } = await import("./app-CUL3q2ZA.mjs").then((n) => n.t);
828
1306
  const resolved = resolveApp(options.app);
829
1307
  const results = [];
830
1308
  const isgManifest = {};
@@ -886,7 +1364,6 @@ async function collectSSGPaths(route, registry) {
886
1364
  console.warn(` Warning: SSG route "${route.path}" has dynamic segments but no getStaticPaths() export, skipping.`);
887
1365
  return [];
888
1366
  }
889
- const { buildPathFromSegments } = await import("./app-CUL3q2ZA.mjs").then((n) => n.t);
890
1367
  return (await routeModule.getStaticPaths()).map((params) => buildPathFromSegments(route.segments, params));
891
1368
  }
892
1369
  //#endregion
@@ -939,7 +1416,6 @@ function setupPrefetching(app, warmModules) {
939
1416
  const match = matchAppRoute(app, routePathname);
940
1417
  if (!match) return "none";
941
1418
  if (match.route.prefetch) return match.route.prefetch;
942
- if (match.route.render === "spa") return "none";
943
1419
  return "intent";
944
1420
  }
945
1421
  document.addEventListener("mouseenter", (e) => {
@@ -1041,7 +1517,30 @@ async function initClientRouter(options) {
1041
1517
  if (!shellKey) return null;
1042
1518
  return loadModule(shellModules, shellKey);
1043
1519
  }
1044
- async function buildRouteTree(match, state, currentUrl, routeModPromise, shellModPromise) {
1520
+ let updateRouteState = null;
1521
+ let routeStateVersion = 0;
1522
+ function RouterRoot({ initialState }) {
1523
+ const [routeState, setRouteState] = useState(initialState);
1524
+ updateRouteState = setRouteState;
1525
+ const navigateValue = useMemo(() => navigate, []);
1526
+ const { Shell, Component, componentProps, data, params, routeId, url, version } = routeState;
1527
+ const componentTree = Shell ? h(Shell, null, h(Component, componentProps)) : h(Component, componentProps);
1528
+ return h(NavigateContext.Provider, { value: navigateValue }, h(PrachtRuntimeProvider, {
1529
+ data,
1530
+ params,
1531
+ routeId,
1532
+ stateVersion: version,
1533
+ url
1534
+ }, componentTree));
1535
+ }
1536
+ function applyRouteState(routeState) {
1537
+ if (updateRouteState) {
1538
+ updateRouteState(routeState);
1539
+ return;
1540
+ }
1541
+ render(h(RouterRoot, { initialState: routeState }), root);
1542
+ }
1543
+ async function resolveRouteState(match, state, currentUrl, routeModPromise, shellModPromise) {
1045
1544
  const routeMod = await (routeModPromise ?? startRouteImport(match));
1046
1545
  if (!routeMod) return null;
1047
1546
  let Shell = null;
@@ -1050,34 +1549,44 @@ async function initClientRouter(options) {
1050
1549
  const DefaultComponent = typeof routeMod.default === "function" ? routeMod.default : void 0;
1051
1550
  const Component = state.error ? routeMod.ErrorBoundary : routeMod.Component ?? DefaultComponent;
1052
1551
  if (!Component) return null;
1053
- const props = state.error ? { error: deserializeRouteError(state.error) } : {
1552
+ const componentProps = state.error ? { error: deserializeRouteError(state.error) } : {
1054
1553
  data: state.data,
1055
1554
  params: match.params
1056
1555
  };
1057
- const componentTree = Shell ? h(Shell, null, h(Component, props)) : h(Component, props);
1058
- return h(NavigateContext.Provider, { value: navigate }, h(PrachtRuntimeProvider, {
1556
+ return {
1557
+ Shell,
1558
+ Component,
1559
+ componentProps,
1059
1560
  data: state.data,
1060
1561
  params: match.params,
1061
1562
  routeId: match.route.id ?? "",
1062
- url: currentUrl
1063
- }, componentTree));
1563
+ url: currentUrl,
1564
+ version: ++routeStateVersion
1565
+ };
1064
1566
  }
1065
- async function buildSpaPendingTree(match, currentUrl, shellModPromise) {
1567
+ async function resolveSpaPendingState(match, currentUrl, shellModPromise) {
1066
1568
  const resolvedShell = await (shellModPromise ?? startShellImport(match));
1067
1569
  if (!resolvedShell) return null;
1068
- const Shell = resolvedShell.Shell;
1570
+ const Shell = resolvedShell.Shell ?? null;
1069
1571
  const Loading = resolvedShell.Loading;
1070
- const componentTree = Shell != null ? h(Shell, null, Loading ? h(Loading, null) : null) : Loading ? h(Loading, null) : null;
1071
- if (!componentTree) return null;
1072
- return h(NavigateContext.Provider, { value: navigate }, h(PrachtRuntimeProvider, {
1572
+ if (!Shell && !Loading) return null;
1573
+ return {
1574
+ Shell,
1575
+ Component: Loading ?? (() => null),
1576
+ componentProps: {},
1073
1577
  data: void 0,
1074
1578
  params: match.params,
1075
1579
  routeId: match.route.id ?? "",
1076
- url: currentUrl
1077
- }, componentTree));
1580
+ url: currentUrl,
1581
+ version: ++routeStateVersion
1582
+ };
1078
1583
  }
1079
1584
  function resolveRedirectTarget(location) {
1080
- const targetUrl = new URL(location, window.location.href);
1585
+ const targetUrl = parseSafeNavigationUrl(location, window.location.href);
1586
+ if (!targetUrl) return {
1587
+ isCurrentLocation: false,
1588
+ unsafe: true
1589
+ };
1081
1590
  const fullInternalTarget = targetUrl.pathname + targetUrl.search + targetUrl.hash;
1082
1591
  const internalPath = targetUrl.pathname + targetUrl.search;
1083
1592
  const currentPath = window.location.pathname + window.location.search + window.location.hash;
@@ -1118,6 +1627,10 @@ async function initClientRouter(options) {
1118
1627
  if (result.type === "redirect") {
1119
1628
  if (result.location) {
1120
1629
  const redirect = resolveRedirectTarget(result.location);
1630
+ if (redirect.unsafe) {
1631
+ console.error(`[pracht] refused to navigate to unsafe URL: ${result.location}`);
1632
+ return;
1633
+ }
1121
1634
  if (redirect.externalUrl) {
1122
1635
  window.location.href = redirect.externalUrl;
1123
1636
  return;
@@ -1131,7 +1644,7 @@ async function initClientRouter(options) {
1131
1644
  await navigate(redirect.internalPath, opts);
1132
1645
  return;
1133
1646
  }
1134
- window.location.href = result.location;
1647
+ window.location.href = target.browserUrl;
1135
1648
  return;
1136
1649
  }
1137
1650
  window.location.href = target.browserUrl;
@@ -1151,21 +1664,12 @@ async function initClientRouter(options) {
1151
1664
  }
1152
1665
  if (!opts?._popstate) if (opts?.replace) history.replaceState(null, "", target.browserUrl);
1153
1666
  else history.pushState(null, "", target.browserUrl);
1154
- const tree = await buildRouteTree(match, state, target.requestUrl, routeModPromise, shellModPromise);
1155
- if (tree) {
1156
- render(tree, root);
1667
+ const routeState = await resolveRouteState(match, state, target.requestUrl, routeModPromise, shellModPromise);
1668
+ if (routeState) {
1669
+ applyRouteState(routeState);
1157
1670
  window.scrollTo(0, 0);
1158
1671
  } else window.location.href = target.browserUrl;
1159
1672
  }
1160
- if (import.meta.env?.DEV) {
1161
- const prev = options.__m;
1162
- options.__m = (vnode, s) => {
1163
- const message = `Hydration mismatch in <${(typeof vnode.type === "function" ? vnode.type.displayName || vnode.type.name : vnode.type) || "Unknown"}>: ${s}`;
1164
- console.warn(`[pracht] ${message}`);
1165
- appendHydrationWarning(message);
1166
- if (prev) prev(vnode, s);
1167
- };
1168
- }
1169
1673
  const initialTarget = resolveBrowserRouteTarget(options.initialState.url);
1170
1674
  const initialRequestUrl = initialTarget?.requestUrl ?? options.initialState.url;
1171
1675
  const initialBrowserUrl = initialTarget?.browserUrl ?? options.initialState.url;
@@ -1177,13 +1681,18 @@ async function initClientRouter(options) {
1177
1681
  error: options.initialState.error ?? null
1178
1682
  };
1179
1683
  if (initialMatch.route.render === "spa" && options.initialState.pending) {
1180
- const dataPromise = fetchPrachtRouteState(initialRequestUrl);
1181
- const pendingTree = await buildSpaPendingTree(initialMatch, initialRequestUrl, initialShellPromise);
1182
- if (pendingTree) hydrate(pendingTree, root);
1684
+ const dataPromise = fetchPrachtRouteState(initialRequestUrl, { useDataParam: true });
1685
+ const pendingState = await resolveSpaPendingState(initialMatch, initialRequestUrl, initialShellPromise);
1686
+ if (pendingState) hydrate(h(RouterRoot, { initialState: pendingState }), root);
1183
1687
  try {
1184
1688
  const result = await dataPromise;
1185
1689
  if (result.type === "redirect") {
1186
- window.location.href = result.location;
1690
+ const safeRedirect = parseSafeNavigationUrl(result.location, window.location.href);
1691
+ if (!safeRedirect) {
1692
+ console.error(`[pracht] refused to navigate to unsafe URL: ${result.location}`);
1693
+ return;
1694
+ }
1695
+ window.location.href = safeRedirect.toString();
1187
1696
  return;
1188
1697
  }
1189
1698
  if (result.type === "error") state = {
@@ -1198,12 +1707,15 @@ async function initClientRouter(options) {
1198
1707
  window.location.href = initialBrowserUrl;
1199
1708
  return;
1200
1709
  }
1201
- }
1202
- const tree = await buildRouteTree(initialMatch, state, initialRequestUrl, void 0, initialShellPromise);
1203
- if (tree) if (initialMatch.route.render === "spa") render(tree, root);
1204
- else {
1205
- markHydrating();
1206
- hydrate(tree, root);
1710
+ const resolvedState = await resolveRouteState(initialMatch, state, initialRequestUrl, void 0, initialShellPromise);
1711
+ if (resolvedState) applyRouteState(resolvedState);
1712
+ } else {
1713
+ const initialRouteState = await resolveRouteState(initialMatch, state, initialRequestUrl, void 0, initialShellPromise);
1714
+ if (initialRouteState) if (initialMatch.route.render === "spa") render(h(RouterRoot, { initialState: initialRouteState }), root);
1715
+ else {
1716
+ markHydrating();
1717
+ hydrate(h(RouterRoot, { initialState: initialRouteState }), root);
1718
+ }
1207
1719
  }
1208
1720
  }
1209
1721
  document.addEventListener("click", (e) => {
@@ -1238,61 +1750,6 @@ async function initClientRouter(options) {
1238
1750
  };
1239
1751
  setupPrefetching(app, warmModules);
1240
1752
  }
1241
- const HYDRATION_BANNER_ID = "__pracht_hydration_warnings__";
1242
- function appendHydrationWarning(message) {
1243
- let container = document.getElementById(HYDRATION_BANNER_ID);
1244
- if (!container) {
1245
- container = document.createElement("div");
1246
- container.id = HYDRATION_BANNER_ID;
1247
- Object.assign(container.style, {
1248
- position: "fixed",
1249
- bottom: "0",
1250
- left: "0",
1251
- right: "0",
1252
- maxHeight: "30vh",
1253
- overflow: "auto",
1254
- background: "#2d1b00",
1255
- borderTop: "2px solid #f0ad4e",
1256
- color: "#ffc107",
1257
- fontFamily: "ui-monospace, Consolas, monospace",
1258
- fontSize: "13px",
1259
- padding: "12px 16px",
1260
- zIndex: "2147483647"
1261
- });
1262
- const header = document.createElement("div");
1263
- Object.assign(header.style, {
1264
- display: "flex",
1265
- justifyContent: "space-between",
1266
- alignItems: "center",
1267
- marginBottom: "8px"
1268
- });
1269
- header.innerHTML = "<strong style=\"color:#f0ad4e\">⚠ Hydration Mismatches</strong>";
1270
- const close = document.createElement("button");
1271
- close.textContent = "×";
1272
- Object.assign(close.style, {
1273
- background: "none",
1274
- border: "none",
1275
- color: "#f0ad4e",
1276
- fontSize: "18px",
1277
- cursor: "pointer"
1278
- });
1279
- close.onclick = () => container.remove();
1280
- header.appendChild(close);
1281
- container.appendChild(header);
1282
- document.body.appendChild(container);
1283
- }
1284
- const entry = document.createElement("div");
1285
- entry.textContent = message;
1286
- Object.assign(entry.style, { padding: "2px 0" });
1287
- container.appendChild(entry);
1288
- }
1289
- function deserializeRouteError(error) {
1290
- const result = new Error(error.message);
1291
- result.name = error.name;
1292
- result.status = error.status;
1293
- result.diagnostics = error.diagnostics;
1294
- return result;
1295
- }
1296
1753
  function resolveBrowserRouteTarget(to) {
1297
1754
  if (typeof window === "undefined") return null;
1298
1755
  try {
@@ -1318,4 +1775,4 @@ var PrachtHttpError = class extends Error {
1318
1775
  }
1319
1776
  };
1320
1777
  //#endregion
1321
- 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, useRevalidateRoute, useRouteData };
1778
+ 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 };