@pracht/core 0.2.7 → 0.4.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
@@ -172,14 +172,26 @@ function buildPathFromSegments(segments, params) {
172
172
  return normalizeRoutePath("/" + segments.map((segment) => {
173
173
  if (segment.type === "static") return segment.value;
174
174
  if (segment.type === "param") return encodeURIComponent(params[segment.name] ?? "");
175
- return params["*"] ?? "";
175
+ return (params["*"] ?? "").split("/").map((part) => encodeCatchAllSegment(part)).join("/");
176
176
  }).join("/"));
177
177
  }
178
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
+ /**
179
190
  * Convert a list of file paths from `import.meta.glob` into resolved API routes.
180
191
  *
181
192
  * Example: `"/src/api/health.ts"` → path `/api/health`
182
193
  * `"/src/api/users/[id].ts"` → path `/api/users/:id`
194
+ * `"/src/api/files/[...path].ts"` → path `/api/files/*`
183
195
  * `"/src/api/index.ts"` → path `/api`
184
196
  */
185
197
  function resolveApiRoutes(files, apiDir = "/src/api") {
@@ -189,6 +201,7 @@ function resolveApiRoutes(files, apiDir = "/src/api") {
189
201
  if (relative.startsWith(normalizedDir)) relative = relative.slice(normalizedDir.length);
190
202
  relative = relative.replace(/\.(ts|tsx|js|jsx)$/, "");
191
203
  if (relative.endsWith("/index")) relative = relative.slice(0, -6) || "/";
204
+ relative = relative.replace(/\[\.\.\.[^\]]+\]/g, "*");
192
205
  relative = relative.replace(/\[([^\]]+)\]/g, ":$1");
193
206
  const path = normalizeRoutePath(`/api${relative}`);
