@pracht/core 0.2.7 → 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
@@ -172,10 +172,21 @@ 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`
@@ -309,18 +320,257 @@ function useIsHydrated() {
309
320
  return hydrated;
310
321
  }
311
322
  //#endregion
312
- //#region src/runtime.ts
323
+ //#region src/runtime-constants.ts
313
324
  const SAFE_METHODS = new Set(["GET", "HEAD"]);
314
325
  const HYDRATION_STATE_ELEMENT_ID = "pracht-state";
315
326
  const ROUTE_STATE_REQUEST_HEADER = "x-pracht-route-state-request";
316
327
  const ROUTE_STATE_CACHE_CONTROL = "no-store";
317
328
  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;
329
+ //#endregion
330
+ //#region src/runtime-errors.ts
331
+ function isPrachtHttpError(error) {
332
+ return error instanceof Error && error.name === "PrachtHttpError" && "status" in error;
333
+ }
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;
351
+ }
352
+ return true;
353
+ }
354
+ function createSerializedRouteError(message, status, options = {}) {
355
+ return {
356
+ message,
357
+ name: options.name ?? "Error",
358
+ status,
359
+ ...options.diagnostics ? { diagnostics: options.diagnostics } : {}
360
+ };
361
+ }
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
374
+ };
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
405
+ };
406
+ }
407
+ if (options.exposeDetails) return {
408
+ message: typeof error === "string" && error ? error : "Internal Server Error",
409
+ name: "Error",
410
+ status: 500
411
+ };
412
+ return {
413
+ message: "Internal Server Error",
414
+ name: "Error",
415
+ status: 500
416
+ };
417
+ }
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;
448
+ }
449
+ if (Array.isArray(init)) {
450
+ for (const entry of init) if (entry && entry.length >= 2) yield [entry[0], entry[1]];
451
+ return;
452
+ }
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);
467
+ }
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;
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}`);
497
+ }
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;
513
+ try {
514
+ targetUrl = new URL(location, base);
515
+ } catch {
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"
546
+ };
547
+ throw new Error(`Failed to fetch route state (${response.status})`);
548
+ }
549
+ return {
550
+ data: json.data,
551
+ type: "data"
552
+ };
553
+ }
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();
323
571
  }
572
+ //#endregion
573
+ //#region src/runtime-hooks.ts
324
574
  const RouteDataContext = createContext(void 0);
325
575
  function PrachtRuntimeProvider({ children, data, params = EMPTY_ROUTE_PARAMS, routeId, stateVersion = 0, url }) {
326
576
  const [routeDataState, setRouteDataState] = useState({
@@ -428,251 +678,60 @@ function Form(props) {
428
678
  }
429
679
  });
430
680
  }
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
- }
681
+ function parseLocation(value) {
682
+ const url = new URL(value, "http://pracht.local");
456
683
  return {
457
- data: json.data,
458
- type: "data"
684
+ pathname: url.pathname,
685
+ search: url.search
459
686
  };
460
687
  }
461
- function buildRouteStateUrl(url) {
462
- return `${url}${url.includes("?") ? "&" : "?"}_data=1`;
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;");
463
692
  }
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
- }
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");
665
695
  }
