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