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