666
- function parseLocation(value) {
667
- const url = new URL(value, "http://pracht.local");
668
- return {
669
- pathname: url.pathname,
670
- search: url.search
671
- };
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>`;
672
723
  }
673
- function getRequestPath(url) {
674
- return `${url.pathname}${url.search}`;
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
+ });
675
732
  }
733
+ //#endregion
734
+ //#region src/runtime-manifest.ts
676
735
  /** Strip leading `./` and `/` so all module paths share one canonical form. */
677
736
  function normalizeModulePath(path) {
678
737
  return path.replace(/^\.?\//, "");
@@ -713,104 +772,122 @@ function resolvePageUrlsFromManifest(manifest, shellFile, routeFile) {
713
772
  add(routeFile);
714
773
  return [...urls];
715
774
  }
716
- function resolvePageCssUrls(options, shellFile, routeFile) {
717
- if (!options.cssManifest) return [];
718
- return resolvePageUrlsFromManifest(options.cssManifest, shellFile, routeFile);
775
+ function resolvePageCssUrls(cssManifest, shellFile, routeFile) {
776
+ if (!cssManifest) return [];
777
+ return resolvePageUrlsFromManifest(cssManifest, shellFile, routeFile);
719
778
  }
720
- function resolvePageJsUrls(options, shellFile, routeFile) {
721
- if (!options.jsManifest) return [];
722
- return resolvePageUrlsFromManifest(options.jsManifest, shellFile, routeFile);
779
+ function resolvePageJsUrls(jsManifest, shellFile, routeFile) {
780
+ if (!jsManifest) return [];
781
+ return resolvePageUrlsFromManifest(jsManifest, shellFile, routeFile);
723
782
  }
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;
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]();
743
788
  }
744
- function createSerializedRouteError(message, status, options = {}) {
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
+ }
745
799
  return {
746
- message,
747
- name: options.name ?? "Error",
748
- status,
749
- ...options.diagnostics ? { diagnostics: options.diagnostics } : {}
800
+ loader,
801
+ loaderFile
750
802
  };
751
803
  }
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
- };
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
+ });
765
829
  }
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
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
795
858
  };
796
859
  }
797
- if (options.exposeDetails) return {
798
- message: typeof error === "string" && error ? error : "Internal Server Error",
799
- name: "Error",
800
- status: 500
801
- };
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({})]);
802
867
  return {
803
- message: "Internal Server Error",
804
- name: "Error",
805
- 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 ?? []]
806
872
  };
807
873
  }
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;
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;
814
891
  }
815
892
  function jsonErrorResponse(routeError, options) {
816
893
  const headers = applySecurityAndRouteHeaders(new Headers({ "content-type": "application/json; charset=utf-8" }), options.isRouteStateRequest ? { isRouteStateRequest: true } : void 0);
@@ -882,8 +959,8 @@ async function renderRouteErrorResponse(options) {
882
959
  const shellModule = options.shellModule ?? (options.shellFile ? await resolveRegistryModule(options.options.registry?.shellModules, options.shellFile) : void 0);
883
960
  const head = shellModule?.head ? await shellModule.head(options.routeArgs) : {};
884
961
  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);
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);
887
964
  const renderToString = await getRenderToStringAsync();
888
965
  const ErrorBoundary = options.routeModule.ErrorBoundary;
889
966
  const Shell = shellModule?.Shell;
@@ -903,172 +980,328 @@ async function renderRouteErrorResponse(options) {
903
980
  error: routeErrorWithDiagnostics
904
981
  },
905
982
  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;
983
+ cssUrls,
984
+ modulePreloadUrls
985
+ }), routeErrorWithDiagnostics.status, documentHeaders);
1037
986
  }
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
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;
1011
+ }
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"
1045
1025
  });
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,
1026
+ appendVaryHeader(headers, "Accept");
1027
+ applyDefaultSecurityHeaders(headers);
1028
+ return new Response(source, {
1029
+ status: 200,
1053
1030
  headers
1054
1031
  });
1055
1032
  }
1056
- function appendVaryHeader(headers, value) {
1057
- const current = headers.get("vary");
1058
- if (!current) {
1059
- headers.set("vary", value);
1060
- 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;
1061
1053
  }
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}`);
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;
1065
1061
  }
1066
- function escapeHtml(str) {
1067
- 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;
1068
1090
  }
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");
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}`;
1071
1302
  }
1303
+ //#endregion
1304
+ //#region src/prerender.ts
1072
1305
  async function prerenderApp(options) {
1073
1306
  const resolved = resolveApp(options.app);
1074
1307
  const results = [];
@@ -1349,7 +1582,11 @@ async function initClientRouter(options) {
1349
1582
  };
1350
1583
  }
1351
1584
  function resolveRedirectTarget(location) {
1352
- 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
+ };
1353
1590
  const fullInternalTarget = targetUrl.pathname + targetUrl.search + targetUrl.hash;
1354
1591
  const internalPath = targetUrl.pathname + targetUrl.search;
1355
1592
  const currentPath = window.location.pathname + window.location.search + window.location.hash;
@@ -1390,6 +1627,10 @@ async function initClientRouter(options) {
1390
1627
  if (result.type === "redirect") {
1391
1628
  if (result.location) {
1392
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
+ }
1393
1634
  if (redirect.externalUrl) {
1394
1635
  window.location.href = redirect.externalUrl;
1395
1636
  return;
@@ -1403,7 +1644,7 @@ async function initClientRouter(options) {
1403
1644
  await navigate(redirect.internalPath, opts);
1404
1645
  return;
1405
1646
  }
1406
- window.location.href = result.location;
1647
+ window.location.href = target.browserUrl;
1407
1648
  return;
1408
1649
  }
1409
1650
  window.location.href = target.browserUrl;
@@ -1446,7 +1687,12 @@ async function initClientRouter(options) {
1446
1687
  try {
1447
1688
  const result = await dataPromise;
1448
1689
  if (result.type === "redirect") {
1449
- 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();
1450
1696
  return;
1451
1697
  }
1452
1698
  if (result.type === "error") state = {