194
207
  return {
@@ -309,18 +322,257 @@ function useIsHydrated() {
309
322
  return hydrated;
310
323
  }
311
324
  //#endregion
312
- //#region src/runtime.ts
325
+ //#region src/runtime-constants.ts
313
326
  const SAFE_METHODS = new Set(["GET", "HEAD"]);
314
327
  const HYDRATION_STATE_ELEMENT_ID = "pracht-state";
315
328
  const ROUTE_STATE_REQUEST_HEADER = "x-pracht-route-state-request";
316
329
  const ROUTE_STATE_CACHE_CONTROL = "no-store";
317
330
  const EMPTY_ROUTE_PARAMS = {};
318
- let _renderToStringAsync;
319
- async function getRenderToStringAsync() {
320
- if (_renderToStringAsync) return _renderToStringAsync;
321
- _renderToStringAsync = (await import("preact-render-to-string")).renderToStringAsync;
322
- return _renderToStringAsync;
331
+ //#endregion
332
+ //#region src/runtime-errors.ts
333
+ function isPrachtHttpError(error) {
334
+ return error instanceof Error && error.name === "PrachtHttpError" && "status" in error;
335
+ }
336
+ let warnedAboutProductionDebugErrors = false;
337
+ /**
338
+ * `debugErrors: true` opts into surfacing stack traces, module paths,
339
+ * and middleware names in error responses. That is great in dev and
340
+ * dangerous in production — a misconfigured deploy would leak internals
341
+ * to the public. When `NODE_ENV === "production"` we refuse to honor
342
+ * the flag and emit a single console warning so the misconfiguration
343
+ * is visible in logs.
344
+ */
345
+ function shouldExposeServerErrors(options) {
346
+ if (options.debugErrors !== true) return false;
347
+ if ((typeof process !== "undefined" && process.env ? process.env.NODE_ENV : typeof globalThis !== "undefined" && globalThis.process ? globalThis.process?.env?.NODE_ENV : void 0) === "production") {
348
+ if (!warnedAboutProductionDebugErrors) {
349
+ warnedAboutProductionDebugErrors = true;
350
+ console.warn("[pracht] debugErrors is ignored in production builds. Remove it to silence this warning.");
351
+ }
352
+ return false;
353
+ }
354
+ return true;
355
+ }
356
+ function createSerializedRouteError(message, status, options = {}) {
357
+ return {
358
+ message,
359
+ name: options.name ?? "Error",
360
+ status,
361
+ ...options.diagnostics ? { diagnostics: options.diagnostics } : {}
362
+ };
363
+ }
364
+ function buildRuntimeDiagnostics(options) {
365
+ const route = options.route;
366
+ const routeId = route && "id" in route ? route.id : void 0;
367
+ return {
368
+ phase: options.phase,
369
+ routeId,
370
+ routePath: route?.path,
371
+ routeFile: route?.file,
372
+ loaderFile: options.loaderFile,
373
+ shellFile: options.shellFile,
374
+ middlewareFiles: options.middlewareFiles ? [...options.middlewareFiles] : [],
375
+ status: options.status
376
+ };
377
+ }
378
+ function normalizeRouteError(error, options) {
379
+ if (isPrachtHttpError(error)) {
380
+ const status = typeof error.status === "number" ? error.status : 500;
381
+ if (status >= 400 && status < 500) return {
382
+ message: error.message,
383
+ name: error.name,
384
+ status
385
+ };
386
+ if (options.exposeDetails) return {
387
+ message: error.message || "Internal Server Error",
388
+ name: error.name || "Error",
389
+ status
390
+ };
391
+ return {
392
+ message: "Internal Server Error",
393
+ name: "Error",
394
+ status
395
+ };
396
+ }
397
+ if (error instanceof Error) {
398
+ if (options.exposeDetails) return {
399
+ message: error.message || "Internal Server Error",
400
+ name: error.name || "Error",
401
+ status: 500
402
+ };
403
+ return {
404
+ message: "Internal Server Error",
405
+ name: "Error",
406
+ status: 500
407
+ };
408
+ }
409
+ if (options.exposeDetails) return {
410
+ message: typeof error === "string" && error ? error : "Internal Server Error",
411
+ name: "Error",
412
+ status: 500
413
+ };
414
+ return {
415
+ message: "Internal Server Error",
416
+ name: "Error",
417
+ status: 500
418
+ };
419
+ }
420
+ function deserializeRouteError(error) {
421
+ const result = new Error(error.message);
422
+ result.name = error.name;
423
+ result.status = error.status;
424
+ result.diagnostics = error.diagnostics;
425
+ return result;
426
+ }
427
+ //#endregion
428
+ //#region src/runtime-headers.ts
429
+ const HEADER_CRLF_RE = /[\r\n]/;
430
+ /**
431
+ * Reject header values containing CR/LF. Some runtimes (Node `undici`
432
+ * Headers) throw on their own, but Web-runtime fetch implementations
433
+ * vary, and a user-supplied `headers()` value is never trusted input.
434
+ * Keeping the check here means response-splitting can't slip through on
435
+ * any adapter.
436
+ */
437
+ function assertSafeHeaderValue(name, value) {
438
+ if (HEADER_CRLF_RE.test(value)) throw new Error(`Refused to set header "${name}": value contains CR or LF`);
439
+ }
440
+ function applyHeaders(headers, init) {
441
+ for (const [key, value] of iterateHeaderInit(init)) assertSafeHeaderValue(key, value);
442
+ new Headers(init).forEach((value, key) => {
443
+ headers.set(key, value);
444
+ });
445
+ }
446
+ function* iterateHeaderInit(init) {
447
+ if (init instanceof Headers) {
448
+ for (const entry of init.entries()) yield entry;
449
+ return;
450
+ }
451
+ if (Array.isArray(init)) {
452
+ for (const entry of init) if (entry && entry.length >= 2) yield [entry[0], entry[1]];
453
+ return;
454
+ }
455
+ for (const [key, value] of Object.entries(init)) yield [key, value];
456
+ }
457
+ function applyDefaultSecurityHeaders(headers) {
458
+ if (!headers.has("permissions-policy")) headers.set("permissions-policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()");
459
+ if (!headers.has("referrer-policy")) headers.set("referrer-policy", "strict-origin-when-cross-origin");
460
+ if (!headers.has("x-content-type-options")) headers.set("x-content-type-options", "nosniff");
461
+ if (!headers.has("x-frame-options")) headers.set("x-frame-options", "SAMEORIGIN");
462
+ return headers;
463
+ }
464
+ function applySecurityAndRouteHeaders(headers, options) {
465
+ applyDefaultSecurityHeaders(headers);
466
+ if (options) {
467
+ appendVaryHeader(headers, ROUTE_STATE_REQUEST_HEADER);
468
+ if (options.isRouteStateRequest && !headers.has("cache-control")) headers.set("cache-control", ROUTE_STATE_CACHE_CONTROL);
469
+ }
470
+ return headers;
471
+ }
472
+ function withDefaultSecurityHeaders(response) {
473
+ const headers = new Headers(response.headers);
474
+ applySecurityAndRouteHeaders(headers);
475
+ return new Response(response.body, {
476
+ status: response.status,
477
+ statusText: response.statusText,
478
+ headers
479
+ });
480
+ }
481
+ function withRouteResponseHeaders(response, options) {
482
+ const headers = new Headers(response.headers);
483
+ applySecurityAndRouteHeaders(headers, options);
484
+ return new Response(response.body, {
485
+ status: response.status,
486
+ statusText: response.statusText,
487
+ headers
488
+ });
489
+ }
490
+ function appendVaryHeader(headers, value) {
491
+ const current = headers.get("vary");
492
+ if (!current) {
493
+ headers.set("vary", value);
494
+ return;
495
+ }
496
+ const values = current.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
497
+ if (values.includes("*") || values.includes(value.toLowerCase())) return;
498
+ headers.set("vary", `${current}, ${value}`);
499
+ }
500
+ //#endregion
501
+ //#region src/runtime-client-fetch.ts
502
+ const SAFE_NAVIGATION_PROTOCOLS = new Set(["http:", "https:"]);
503
+ /**
504
+ * Parse a possibly-server-supplied redirect target against a base URL and
505
+ * return it only if it uses a safe navigation scheme (`http:` or `https:`).
506
+ *
507
+ * `javascript:`, `data:`, `vbscript:`, `blob:`, `file:` and similar schemes
508
+ * can execute script or bypass same-origin assumptions when assigned to
509
+ * `window.location.href` — a server-controlled redirect (from a loader,
510
+ * middleware, form action response, or API route) must never be able to
511
+ * trigger them. Returns `null` for unsafe or unparseable inputs.
512
+ */
513
+ function parseSafeNavigationUrl(location, base) {
514
+ let targetUrl;
515
+ try {
516
+ targetUrl = new URL(location, base);
517
+ } catch {
518
+ return null;
519
+ }
520
+ if (!SAFE_NAVIGATION_PROTOCOLS.has(targetUrl.protocol)) return null;
521
+ return targetUrl;
522
+ }
523
+ function buildRouteStateUrl(url) {
524
+ return `${url}${url.includes("?") ? "&" : "?"}_data=1`;
525
+ }
526
+ async function fetchPrachtRouteState(url, options) {
527
+ const fetchUrl = options?.useDataParam ? buildRouteStateUrl(url) : url;
528
+ const response = await fetch(fetchUrl, {
529
+ headers: options?.useDataParam ? {} : {
530
+ [ROUTE_STATE_REQUEST_HEADER]: "1",
531
+ "Cache-Control": "no-cache"
532
+ },
533
+ redirect: "manual"
534
+ });
535
+ if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) return {
536
+ location: response.headers.get("location") ?? url,
537
+ type: "redirect"
538
+ };
539
+ const json = await response.json();
540
+ if (json.redirect) return {
541
+ location: json.redirect,
542
+ type: "redirect"
543
+ };
544
+ if (!response.ok) {
545
+ if (json.error) return {
546
+ error: json.error,
547
+ type: "error"
548
+ };
549
+ throw new Error(`Failed to fetch route state (${response.status})`);
550
+ }
551
+ return {
552
+ data: json.data,
553
+ type: "data"
554
+ };
555
+ }
556
+ async function navigateToClientLocation(location, options) {
557
+ if (typeof window === "undefined") return;
558
+ const targetUrl = parseSafeNavigationUrl(location, window.location.href);
559
+ if (!targetUrl) {
560
+ console.error(`[pracht] refused to navigate to unsafe URL: ${location}`);
561
+ return;
562
+ }
563
+ const target = targetUrl.pathname + targetUrl.search + targetUrl.hash;
564
+ if (targetUrl.origin === window.location.origin && window.__PRACHT_NAVIGATE__) {
565
+ await window.__PRACHT_NAVIGATE__(target, options);
566
+ return;
567
+ }
568
+ if (options?.replace) {
569
+ window.location.replace(targetUrl.toString());
570
+ return;
571
+ }
572
+ window.location.href = targetUrl.toString();
323
573
  }
574
+ //#endregion
575
+ //#region src/runtime-hooks.ts
324
576
  const RouteDataContext = createContext(void 0);
325
577
  function PrachtRuntimeProvider({ children, data, params = EMPTY_ROUTE_PARAMS, routeId, stateVersion = 0, url }) {
326
578
  const [routeDataState, setRouteDataState] = useState({
@@ -428,251 +680,60 @@ function Form(props) {
428
680
  }
429
681
  });
430
682
  }
431
- async function fetchPrachtRouteState(url, options) {
432
- const fetchUrl = options?.useDataParam ? buildRouteStateUrl(url) : url;
433
- const response = await fetch(fetchUrl, {
434
- headers: options?.useDataParam ? {} : {
435
- [ROUTE_STATE_REQUEST_HEADER]: "1",
436
- "Cache-Control": "no-cache"
437
- },
438
- redirect: "manual"
439
- });
440
- if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) return {
441
- location: response.headers.get("location") ?? url,
442
- type: "redirect"
443
- };
444
- const json = await response.json();
445
- if (json.redirect) return {
446
- location: json.redirect,
447
- type: "redirect"
448
- };
449
- if (!response.ok) {
450
- if (json.error) return {
451
- error: json.error,
452
- type: "error"
453
- };
454
- throw new Error(`Failed to fetch route state (${response.status})`);
455
- }
683
+ function parseLocation(value) {
684
+ const url = new URL(value, "http://pracht.local");
456
685
  return {
457
- data: json.data,
458
- type: "data"
686
+ pathname: url.pathname,
687
+ search: url.search
459
688
  };
460
689
  }
461
- function buildRouteStateUrl(url) {
462
- return `${url}${url.includes("?") ? "&" : "?"}_data=1`;
690
+ //#endregion
691
+ //#region src/runtime-html.ts
692
+ function escapeHtml(str) {
693
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
463
694
  }
464
- async function handlePrachtRequest(options) {
465
- const url = new URL(options.request.url);
466
- const hasDataParam = url.searchParams.get("_data") === "1";
467
- if (hasDataParam) url.searchParams.delete("_data");
468
- const requestPath = getRequestPath(url);
469
- const registry = options.registry ?? {};
470
- const isRouteStateRequest = options.request.headers.get(ROUTE_STATE_REQUEST_HEADER) === "1" || hasDataParam;
471
- const exposeDiagnostics = shouldExposeServerErrors(options);
472
- if (options.apiRoutes?.length) {
473
- const apiMatch = matchApiRoute(options.apiRoutes, url.pathname);
474
- if (apiMatch) {
475
- const apiMiddlewareFiles = (options.app.api.middleware ?? []).flatMap((name) => {
476
- const middlewareFile = options.app.middleware[name];
477
- return middlewareFile ? [middlewareFile] : [];
478
- });
479
- let currentPhase = "middleware";
480
- try {
481
- const middlewareResult = await runMiddlewareChain({
482
- context: options.context ?? {},
483
- middlewareFiles: apiMiddlewareFiles,
484
- params: apiMatch.params,
485
- registry,
486
- request: options.request,
487
- route: apiMatch.route,
488
- url
489
- });
490
- if (middlewareResult.response) return middlewareResult.response;
491
- currentPhase = "api";
492
- const apiModule = await resolveRegistryModule(registry.apiModules, apiMatch.route.file);
493
- if (!apiModule) throw new Error("API route module not found");
494
- const handler = apiModule[options.request.method.toUpperCase()] ?? apiModule.default;
495
- if (!handler) return withDefaultSecurityHeaders(new Response("Method not allowed", {
496
- status: 405,
497
- headers: { "content-type": "text/plain; charset=utf-8" }
498
- }));
499
- return withDefaultSecurityHeaders(await handler({
500
- request: options.request,
501
- params: apiMatch.params,
502
- context: middlewareResult.context,
503
- signal: AbortSignal.timeout(3e4),
504
- url,
505
- route: apiMatch.route
506
- }));
507
- } catch (error) {
508
- return renderApiErrorResponse({
509
- error,
510
- middlewareFiles: apiMiddlewareFiles,
511
- options,
512
- phase: currentPhase,
513
- route: apiMatch.route
514
- });
515
- }
516
- }
517
- }
518
- const match = matchAppRoute(options.app, url.pathname);
519
- if (!match) {
520
- if (isRouteStateRequest) return jsonErrorResponse(createSerializedRouteError("Not found", 404, {
521
- diagnostics: exposeDiagnostics ? buildRuntimeDiagnostics({
522
- phase: "match",
523
- status: 404
524
- }) : void 0,
525
- name: "Error"
526
- }), { isRouteStateRequest: true });
527
- return withDefaultSecurityHeaders(new Response("Not found", {
528
- status: 404,
529
- headers: { "content-type": "text/plain; charset=utf-8" }
530
- }));
531
- }
532
- if (!SAFE_METHODS.has(options.request.method)) {
533
- if (isRouteStateRequest) return jsonErrorResponse(createSerializedRouteError("Method not allowed", 405, {
534
- diagnostics: exposeDiagnostics ? buildRuntimeDiagnostics({
535
- middlewareFiles: match.route.middlewareFiles,
536
- phase: "action",
537
- route: match.route,
538
- shellFile: match.route.shellFile,
539
- status: 405
540
- }) : void 0,
541
- name: "Error"
542
- }), { isRouteStateRequest: true });
543
- return withRouteResponseHeaders(new Response("Method not allowed", {
544
- status: 405,
545
- headers: { "content-type": "text/plain; charset=utf-8" }
546
- }), { isRouteStateRequest });
547
- }
548
- let routeArgs = {
549
- request: options.request,
550
- params: match.params,
551
- context: options.context ?? {},
552
- signal: AbortSignal.timeout(3e4),
553
- url,
554
- route: match.route
555
- };
556
- let routeModule;
557
- let shellModule;
558
- let loaderFile;
559
- let currentPhase = "middleware";
560
- try {
561
- const middlewarePromise = runMiddlewareChain({
562
- context: routeArgs.context,
563
- middlewareFiles: match.route.middlewareFiles,
564
- params: match.params,
565
- registry,
566
- request: options.request,
567
- route: match.route,
568
- url
569
- });
570
- const routeModulePromise = resolveRegistryModule(registry.routeModules, match.route.file);
571
- const shellModulePromise = match.route.shellFile ? resolveRegistryModule(registry.shellModules, match.route.shellFile) : Promise.resolve(void 0);
572
- const dataFunctionsPromise = routeModulePromise.then((mod) => resolveDataFunctions(match.route, mod, registry));
573
- routeModulePromise.catch(() => {});
574
- shellModulePromise.catch(() => {});
575
- dataFunctionsPromise.catch(() => {});
576
- const middlewareResult = await middlewarePromise;
577
- if (middlewareResult.response) return normalizePageResponse(middlewareResult.response, { isRouteStateRequest });
578
- routeArgs = {
579
- ...routeArgs,
580
- context: middlewareResult.context
581
- };
582
- currentPhase = "render";
583
- routeModule = await routeModulePromise;
584
- if (!routeModule) throw new Error("Route module not found");
585
- currentPhase = "loader";
586
- const { loader, loaderFile: resolvedLoaderFile } = await dataFunctionsPromise;
587
- loaderFile = resolvedLoaderFile;
588
- const loaderResult = loader ? await loader(routeArgs) : void 0;
589
- if (loaderResult instanceof Response) return normalizePageResponse(loaderResult, { isRouteStateRequest });
590
- const data = loaderResult;
591
- if (isRouteStateRequest) return withRouteResponseHeaders(Response.json({ data }), { isRouteStateRequest: true });
592
- currentPhase = "render";
593
- shellModule = await shellModulePromise;
594
- const [head, documentHeaders] = await Promise.all([mergeHeadMetadata(shellModule, routeModule, routeArgs, data), mergeDocumentHeaders(shellModule, routeModule, routeArgs, data)]);
595
- const cssUrls = resolvePageCssUrls(options, match.route.shellFile, match.route.file);
596
- const modulePreloadUrls = resolvePageJsUrls(options, match.route.shellFile, match.route.file);
597
- if (match.route.render === "spa") {
598
- let body = "";
599
- if (shellModule?.Shell || shellModule?.Loading) {
600
- const Shell = shellModule?.Shell;
601
- const Loading = shellModule?.Loading;
602
- const loadingTree = Shell != null ? h(Shell, null, Loading ? h(Loading, null) : null) : Loading ? h(Loading, null) : null;
603
- if (loadingTree) body = await (await getRenderToStringAsync())(loadingTree);
604
- }
605
- return htmlResponse(buildHtmlDocument({
606
- head,
607
- body,
608
- hydrationState: {
609
- url: requestPath,
610
- routeId: match.route.id ?? "",
611
- data: null,
612
- error: null,
613
- pending: true
614
- },
615
- clientEntryUrl: options.clientEntryUrl,
616
- cssUrls,
617
- modulePreloadUrls,
618
- routeStatePreloadUrl: loader ? buildRouteStateUrl(requestPath) : void 0
619
- }), 200, documentHeaders);
620
- }
621
- const DefaultComponent = typeof routeModule.default === "function" ? routeModule.default : void 0;
622
- const Component = routeModule.Component ?? DefaultComponent;
623
- if (!Component) throw new Error("Route has no Component or default export");
624
- const Shell = shellModule?.Shell;
625
- const Comp = Component;
626
- const componentProps = {
627
- data,
628
- params: match.params
629
- };
630
- const componentTree = Shell ? h(Shell, null, h(Comp, componentProps)) : h(Comp, componentProps);
631
- const tree = h(PrachtRuntimeProvider, {
632
- data,
633
- params: match.params,
634
- routeId: match.route.id ?? "",
635
- url: requestPath
636
- }, componentTree);
637
- return htmlResponse(buildHtmlDocument({
638
- head,
639
- body: await (await getRenderToStringAsync())(tree),
640
- hydrationState: {
641
- url: requestPath,
642
- routeId: match.route.id ?? "",
643
- data,
644
- error: null
645
- },
646
- clientEntryUrl: options.clientEntryUrl,
647
- cssUrls,
648
- modulePreloadUrls
649
- }), 200, documentHeaders);
650
- } catch (error) {
651
- return renderRouteErrorResponse({
652
- error,
653
- isRouteStateRequest,
654
- loaderFile,
655
- options,
656
- phase: currentPhase,
657
- routeArgs,
658
- routeId: match.route.id ?? "",
659
- routeModule,
660
- shellFile: match.route.shellFile,
661
- shellModule,
662
- requestPath
663
- });
664
- }
695
+ function serializeJsonForHtml(value) {
696
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
665
697
  }
666
- function parseLocation(value) {
667
- const url = new URL(value, "http://pracht.local");
668
- return {
669
- pathname: url.pathname,
670
- search: url.search
671
- };
698
+ function buildHtmlDocument(options) {
699
+ const { head, body, hydrationState, clientEntryUrl, cssUrls = [], modulePreloadUrls = [], routeStatePreloadUrl } = options;
700
+ const titleTag = head.title ? `<title>${escapeHtml(head.title)}</title>` : "";
701
+ const metaTags = (head.meta ?? []).map((m) => `<meta ${Object.entries(m).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ")}>`).join("\n ");
702
+ const linkTags = (head.link ?? []).map((l) => `<link ${Object.entries(l).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ")}>`).join("\n ");
703
+ const cssTags = cssUrls.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join("\n ");
704
+ const modulePreloadTags = modulePreloadUrls.map((url) => `<link rel="modulepreload" href="${escapeHtml(url)}">`).join("\n ");
705
+ const routeStatePreloadTag = routeStatePreloadUrl ? `<link rel="preload" as="fetch" href="${escapeHtml(routeStatePreloadUrl)}" crossorigin="anonymous">` : "";
706
+ const stateScript = `<script id="${HYDRATION_STATE_ELEMENT_ID}" type="application/json">${serializeJsonForHtml(hydrationState)}<\/script>`;
707
+ const entryScript = clientEntryUrl ? `<script type="module" src="${escapeHtml(clientEntryUrl)}"><\/script>` : "";
708
+ return `<!DOCTYPE html>
709
+ <html${head.lang ? ` lang="${escapeHtml(head.lang)}"` : ""}>
710
+ <head>
711
+ <meta charset="utf-8">
712
+ ${titleTag}
713
+ ${metaTags}
714
+ ${linkTags}
715
+ ${cssTags}
716
+ ${modulePreloadTags}
717
+ ${routeStatePreloadTag}
718
+ </head>
719
+ <body>
720
+ <div id="pracht-root">${body}</div>
721
+ ${stateScript}
722
+ ${entryScript}
723
+ </body>
724
+ </html>`;
672
725
  }
673
- function getRequestPath(url) {
674
- return `${url.pathname}${url.search}`;
726
+ function htmlResponse(html, status = 200, initHeaders) {
727
+ const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
728
+ if (initHeaders) applyHeaders(headers, initHeaders);
729
+ applySecurityAndRouteHeaders(headers, { isRouteStateRequest: false });
730
+ return new Response(html, {
731
+ status,
732
+ headers
733
+ });
675
734
  }
735
+ //#endregion
736
+ //#region src/runtime-manifest.ts
676
737
  /** Strip leading `./` and `/` so all module paths share one canonical form. */
677
738
  function normalizeModulePath(path) {
678
739
  return path.replace(/^\.?\//, "");
@@ -713,104 +774,122 @@ function resolvePageUrlsFromManifest(manifest, shellFile, routeFile) {
713
774
  add(routeFile);
714
775
  return [...urls];
715
776
  }
716
- function resolvePageCssUrls(options, shellFile, routeFile) {
717
- if (!options.cssManifest) return [];
718
- return resolvePageUrlsFromManifest(options.cssManifest, shellFile, routeFile);
777
+ function resolvePageCssUrls(cssManifest, shellFile, routeFile) {
778
+ if (!cssManifest) return [];
779
+ return resolvePageUrlsFromManifest(cssManifest, shellFile, routeFile);
719
780
  }
720
- function resolvePageJsUrls(options, shellFile, routeFile) {
721
- if (!options.jsManifest) return [];
722
- return resolvePageUrlsFromManifest(options.jsManifest, shellFile, routeFile);
781
+ function resolvePageJsUrls(jsManifest, shellFile, routeFile) {
782
+ if (!jsManifest) return [];
783
+ return resolvePageUrlsFromManifest(jsManifest, shellFile, routeFile);
723
784
  }
724
- async function navigateToClientLocation(location, options) {
725
- if (typeof window === "undefined") return;
726
- const targetUrl = new URL(location, window.location.href);
727
- const target = targetUrl.pathname + targetUrl.search + targetUrl.hash;
728
- if (targetUrl.origin === window.location.origin && window.__PRACHT_NAVIGATE__) {
729
- await window.__PRACHT_NAVIGATE__(target, options);
730
- return;
731
- }
732
- if (options?.replace) {
733
- window.location.replace(targetUrl.toString());
734
- return;
735
- }
736
- window.location.href = targetUrl.toString();
737
- }
738
- function isPrachtHttpError(error) {
739
- return error instanceof Error && error.name === "PrachtHttpError" && "status" in error;
740
- }
741
- function shouldExposeServerErrors(options) {
742
- return options.debugErrors === true;
785
+ async function resolveRegistryModule(modules, file) {
786
+ if (!modules) return void 0;
787
+ if (file in modules) return modules[file]();
788
+ const resolved = getSuffixIndex(modules).get(normalizeModulePath(file));
789
+ if (resolved) return modules[resolved]();
743
790
  }
744
- function createSerializedRouteError(message, status, options = {}) {
791
+ async function resolveDataFunctions(route, routeModule, registry) {
792
+ let loader = routeModule?.loader;
793
+ let loaderFile = routeModule?.loader ? route.file : void 0;
794
+ if (route.loaderFile) {
795
+ const dataModule = await resolveRegistryModule(registry.dataModules, route.loaderFile);
796
+ if (dataModule?.loader) {
797
+ loader = dataModule.loader;
798
+ loaderFile = route.loaderFile;
799
+ }
800
+ }
745
801
  return {
746
- message,
747
- name: options.name ?? "Error",
748
- status,
749
- ...options.diagnostics ? { diagnostics: options.diagnostics } : {}
802
+ loader,
803
+ loaderFile
750
804
  };
751
805
  }
752
- function buildRuntimeDiagnostics(options) {
753
- const route = options.route;
754
- const routeId = route && "id" in route ? route.id : void 0;
755
- return {
756
- phase: options.phase,
757
- routeId,
758
- routePath: route?.path,
759
- routeFile: route?.file,
760
- loaderFile: options.loaderFile,
761
- shellFile: options.shellFile,
762
- middlewareFiles: options.middlewareFiles ? [...options.middlewareFiles] : [],
763
- status: options.status
764
- };
806
+ //#endregion
807
+ //#region src/runtime-middleware.ts
808
+ const DEFAULT_REDIRECT_STATUS_SAFE = 302;
809
+ const DEFAULT_REDIRECT_STATUS_UNSAFE = 303;
810
+ /**
811
+ * Build a safe redirect response from middleware/loader output. Rejects
812
+ * non-http(s) schemes (no `javascript:`/`data:`/etc.) and CR/LF injection
813
+ * against the `Location` header. When status is omitted, non-GET/HEAD
814
+ * requests default to 303 so the browser does not resend the body to the
815
+ * redirect target; safe methods default to 302.
816
+ *
817
+ * The original `target` string is preserved on success (relative paths
818
+ * stay relative) — we only parse it to validate scheme, not to rewrite
819
+ * it. Both the original input and its resolved URL must be CR/LF-free.
820
+ */
821
+ function buildRedirectResponse(target, options) {
822
+ if (/[\r\n]/.test(target)) throw new Error("Refused redirect target containing CR/LF");
823
+ if (!parseSafeNavigationUrl(target, options.baseUrl)) throw new Error("Refused unsafe redirect target");
824
+ const method = (options.method ?? "GET").toUpperCase();
825
+ const defaultStatus = SAFE_METHODS.has(method) ? DEFAULT_REDIRECT_STATUS_SAFE : DEFAULT_REDIRECT_STATUS_UNSAFE;
826
+ const status = options.status ?? defaultStatus;
827
+ return new Response(null, {
828
+ status,
829
+ headers: { location: target }
830
+ });
765
831
  }
766
- function normalizeRouteError(error, options) {
767
- if (isPrachtHttpError(error)) {
768
- const status = typeof error.status === "number" ? error.status : 500;
769
- if (status >= 400 && status < 500) return {
770
- message: error.message,
771
- name: error.name,
772
- status
773
- };
774
- if (options.exposeDetails) return {
775
- message: error.message || "Internal Server Error",
776
- name: error.name || "Error",
777
- status
778
- };
779
- return {
780
- message: "Internal Server Error",
781
- name: "Error",
782
- status
783
- };
784
- }
785
- if (error instanceof Error) {
786
- if (options.exposeDetails) return {
787
- message: error.message || "Internal Server Error",
788
- name: error.name || "Error",
789
- status: 500
790
- };
791
- return {
792
- message: "Internal Server Error",
793
- name: "Error",
794
- status: 500
832
+ async function runMiddlewareChain(options) {
833
+ let context = options.context;
834
+ const modulePromises = options.middlewareFiles.map((mwFile) => resolveRegistryModule(options.registry.middlewareModules, mwFile));
835
+ for (const p of modulePromises) p.catch(() => {});
836
+ for (const modulePromise of modulePromises) {
837
+ const mwModule = await modulePromise;
838
+ if (!mwModule?.middleware) continue;
839
+ const result = await mwModule.middleware({
840
+ request: options.request,
841
+ params: options.params,
842
+ context,
843
+ signal: AbortSignal.timeout(3e4),
844
+ url: options.url,
845
+ route: options.route
846
+ });
847
+ if (!result) continue;
848
+ if (result instanceof Response) return { response: withDefaultSecurityHeaders(result) };
849
+ if ("redirect" in result) {
850
+ const status = "status" in result ? result.status : void 0;
851
+ return { response: withDefaultSecurityHeaders(buildRedirectResponse(result.redirect, {
852
+ baseUrl: options.request.url,
853
+ method: options.request.method,
854
+ status
855
+ })) };
856
+ }
857
+ if ("context" in result) context = {
858
+ ...context,
859
+ ...result.context
795
860
  };
796
861
  }
797
- if (options.exposeDetails) return {
798
- message: typeof error === "string" && error ? error : "Internal Server Error",
799
- name: "Error",
800
- status: 500
801
- };
862
+ return { context };
863
+ }
864
+ async function mergeHeadMetadata(shellModule, routeModule, routeArgs, data) {
865
+ const [shellHead, routeHead] = await Promise.all([shellModule?.head ? shellModule.head(routeArgs) : Promise.resolve({}), routeModule?.head ? routeModule.head({
866
+ ...routeArgs,
867
+ data
868
+ }) : Promise.resolve({})]);
802
869
  return {
803
- message: "Internal Server Error",
804
- name: "Error",
805
- status: 500
870
+ title: routeHead.title ?? shellHead.title,
871
+ lang: routeHead.lang ?? shellHead.lang,
872
+ meta: [...shellHead.meta ?? [], ...routeHead.meta ?? []],
873
+ link: [...shellHead.link ?? [], ...routeHead.link ?? []]
806
874
  };
807
875
  }
808
- function deserializeRouteError(error) {
809
- const result = new Error(error.message);
810
- result.name = error.name;
811
- result.status = error.status;
812
- result.diagnostics = error.diagnostics;
813
- return result;
876
+ async function mergeDocumentHeaders(shellModule, routeModule, routeArgs, data) {
877
+ const headers = new Headers();
878
+ const [shellHeaders, routeHeaders] = await Promise.all([shellModule?.headers ? shellModule.headers(routeArgs) : Promise.resolve(void 0), routeModule?.headers ? routeModule.headers({
879
+ ...routeArgs,
880
+ data
881
+ }) : Promise.resolve(void 0)]);
882
+ if (shellHeaders) applyHeaders(headers, shellHeaders);
883
+ if (routeHeaders) applyHeaders(headers, routeHeaders);
884
+ return headers;
885
+ }
886
+ //#endregion
887
+ //#region src/runtime-response.ts
888
+ let _renderToStringAsync;
889
+ async function getRenderToStringAsync() {
890
+ if (_renderToStringAsync) return _renderToStringAsync;
891
+ _renderToStringAsync = (await import("preact-render-to-string")).renderToStringAsync;
892
+ return _renderToStringAsync;
814
893
  }
815
894
  function jsonErrorResponse(routeError, options) {
816
895
  const headers = applySecurityAndRouteHeaders(new Headers({ "content-type": "application/json; charset=utf-8" }), options.isRouteStateRequest ? { isRouteStateRequest: true } : void 0);
@@ -882,8 +961,8 @@ async function renderRouteErrorResponse(options) {
882
961
  const shellModule = options.shellModule ?? (options.shellFile ? await resolveRegistryModule(options.options.registry?.shellModules, options.shellFile) : void 0);
883
962
  const head = shellModule?.head ? await shellModule.head(options.routeArgs) : {};
884
963
  const documentHeaders = await mergeDocumentHeaders(shellModule, void 0, options.routeArgs, void 0);
885
- const cssUrls = resolvePageCssUrls(options.options, options.shellFile, options.routeArgs.route.file);
886
- const modulePreloadUrls = resolvePageJsUrls(options.options, options.shellFile, options.routeArgs.route.file);
964
+ const cssUrls = resolvePageCssUrls(options.options.cssManifest, options.shellFile, options.routeArgs.route.file);
965
+ const modulePreloadUrls = resolvePageJsUrls(options.options.jsManifest, options.shellFile, options.routeArgs.route.file);
887
966
  const renderToString = await getRenderToStringAsync();
888
967
  const ErrorBoundary = options.routeModule.ErrorBoundary;
889
968
  const Shell = shellModule?.Shell;
@@ -903,172 +982,328 @@ async function renderRouteErrorResponse(options) {
903
982
  error: routeErrorWithDiagnostics
904
983
  },
905
984
  clientEntryUrl: options.options.clientEntryUrl,
906
- cssUrls,
907
- modulePreloadUrls
908
- }), routeErrorWithDiagnostics.status, documentHeaders);
909
- }
910
- async function runMiddlewareChain(options) {
911
- let context = options.context;
912
- const modulePromises = options.middlewareFiles.map((mwFile) => resolveRegistryModule(options.registry.middlewareModules, mwFile));
913
- for (const p of modulePromises) p.catch(() => {});
914
- for (const modulePromise of modulePromises) {
915
- const mwModule = await modulePromise;
916
- if (!mwModule?.middleware) continue;
917
- const result = await mwModule.middleware({
918
- request: options.request,
919
- params: options.params,
920
- context,
921
- signal: AbortSignal.timeout(3e4),
922
- url: options.url,
923
- route: options.route
924
- });
925
- if (!result) continue;
926
- if (result instanceof Response) return { response: withDefaultSecurityHeaders(result) };
927
- if ("redirect" in result) return { response: withDefaultSecurityHeaders(new Response(null, {
928
- status: 302,
929
- headers: { location: result.redirect }
930
- })) };
931
- if ("context" in result) context = {
932
- ...context,
933
- ...result.context
934
- };
935
- }
936
- return { context };
937
- }
938
- async function resolveDataFunctions(route, routeModule, registry) {
939
- let loader = routeModule?.loader;
940
- let loaderFile = routeModule?.loader ? route.file : void 0;
941
- if (route.loaderFile) {
942
- const dataModule = await resolveRegistryModule(registry.dataModules, route.loaderFile);
943
- if (dataModule?.loader) {
944
- loader = dataModule.loader;
945
- loaderFile = route.loaderFile;
946
- }
947
- }
948
- return {
949
- loader,
950
- loaderFile
951
- };
952
- }
953
- async function resolveRegistryModule(modules, file) {
954
- if (!modules) return void 0;
955
- if (file in modules) return modules[file]();
956
- const resolved = getSuffixIndex(modules).get(normalizeModulePath(file));
957
- if (resolved) return modules[resolved]();
958
- }
959
- async function mergeHeadMetadata(shellModule, routeModule, routeArgs, data) {
960
- const [shellHead, routeHead] = await Promise.all([shellModule?.head ? shellModule.head(routeArgs) : Promise.resolve({}), routeModule?.head ? routeModule.head({
961
- ...routeArgs,
962
- data
963
- }) : Promise.resolve({})]);
964
- return {
965
- title: routeHead.title ?? shellHead.title,
966
- lang: routeHead.lang ?? shellHead.lang,
967
- meta: [...shellHead.meta ?? [], ...routeHead.meta ?? []],
968
- link: [...shellHead.link ?? [], ...routeHead.link ?? []]
969
- };
970
- }
971
- async function mergeDocumentHeaders(shellModule, routeModule, routeArgs, data) {
972
- const headers = new Headers();
973
- const [shellHeaders, routeHeaders] = await Promise.all([shellModule?.headers ? shellModule.headers(routeArgs) : Promise.resolve(void 0), routeModule?.headers ? routeModule.headers({
974
- ...routeArgs,
975
- data
976
- }) : Promise.resolve(void 0)]);
977
- if (shellHeaders) applyHeaders(headers, shellHeaders);
978
- if (routeHeaders) applyHeaders(headers, routeHeaders);
979
- return headers;
980
- }
981
- function applyHeaders(headers, init) {
982
- new Headers(init).forEach((value, key) => {
983
- headers.set(key, value);
984
- });
985
- }
986
- function buildHtmlDocument(options) {
987
- const { head, body, hydrationState, clientEntryUrl, cssUrls = [], modulePreloadUrls = [], routeStatePreloadUrl } = options;
988
- const titleTag = head.title ? `<title>${escapeHtml(head.title)}</title>` : "";
989
- const metaTags = (head.meta ?? []).map((m) => `<meta ${Object.entries(m).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ")}>`).join("\n ");
990
- const linkTags = (head.link ?? []).map((l) => `<link ${Object.entries(l).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ")}>`).join("\n ");
991
- const cssTags = cssUrls.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join("\n ");
992
- const modulePreloadTags = modulePreloadUrls.map((url) => `<link rel="modulepreload" href="${escapeHtml(url)}">`).join("\n ");
993
- const routeStatePreloadTag = routeStatePreloadUrl ? `<link rel="preload" as="fetch" href="${escapeHtml(routeStatePreloadUrl)}" crossorigin="anonymous">` : "";
994
- const stateScript = `<script id="${HYDRATION_STATE_ELEMENT_ID}" type="application/json">${serializeJsonForHtml(hydrationState)}<\/script>`;
995
- const entryScript = clientEntryUrl ? `<script type="module" src="${escapeHtml(clientEntryUrl)}"><\/script>` : "";
996
- return `<!DOCTYPE html>
997
- <html${head.lang ? ` lang="${escapeHtml(head.lang)}"` : ""}>
998
- <head>
999
- <meta charset="utf-8">
1000
- ${titleTag}
1001
- ${metaTags}
1002
- ${linkTags}
1003
- ${cssTags}
1004
- ${modulePreloadTags}
1005
- ${routeStatePreloadTag}
1006
- </head>
1007
- <body>
1008
- <div id="pracht-root">${body}</div>
1009
- ${stateScript}
1010
- ${entryScript}
1011
- </body>
1012
- </html>`;
1013
- }
1014
- function htmlResponse(html, status = 200, initHeaders) {
1015
- const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
1016
- if (initHeaders) applyHeaders(headers, initHeaders);
1017
- applySecurityAndRouteHeaders(headers, { isRouteStateRequest: false });
1018
- return new Response(html, {
1019
- status,
1020
- headers
1021
- });
1022
- }
1023
- function applyDefaultSecurityHeaders(headers) {
1024
- if (!headers.has("permissions-policy")) headers.set("permissions-policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()");
1025
- if (!headers.has("referrer-policy")) headers.set("referrer-policy", "strict-origin-when-cross-origin");
1026
- if (!headers.has("x-content-type-options")) headers.set("x-content-type-options", "nosniff");
1027
- if (!headers.has("x-frame-options")) headers.set("x-frame-options", "SAMEORIGIN");
1028
- return headers;
1029
- }
1030
- function applySecurityAndRouteHeaders(headers, options) {
1031
- applyDefaultSecurityHeaders(headers);
1032
- if (options) {
1033
- appendVaryHeader(headers, ROUTE_STATE_REQUEST_HEADER);
1034
- if (options.isRouteStateRequest && !headers.has("cache-control")) headers.set("cache-control", ROUTE_STATE_CACHE_CONTROL);
1035
- }
1036
- return headers;
985
+ cssUrls,
986
+ modulePreloadUrls
987
+ }), routeErrorWithDiagnostics.status, documentHeaders);
1037
988
  }
1038
- function withDefaultSecurityHeaders(response) {
1039
- const headers = new Headers(response.headers);
1040
- applySecurityAndRouteHeaders(headers);
1041
- return new Response(response.body, {
1042
- status: response.status,
1043
- statusText: response.statusText,
1044
- headers
989
+ //#endregion
990
+ //#region src/runtime-negotiation.ts
991
+ const MARKDOWN_MEDIA_TYPE = "text/markdown";
992
+ function parseAccept(header) {
993
+ if (!header) return [];
994
+ const entries = [];
995
+ for (const raw of header.split(",")) {
996
+ const parts = raw.trim().split(";");
997
+ const type = parts.shift()?.trim().toLowerCase();
998
+ if (!type) continue;
999
+ let quality = 1;
1000
+ for (const param of parts) {
1001
+ const [key, value] = param.split("=").map((p) => p.trim());
1002
+ if (key === "q" && value != null) {
1003
+ const parsed = Number.parseFloat(value);
1004
+ if (!Number.isNaN(parsed)) quality = parsed;
1005
+ }
1006
+ }
1007
+ entries.push({
1008
+ type,
1009
+ quality
1010
+ });
1011
+ }
1012
+ return entries;
1013
+ }
1014
+ function prefersMarkdown(accept) {
1015
+ const entries = parseAccept(accept);
1016
+ if (!entries.length) return false;
1017
+ const md = entries.find((e) => e.type === MARKDOWN_MEDIA_TYPE);
1018
+ if (!md || md.quality === 0) return false;
1019
+ const html = entries.find((e) => e.type === "text/html");
1020
+ if (!html) return true;
1021
+ return md.quality >= html.quality;
1022
+ }
1023
+ function markdownResponse(source) {
1024
+ const headers = new Headers({
1025
+ "content-type": "text/markdown; charset=utf-8",
1026
+ "cache-control": "public, max-age=0, must-revalidate"
1045
1027
  });
1046
- }
1047
- function withRouteResponseHeaders(response, options) {
1048
- const headers = new Headers(response.headers);
1049
- applySecurityAndRouteHeaders(headers, options);
1050
- return new Response(response.body, {
1051
- status: response.status,
1052
- statusText: response.statusText,
1028
+ appendVaryHeader(headers, "Accept");
1029
+ applyDefaultSecurityHeaders(headers);
1030
+ return new Response(source, {
1031
+ status: 200,
1053
1032
  headers
1054
1033
  });
1055
1034
  }
1056
- function appendVaryHeader(headers, value) {
1057
- const current = headers.get("vary");
1058
- if (!current) {
1059
- headers.set("vary", value);
1060
- return;
1035
+ //#endregion
1036
+ //#region src/runtime.ts
1037
+ const FIRST_PARTY_FETCH_SITES = new Set(["same-origin", "same-site"]);
1038
+ /**
1039
+ * Stricter variant of first-party detection used for CSRF protection on
1040
+ * state-changing API requests. Unlike `isFirstPartyFetch`, this *only*
1041
+ * accepts explicit positive evidence that the request came from this
1042
+ * origin — a cross-origin form POST will send `Origin` from the
1043
+ * attacker, and a missing `Origin` on POST is unusual enough to block.
1044
+ * Non-browser callers (curl, server-to-server) should set the header
1045
+ * explicitly or pre-flight via middleware.
1046
+ */
1047
+ function isSameOriginMutation(request, url) {
1048
+ const site = request.headers.get("sec-fetch-site");
1049
+ if (site) return FIRST_PARTY_FETCH_SITES.has(site);
1050
+ const origin = request.headers.get("origin");
1051
+ if (origin) try {
1052
+ return new URL(origin).origin === url.origin;
1053
+ } catch {
1054
+ return false;
1061
1055
  }
1062
- const values = current.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
1063
- if (values.includes("*") || values.includes(value.toLowerCase())) return;
1064
- headers.set("vary", `${current}, ${value}`);
1056
+ const referer = request.headers.get("referer");
1057
+ if (referer) try {
1058
+ return new URL(referer).origin === url.origin;
1059
+ } catch {
1060
+ return false;
1061
+ }
1062
+ return true;
1065
1063
  }
1066
- function escapeHtml(str) {
1067
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1064
+ /**
1065
+ * Heuristic "this request came from our own page" check. Used to gate
1066
+ * the `_data=1` query-param form of the route-state endpoint, which is
1067
+ * otherwise reachable via any cross-origin `<a href>` / redirect.
1068
+ *
1069
+ * Accepts a request as first-party when:
1070
+ * - Sec-Fetch-Site is `same-origin` or `same-site` (modern browsers),
1071
+ * - OR Sec-Fetch-Site is absent AND the Origin header matches the
1072
+ * request URL's origin (older clients that still send Origin),
1073
+ * - OR no Origin/Sec-Fetch-Site is present AND there is no Referer
1074
+ * (non-browser clients like curl — CSRF is not the threat model
1075
+ * there; blocking would break tests and CLIs).
1076
+ *
1077
+ * Cross-origin browser navigations set Sec-Fetch-Site to `cross-site`
1078
+ * or `none` (for user-typed URLs Sec-Fetch-Site: none, Referer absent,
1079
+ * Origin absent — handled by the "no headers → allow" branch since that
1080
+ * matches a first-party typed URL too).
1081
+ */
1082
+ function isFirstPartyFetch(request) {
1083
+ const site = request.headers.get("sec-fetch-site");
1084
+ if (site) return FIRST_PARTY_FETCH_SITES.has(site);
1085
+ const origin = request.headers.get("origin");
1086
+ if (origin) try {
1087
+ return new URL(origin).origin === new URL(request.url).origin;
1088
+ } catch {
1089
+ return false;
1090
+ }
1091
+ return true;
1068
1092
  }
1069
- function serializeJsonForHtml(value) {
1070
- return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
1093
+ async function handlePrachtRequest(options) {
1094
+ const url = new URL(options.request.url);
1095
+ const hasDataParam = url.searchParams.get("_data") === "1";
1096
+ if (hasDataParam) url.searchParams.delete("_data");
1097
+ const requestPath = getRequestPath(url);
1098
+ const registry = options.registry ?? {};
1099
+ const headerSignalsRouteState = options.request.headers.get(ROUTE_STATE_REQUEST_HEADER) === "1";
1100
+ const dataParamIsFirstParty = hasDataParam && isFirstPartyFetch(options.request);
1101
+ const isRouteStateRequest = headerSignalsRouteState || dataParamIsFirstParty;
1102
+ const exposeDiagnostics = shouldExposeServerErrors(options);
1103
+ if (options.apiRoutes?.length) {
1104
+ const apiMatch = matchApiRoute(options.apiRoutes, url.pathname);
1105
+ if (apiMatch) {
1106
+ const apiMiddlewareFiles = (options.app.api.middleware ?? []).flatMap((name) => {
1107
+ const middlewareFile = options.app.middleware[name];
1108
+ return middlewareFile ? [middlewareFile] : [];
1109
+ });
1110
+ let currentPhase = "middleware";
1111
+ if ((options.app.api.requireSameOrigin ?? true) && !SAFE_METHODS.has(options.request.method) && !isSameOriginMutation(options.request, url)) return withDefaultSecurityHeaders(new Response("Cross-origin request blocked", {
1112
+ status: 403,
1113
+ headers: { "content-type": "text/plain; charset=utf-8" }
1114
+ }));
1115
+ try {
1116
+ const middlewareResult = await runMiddlewareChain({
1117
+ context: options.context ?? {},
1118
+ middlewareFiles: apiMiddlewareFiles,
1119
+ params: apiMatch.params,
1120
+ registry,
1121
+ request: options.request,
1122
+ route: apiMatch.route,
1123
+ url
1124
+ });
1125
+ if (middlewareResult.response) return middlewareResult.response;
1126
+ currentPhase = "api";
1127
+ const apiModule = await resolveRegistryModule(registry.apiModules, apiMatch.route.file);
1128
+ if (!apiModule) throw new Error("API route module not found");
1129
+ const handler = apiModule[options.request.method.toUpperCase()] ?? apiModule.default;
1130
+ if (!handler) return withDefaultSecurityHeaders(new Response("Method not allowed", {
1131
+ status: 405,
1132
+ headers: { "content-type": "text/plain; charset=utf-8" }
1133
+ }));
1134
+ return withDefaultSecurityHeaders(await handler({
1135
+ request: options.request,
1136
+ params: apiMatch.params,
1137
+ context: middlewareResult.context,
1138
+ signal: AbortSignal.timeout(3e4),
1139
+ url,
1140
+ route: apiMatch.route
1141
+ }));
1142
+ } catch (error) {
1143
+ return renderApiErrorResponse({
1144
+ error,
1145
+ middlewareFiles: apiMiddlewareFiles,
1146
+ options,
1147
+ phase: currentPhase,
1148
+ route: apiMatch.route
1149
+ });
1150
+ }
1151
+ }
1152
+ }
1153
+ const match = matchAppRoute(options.app, url.pathname);
1154
+ if (!match) {
1155
+ if (isRouteStateRequest) return jsonErrorResponse(createSerializedRouteError("Not found", 404, {
1156
+ diagnostics: exposeDiagnostics ? buildRuntimeDiagnostics({
1157
+ phase: "match",
1158
+ status: 404
1159
+ }) : void 0,
1160
+ name: "Error"
1161
+ }), { isRouteStateRequest: true });
1162
+ return withDefaultSecurityHeaders(new Response("Not found", {
1163
+ status: 404,
1164
+ headers: { "content-type": "text/plain; charset=utf-8" }
1165
+ }));
1166
+ }
1167
+ if (!SAFE_METHODS.has(options.request.method)) {
1168
+ if (isRouteStateRequest) return jsonErrorResponse(createSerializedRouteError("Method not allowed", 405, {
1169
+ diagnostics: exposeDiagnostics ? buildRuntimeDiagnostics({
1170
+ middlewareFiles: match.route.middlewareFiles,
1171
+ phase: "action",
1172
+ route: match.route,
1173
+ shellFile: match.route.shellFile,
1174
+ status: 405
1175
+ }) : void 0,
1176
+ name: "Error"
1177
+ }), { isRouteStateRequest: true });
1178
+ return withRouteResponseHeaders(new Response("Method not allowed", {
1179
+ status: 405,
1180
+ headers: { "content-type": "text/plain; charset=utf-8" }
1181
+ }), { isRouteStateRequest });
1182
+ }
1183
+ let routeArgs = {
1184
+ request: options.request,
1185
+ params: match.params,
1186
+ context: options.context ?? {},
1187
+ signal: AbortSignal.timeout(3e4),
1188
+ url,
1189
+ route: match.route
1190
+ };
1191
+ let routeModule;
1192
+ let shellModule;
1193
+ let loaderFile;
1194
+ let currentPhase = "middleware";
1195
+ try {
1196
+ const middlewarePromise = runMiddlewareChain({
1197
+ context: routeArgs.context,
1198
+ middlewareFiles: match.route.middlewareFiles,
1199
+ params: match.params,
1200
+ registry,
1201
+ request: options.request,
1202
+ route: match.route,
1203
+ url
1204
+ });
1205
+ const routeModulePromise = resolveRegistryModule(registry.routeModules, match.route.file);
1206
+ const shellModulePromise = match.route.shellFile ? resolveRegistryModule(registry.shellModules, match.route.shellFile) : Promise.resolve(void 0);
1207
+ const dataFunctionsPromise = routeModulePromise.then((mod) => resolveDataFunctions(match.route, mod, registry));
1208
+ routeModulePromise.catch(() => {});
1209
+ shellModulePromise.catch(() => {});
1210
+ dataFunctionsPromise.catch(() => {});
1211
+ const middlewareResult = await middlewarePromise;
1212
+ if (middlewareResult.response) return normalizePageResponse(middlewareResult.response, { isRouteStateRequest });
1213
+ routeArgs = {
1214
+ ...routeArgs,
1215
+ context: middlewareResult.context
1216
+ };
1217
+ currentPhase = "render";
1218
+ routeModule = await routeModulePromise;
1219
+ if (!routeModule) throw new Error("Route module not found");
1220
+ if (!isRouteStateRequest && typeof routeModule.markdown === "string" && prefersMarkdown(options.request.headers.get("accept"))) return markdownResponse(routeModule.markdown);
1221
+ currentPhase = "loader";
1222
+ const { loader, loaderFile: resolvedLoaderFile } = await dataFunctionsPromise;
1223
+ loaderFile = resolvedLoaderFile;
1224
+ const loaderResult = loader ? await loader(routeArgs) : void 0;
1225
+ if (loaderResult instanceof Response) return normalizePageResponse(loaderResult, { isRouteStateRequest });
1226
+ const data = loaderResult;
1227
+ if (isRouteStateRequest) return withRouteResponseHeaders(Response.json({ data }), { isRouteStateRequest: true });
1228
+ currentPhase = "render";
1229
+ shellModule = await shellModulePromise;
1230
+ const [head, documentHeaders] = await Promise.all([mergeHeadMetadata(shellModule, routeModule, routeArgs, data), mergeDocumentHeaders(shellModule, routeModule, routeArgs, data)]);
1231
+ const cssUrls = resolvePageCssUrls(options.cssManifest, match.route.shellFile, match.route.file);
1232
+ const modulePreloadUrls = resolvePageJsUrls(options.jsManifest, match.route.shellFile, match.route.file);
1233
+ if (match.route.render === "spa") {
1234
+ let body = "";
1235
+ if (shellModule?.Shell || shellModule?.Loading) {
1236
+ const Shell = shellModule?.Shell;
1237
+ const Loading = shellModule?.Loading;
1238
+ const loadingTree = Shell != null ? h(Shell, null, Loading ? h(Loading, null) : null) : Loading ? h(Loading, null) : null;
1239
+ if (loadingTree) body = await (await getRenderToStringAsync())(loadingTree);
1240
+ }
1241
+ return htmlResponse(buildHtmlDocument({
1242
+ head,
1243
+ body,
1244
+ hydrationState: {
1245
+ url: requestPath,
1246
+ routeId: match.route.id ?? "",
1247
+ data: null,
1248
+ error: null,
1249
+ pending: true
1250
+ },
1251
+ clientEntryUrl: options.clientEntryUrl,
1252
+ cssUrls,
1253
+ modulePreloadUrls,
1254
+ routeStatePreloadUrl: loader ? buildRouteStateUrl(requestPath) : void 0
1255
+ }), 200, documentHeaders);
1256
+ }
1257
+ const DefaultComponent = typeof routeModule.default === "function" ? routeModule.default : void 0;
1258
+ const Component = routeModule.Component ?? DefaultComponent;
1259
+ if (!Component) throw new Error("Route has no Component or default export");
1260
+ const Shell = shellModule?.Shell;
1261
+ const Comp = Component;
1262
+ const componentProps = {
1263
+ data,
1264
+ params: match.params
1265
+ };
1266
+ const componentTree = Shell ? h(Shell, null, h(Comp, componentProps)) : h(Comp, componentProps);
1267
+ const tree = h(PrachtRuntimeProvider, {
1268
+ data,
1269
+ params: match.params,
1270
+ routeId: match.route.id ?? "",
1271
+ url: requestPath
1272
+ }, componentTree);
1273
+ return htmlResponse(buildHtmlDocument({
1274
+ head,
1275
+ body: await (await getRenderToStringAsync())(tree),
1276
+ hydrationState: {
1277
+ url: requestPath,
1278
+ routeId: match.route.id ?? "",
1279
+ data,
1280
+ error: null
1281
+ },
1282
+ clientEntryUrl: options.clientEntryUrl,
1283
+ cssUrls,
1284
+ modulePreloadUrls
1285
+ }), 200, documentHeaders);
1286
+ } catch (error) {
1287
+ return renderRouteErrorResponse({
1288
+ error,
1289
+ isRouteStateRequest,
1290
+ loaderFile,
1291
+ options,
1292
+ phase: currentPhase,
1293
+ routeArgs,
1294
+ routeId: match.route.id ?? "",
1295
+ routeModule,
1296
+ shellFile: match.route.shellFile,
1297
+ shellModule,
1298
+ requestPath
1299
+ });
1300
+ }
1301
+ }
1302
+ function getRequestPath(url) {
1303
+ return `${url.pathname}${url.search}`;
1071
1304
  }
1305
+ //#endregion
1306
+ //#region src/prerender.ts
1072
1307
  async function prerenderApp(options) {
1073
1308
  const resolved = resolveApp(options.app);
1074
1309
  const results = [];
@@ -1349,7 +1584,11 @@ async function initClientRouter(options) {
1349
1584
  };
1350
1585
  }
1351
1586
  function resolveRedirectTarget(location) {
1352
- const targetUrl = new URL(location, window.location.href);
1587
+ const targetUrl = parseSafeNavigationUrl(location, window.location.href);
1588
+ if (!targetUrl) return {
1589
+ isCurrentLocation: false,
1590
+ unsafe: true
1591
+ };
1353
1592
  const fullInternalTarget = targetUrl.pathname + targetUrl.search + targetUrl.hash;
1354
1593
  const internalPath = targetUrl.pathname + targetUrl.search;
1355
1594
  const currentPath = window.location.pathname + window.location.search + window.location.hash;
@@ -1390,6 +1629,10 @@ async function initClientRouter(options) {
1390
1629
  if (result.type === "redirect") {
1391
1630
  if (result.location) {
1392
1631
  const redirect = resolveRedirectTarget(result.location);
1632
+ if (redirect.unsafe) {
1633
+ console.error(`[pracht] refused to navigate to unsafe URL: ${result.location}`);
1634
+ return;
1635
+ }
1393
1636
  if (redirect.externalUrl) {
1394
1637
  window.location.href = redirect.externalUrl;
1395
1638
  return;
@@ -1403,7 +1646,7 @@ async function initClientRouter(options) {
1403
1646
  await navigate(redirect.internalPath, opts);
1404
1647
  return;
1405
1648
  }
1406
- window.location.href = result.location;
1649
+ window.location.href = target.browserUrl;
1407
1650
  return;
1408
1651
  }
1409
1652
  window.location.href = target.browserUrl;
@@ -1446,7 +1689,12 @@ async function initClientRouter(options) {
1446
1689
  try {
1447
1690
  const result = await dataPromise;
1448
1691
  if (result.type === "redirect") {
1449
- window.location.href = result.location;
1692
+ const safeRedirect = parseSafeNavigationUrl(result.location, window.location.href);
1693
+ if (!safeRedirect) {
1694
+ console.error(`[pracht] refused to navigate to unsafe URL: ${result.location}`);
1695
+ return;
1696
+ }
1697
+ window.location.href = safeRedirect.toString();
1450
1698
  return;
1451
1699
  }
1452
1700
  if (result.type === "error") state = {