@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.d.mts +46 -26
- package/dist/index.mjs +739 -493
- package/package.json +1 -1
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
432
|
-
const
|
|
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
|
-
|
|
458
|
-
|
|
684
|
+
pathname: url.pathname,
|
|
685
|
+
search: url.search
|
|
459
686
|
};
|
|
460
687
|
}
|
|
461
|
-
|
|
462
|
-
|
|
688
|
+
//#endregion
|
|
689
|
+
//#region src/runtime-html.ts
|
|
690
|
+
function escapeHtml(str) {
|
|
691
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
463
692
|
}
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
|
674
|
-
|
|
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(
|
|
717
|
-
if (!
|
|
718
|
-
return resolvePageUrlsFromManifest(
|
|
775
|
+
function resolvePageCssUrls(cssManifest, shellFile, routeFile) {
|
|
776
|
+
if (!cssManifest) return [];
|
|
777
|
+
return resolvePageUrlsFromManifest(cssManifest, shellFile, routeFile);
|
|
719
778
|
}
|
|
720
|
-
function resolvePageJsUrls(
|
|
721
|
-
if (!
|
|
722
|
-
return resolvePageUrlsFromManifest(
|
|
779
|
+
function resolvePageJsUrls(jsManifest, shellFile, routeFile) {
|
|
780
|
+
if (!jsManifest) return [];
|
|
781
|
+
return resolvePageUrlsFromManifest(jsManifest, shellFile, routeFile);
|
|
723
782
|
}
|
|
724
|
-
async function
|
|
725
|
-
if (
|
|
726
|
-
|
|
727
|
-
const
|
|
728
|
-
if (
|
|
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
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
status,
|
|
749
|
-
...options.diagnostics ? { diagnostics: options.diagnostics } : {}
|
|
800
|
+
loader,
|
|
801
|
+
loaderFile
|
|
750
802
|
};
|
|
751
803
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
|
1063
|
-
if (
|
|
1064
|
-
|
|
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
|
-
|
|
1067
|
-
|
|
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
|
|
1070
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 = {
|