@pracht/core 0.6.1 → 0.8.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/app-BWriseLj.d.mts +298 -0
- package/dist/app-BvC1uQG5.mjs +308 -0
- package/dist/browser.d.mts +7 -0
- package/dist/browser.mjs +8 -0
- package/dist/client.d.mts +4 -0
- package/dist/client.mjs +4 -0
- package/dist/forwardRef-grZ6t4GS.mjs +27 -0
- package/dist/hydration-M1QzbQ1O.d.mts +23 -0
- package/dist/index.d.mts +7 -438
- package/dist/index.mjs +7 -1965
- package/dist/manifest.d.mts +2 -0
- package/dist/manifest.mjs +2 -0
- package/dist/prefetch-B50kX9RL.mjs +118 -0
- package/dist/prefetch-cache-DzP2Bj9H.mjs +129 -0
- package/dist/prerender-B8_CqYwd.d.mts +51 -0
- package/dist/prerender-D3E2H96p.mjs +648 -0
- package/dist/router-B7J4YYwg.mjs +418 -0
- package/dist/router-BcUmg1kB.d.mts +27 -0
- package/dist/runtime-context-B5pREhcM.mjs +164 -0
- package/dist/runtime-context-ytVd7GmZ.d.mts +72 -0
- package/dist/runtime-middleware-aeBdgjYr.d.mts +77 -0
- package/dist/server.d.mts +5 -0
- package/dist/server.mjs +5 -0
- package/dist/types-idmK5omD.mjs +355 -0
- package/package.json +19 -2
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import { a as matchApiRoute, c as resolveApp, n as buildPathFromSegments, o as matchAppRoute } from "./app-BvC1uQG5.mjs";
|
|
2
|
+
import { _ as applyDefaultSecurityHeaders, b as withDefaultSecurityHeaders, c as mergeDocumentHeaders, d as runMiddlewareChain, f as resolveDataFunctions, g as appendVaryHeader, h as resolveRegistryModule, l as mergeHeadMetadata, m as resolvePageJsUrls, p as resolvePageCssUrls, v as applyHeaders, x as withRouteResponseHeaders, y as applySecurityAndRouteHeaders } from "./types-idmK5omD.mjs";
|
|
3
|
+
import { f as HYDRATION_STATE_ELEMENT_ID, h as SAFE_METHODS, m as ROUTE_STATE_REQUEST_HEADER, o as buildRouteStateUrl } from "./prefetch-cache-DzP2Bj9H.mjs";
|
|
4
|
+
import { a as buildRuntimeDiagnostics, c as normalizeRouteError, l as shouldExposeServerErrors, o as createSerializedRouteError, s as deserializeRouteError, t as PrachtRuntimeProvider } from "./runtime-context-B5pREhcM.mjs";
|
|
5
|
+
import { h } from "preact";
|
|
6
|
+
//#region src/runtime-html.ts
|
|
7
|
+
function escapeHtml(str) {
|
|
8
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
9
|
+
}
|
|
10
|
+
function serializeJsonForHtml(value) {
|
|
11
|
+
return escapeScriptText(JSON.stringify(value) ?? "null");
|
|
12
|
+
}
|
|
13
|
+
function escapeScriptText(value) {
|
|
14
|
+
return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
15
|
+
}
|
|
16
|
+
const SAFE_ATTRIBUTE_NAME_RE = /^[A-Za-z_:][A-Za-z0-9:._-]*$/;
|
|
17
|
+
const GLOBAL_HEAD_ATTRIBUTE_PREFIXES = ["data-", "aria-"];
|
|
18
|
+
const META_ATTRIBUTES = new Set([
|
|
19
|
+
"charset",
|
|
20
|
+
"content",
|
|
21
|
+
"http-equiv",
|
|
22
|
+
"itemprop",
|
|
23
|
+
"media",
|
|
24
|
+
"name",
|
|
25
|
+
"property"
|
|
26
|
+
]);
|
|
27
|
+
const LINK_ATTRIBUTES = new Set([
|
|
28
|
+
"as",
|
|
29
|
+
"blocking",
|
|
30
|
+
"color",
|
|
31
|
+
"crossorigin",
|
|
32
|
+
"disabled",
|
|
33
|
+
"fetchpriority",
|
|
34
|
+
"href",
|
|
35
|
+
"hreflang",
|
|
36
|
+
"imagesizes",
|
|
37
|
+
"imagesrcset",
|
|
38
|
+
"integrity",
|
|
39
|
+
"media",
|
|
40
|
+
"referrerpolicy",
|
|
41
|
+
"rel",
|
|
42
|
+
"sizes",
|
|
43
|
+
"title",
|
|
44
|
+
"type"
|
|
45
|
+
]);
|
|
46
|
+
const SCRIPT_ATTRIBUTES = new Set([
|
|
47
|
+
"async",
|
|
48
|
+
"blocking",
|
|
49
|
+
"class",
|
|
50
|
+
"crossorigin",
|
|
51
|
+
"defer",
|
|
52
|
+
"fetchpriority",
|
|
53
|
+
"id",
|
|
54
|
+
"integrity",
|
|
55
|
+
"nomodule",
|
|
56
|
+
"nonce",
|
|
57
|
+
"referrerpolicy",
|
|
58
|
+
"src",
|
|
59
|
+
"type"
|
|
60
|
+
]);
|
|
61
|
+
function renderAttributes(attributes, allowedAttributes) {
|
|
62
|
+
return Object.entries(attributes).filter(([key, value]) => isAllowedHeadAttribute(key, value, allowedAttributes)).map(([key, value]) => `${key}="${escapeHtml(value ?? "")}"`).join(" ");
|
|
63
|
+
}
|
|
64
|
+
function isAllowedHeadAttribute(key, value, allowedAttributes) {
|
|
65
|
+
if (key === "children" || typeof value === "undefined" || !SAFE_ATTRIBUTE_NAME_RE.test(key)) return false;
|
|
66
|
+
const normalized = key.toLowerCase();
|
|
67
|
+
if (normalized.startsWith("on")) return false;
|
|
68
|
+
return allowedAttributes.has(normalized) || GLOBAL_HEAD_ATTRIBUTE_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
|
69
|
+
}
|
|
70
|
+
function buildHtmlDocument(options) {
|
|
71
|
+
const { head, body, hydrationState, clientEntryUrl, cssUrls = [], modulePreloadUrls = [], routeStatePreloadUrl } = options;
|
|
72
|
+
const titleTag = head.title ? `<title>${escapeHtml(head.title)}</title>` : "";
|
|
73
|
+
const metaTags = (head.meta ?? []).map((m) => renderAttributes(m, META_ATTRIBUTES)).filter(Boolean).map((attrs) => `<meta ${attrs}>`).join("\n ");
|
|
74
|
+
const linkTags = (head.link ?? []).map((l) => renderAttributes(l, LINK_ATTRIBUTES)).filter(Boolean).map((attrs) => `<link ${attrs}>`).join("\n ");
|
|
75
|
+
const scriptTags = (head.script ?? []).map((script) => {
|
|
76
|
+
const attrs = renderAttributes(script, SCRIPT_ATTRIBUTES);
|
|
77
|
+
const children = script.children ? escapeScriptText(script.children) : "";
|
|
78
|
+
return attrs ? `<script ${attrs}>${children}<\/script>` : `<script>${children}<\/script>`;
|
|
79
|
+
}).join("\n ");
|
|
80
|
+
const cssTags = cssUrls.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join("\n ");
|
|
81
|
+
const modulePreloadTags = modulePreloadUrls.map((url) => `<link rel="modulepreload" href="${escapeHtml(url)}">`).join("\n ");
|
|
82
|
+
const routeStatePreloadTag = routeStatePreloadUrl ? `<link rel="preload" as="fetch" href="${escapeHtml(routeStatePreloadUrl)}" crossorigin="anonymous">` : "";
|
|
83
|
+
const stateScript = `<script id="${HYDRATION_STATE_ELEMENT_ID}" type="application/json">${serializeJsonForHtml(hydrationState)}<\/script>`;
|
|
84
|
+
const entryScript = clientEntryUrl ? `<script type="module" src="${escapeHtml(clientEntryUrl)}"><\/script>` : "";
|
|
85
|
+
return `<!DOCTYPE html>
|
|
86
|
+
<html${head.lang ? ` lang="${escapeHtml(head.lang)}"` : ""}>
|
|
87
|
+
<head>
|
|
88
|
+
<meta charset="utf-8">
|
|
89
|
+
${titleTag}
|
|
90
|
+
${metaTags}
|
|
91
|
+
${linkTags}
|
|
92
|
+
${scriptTags}
|
|
93
|
+
${cssTags}
|
|
94
|
+
${modulePreloadTags}
|
|
95
|
+
${routeStatePreloadTag}
|
|
96
|
+
</head>
|
|
97
|
+
<body>
|
|
98
|
+
<div id="pracht-root">${body}</div>
|
|
99
|
+
${stateScript}
|
|
100
|
+
${entryScript}
|
|
101
|
+
</body>
|
|
102
|
+
</html>`;
|
|
103
|
+
}
|
|
104
|
+
function htmlResponse(html, status = 200, initHeaders) {
|
|
105
|
+
const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
|
|
106
|
+
if (initHeaders) applyHeaders(headers, initHeaders);
|
|
107
|
+
applySecurityAndRouteHeaders(headers, { isRouteStateRequest: false });
|
|
108
|
+
return new Response(html, {
|
|
109
|
+
status,
|
|
110
|
+
headers
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region src/runtime-response.ts
|
|
115
|
+
let _renderToStringAsync;
|
|
116
|
+
async function getRenderToStringAsync() {
|
|
117
|
+
if (_renderToStringAsync) return _renderToStringAsync;
|
|
118
|
+
_renderToStringAsync = (await import("preact-render-to-string")).renderToStringAsync;
|
|
119
|
+
return _renderToStringAsync;
|
|
120
|
+
}
|
|
121
|
+
function jsonErrorResponse(routeError, options) {
|
|
122
|
+
const headers = applySecurityAndRouteHeaders(new Headers({ "content-type": "application/json; charset=utf-8" }), options.isRouteStateRequest ? { isRouteStateRequest: true } : void 0);
|
|
123
|
+
return new Response(JSON.stringify({ error: routeError }), {
|
|
124
|
+
status: routeError.status,
|
|
125
|
+
headers
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function jsonRedirectResponse(location, options) {
|
|
129
|
+
const headers = new Headers(options.headers);
|
|
130
|
+
headers.set("content-type", "application/json; charset=utf-8");
|
|
131
|
+
return withRouteResponseHeaders(new Response(JSON.stringify({ redirect: location }), {
|
|
132
|
+
status: 200,
|
|
133
|
+
headers
|
|
134
|
+
}), { isRouteStateRequest: options.isRouteStateRequest });
|
|
135
|
+
}
|
|
136
|
+
function normalizePageResponse(response, options) {
|
|
137
|
+
if (options.isRouteStateRequest && response.status >= 300 && response.status < 400) {
|
|
138
|
+
const location = response.headers.get("location");
|
|
139
|
+
if (location) return jsonRedirectResponse(location, {
|
|
140
|
+
headers: response.headers,
|
|
141
|
+
isRouteStateRequest: true
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return withRouteResponseHeaders(response, options);
|
|
145
|
+
}
|
|
146
|
+
function renderApiErrorResponse(options) {
|
|
147
|
+
const exposeDetails = shouldExposeServerErrors(options.options);
|
|
148
|
+
const routeError = normalizeRouteError(options.error, { exposeDetails });
|
|
149
|
+
const routeErrorWithDiagnostics = exposeDetails ? {
|
|
150
|
+
...routeError,
|
|
151
|
+
diagnostics: buildRuntimeDiagnostics({
|
|
152
|
+
middlewareFiles: options.middlewareFiles,
|
|
153
|
+
phase: options.phase,
|
|
154
|
+
route: options.route,
|
|
155
|
+
status: routeError.status
|
|
156
|
+
})
|
|
157
|
+
} : routeError;
|
|
158
|
+
if (exposeDetails) return jsonErrorResponse(routeErrorWithDiagnostics, { isRouteStateRequest: false });
|
|
159
|
+
const message = routeErrorWithDiagnostics.status >= 500 ? "Internal Server Error" : routeErrorWithDiagnostics.message;
|
|
160
|
+
return withDefaultSecurityHeaders(new Response(message, {
|
|
161
|
+
status: routeErrorWithDiagnostics.status,
|
|
162
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
async function renderRouteErrorResponse(options) {
|
|
166
|
+
const exposeDetails = shouldExposeServerErrors(options.options);
|
|
167
|
+
const routeError = normalizeRouteError(options.error, { exposeDetails });
|
|
168
|
+
const routeErrorWithDiagnostics = exposeDetails ? {
|
|
169
|
+
...routeError,
|
|
170
|
+
diagnostics: buildRuntimeDiagnostics({
|
|
171
|
+
loaderFile: options.loaderFile,
|
|
172
|
+
middlewareFiles: options.routeArgs.route.middlewareFiles,
|
|
173
|
+
phase: options.phase,
|
|
174
|
+
route: options.routeArgs.route,
|
|
175
|
+
shellFile: options.shellFile,
|
|
176
|
+
status: routeError.status
|
|
177
|
+
})
|
|
178
|
+
} : routeError;
|
|
179
|
+
if (options.isRouteStateRequest) return jsonErrorResponse(routeErrorWithDiagnostics, { isRouteStateRequest: true });
|
|
180
|
+
const shellModule = options.shellModule ?? (options.shellFile ? await resolveRegistryModule(options.options.registry?.shellModules, options.shellFile) : void 0);
|
|
181
|
+
const ErrorBoundary = options.routeModule?.ErrorBoundary ?? shellModule?.ErrorBoundary;
|
|
182
|
+
if (!ErrorBoundary) {
|
|
183
|
+
const message = routeErrorWithDiagnostics.status >= 500 && !exposeDetails ? "Internal Server Error" : routeErrorWithDiagnostics.message;
|
|
184
|
+
const diagnostics = exposeDetails && routeErrorWithDiagnostics.diagnostics ? `\n\n${JSON.stringify(routeErrorWithDiagnostics.diagnostics, null, 2)}` : "";
|
|
185
|
+
return withDefaultSecurityHeaders(new Response(`${message}${diagnostics}`, {
|
|
186
|
+
status: routeErrorWithDiagnostics.status,
|
|
187
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
const head = shellModule?.head ? await shellModule.head(options.routeArgs) : {};
|
|
191
|
+
const documentHeaders = await mergeDocumentHeaders(shellModule, void 0, options.routeArgs, void 0);
|
|
192
|
+
const cssUrls = resolvePageCssUrls(options.options.cssManifest, options.shellFile, options.routeArgs.route.file);
|
|
193
|
+
const modulePreloadUrls = resolvePageJsUrls(options.options.jsManifest, options.shellFile, options.routeArgs.route.file);
|
|
194
|
+
const renderToString = await getRenderToStringAsync();
|
|
195
|
+
const Boundary = ErrorBoundary;
|
|
196
|
+
const Shell = shellModule?.Shell;
|
|
197
|
+
const errorValue = deserializeRouteError(routeErrorWithDiagnostics);
|
|
198
|
+
const componentTree = Shell ? h(Shell, null, h(Boundary, { error: errorValue })) : h(Boundary, { error: errorValue });
|
|
199
|
+
return htmlResponse(buildHtmlDocument({
|
|
200
|
+
head,
|
|
201
|
+
body: await renderToString(h(PrachtRuntimeProvider, {
|
|
202
|
+
data: null,
|
|
203
|
+
routeId: options.routeId,
|
|
204
|
+
routes: options.routes,
|
|
205
|
+
url: options.requestPath
|
|
206
|
+
}, componentTree)),
|
|
207
|
+
hydrationState: {
|
|
208
|
+
url: options.requestPath,
|
|
209
|
+
routeId: options.routeId,
|
|
210
|
+
data: null,
|
|
211
|
+
error: routeErrorWithDiagnostics
|
|
212
|
+
},
|
|
213
|
+
clientEntryUrl: options.options.clientEntryUrl,
|
|
214
|
+
cssUrls,
|
|
215
|
+
modulePreloadUrls
|
|
216
|
+
}), routeErrorWithDiagnostics.status, documentHeaders);
|
|
217
|
+
}
|
|
218
|
+
//#endregion
|
|
219
|
+
//#region src/runtime-negotiation.ts
|
|
220
|
+
const MARKDOWN_MEDIA_TYPE = "text/markdown";
|
|
221
|
+
function parseAccept(header) {
|
|
222
|
+
if (!header) return [];
|
|
223
|
+
const entries = [];
|
|
224
|
+
for (const raw of header.split(",")) {
|
|
225
|
+
const parts = raw.trim().split(";");
|
|
226
|
+
const type = parts.shift()?.trim().toLowerCase();
|
|
227
|
+
if (!type) continue;
|
|
228
|
+
let quality = 1;
|
|
229
|
+
for (const param of parts) {
|
|
230
|
+
const [key, value] = param.split("=").map((p) => p.trim());
|
|
231
|
+
if (key === "q" && value != null) {
|
|
232
|
+
const parsed = Number.parseFloat(value);
|
|
233
|
+
if (!Number.isNaN(parsed)) quality = parsed;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
entries.push({
|
|
237
|
+
type,
|
|
238
|
+
quality
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return entries;
|
|
242
|
+
}
|
|
243
|
+
function prefersMarkdown(accept) {
|
|
244
|
+
const entries = parseAccept(accept);
|
|
245
|
+
if (!entries.length) return false;
|
|
246
|
+
const md = entries.find((e) => e.type === MARKDOWN_MEDIA_TYPE);
|
|
247
|
+
if (!md || md.quality === 0) return false;
|
|
248
|
+
const html = entries.find((e) => e.type === "text/html");
|
|
249
|
+
if (!html) return true;
|
|
250
|
+
return md.quality >= html.quality;
|
|
251
|
+
}
|
|
252
|
+
function markdownResponse(source) {
|
|
253
|
+
const headers = new Headers({
|
|
254
|
+
"content-type": "text/markdown; charset=utf-8",
|
|
255
|
+
"cache-control": "public, max-age=0, must-revalidate"
|
|
256
|
+
});
|
|
257
|
+
appendVaryHeader(headers, "Accept");
|
|
258
|
+
applyDefaultSecurityHeaders(headers);
|
|
259
|
+
return new Response(source, {
|
|
260
|
+
status: 200,
|
|
261
|
+
headers
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
//#endregion
|
|
265
|
+
//#region src/runtime.ts
|
|
266
|
+
const SAME_ORIGIN_FETCH_SITE = "same-origin";
|
|
267
|
+
/**
|
|
268
|
+
* Stricter variant of first-party detection used for CSRF protection on
|
|
269
|
+
* state-changing API requests. It rejects any browser signal that points
|
|
270
|
+
* outside this exact origin — a cross-origin form POST will send `Origin`
|
|
271
|
+
* from the attacker, and `Sec-Fetch-Site: same-site` is not enough because
|
|
272
|
+
* sibling subdomains can be attacker-controlled. Requests with no browser
|
|
273
|
+
* provenance headers are treated as non-browser callers.
|
|
274
|
+
*/
|
|
275
|
+
function isSameOriginMutation(request, url) {
|
|
276
|
+
const site = request.headers.get("sec-fetch-site");
|
|
277
|
+
if (site && site !== SAME_ORIGIN_FETCH_SITE) return false;
|
|
278
|
+
const origin = request.headers.get("origin");
|
|
279
|
+
if (origin) try {
|
|
280
|
+
return new URL(origin).origin === url.origin;
|
|
281
|
+
} catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
if (site === SAME_ORIGIN_FETCH_SITE) return true;
|
|
285
|
+
const referer = request.headers.get("referer");
|
|
286
|
+
if (referer) try {
|
|
287
|
+
return new URL(referer).origin === url.origin;
|
|
288
|
+
} catch {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Heuristic "this request came from our own page" check. Used to gate
|
|
295
|
+
* the `_data=1` query-param form of the route-state endpoint, which is
|
|
296
|
+
* otherwise reachable via any cross-origin `<a href>` / redirect.
|
|
297
|
+
*
|
|
298
|
+
* Accepts a request as first-party when:
|
|
299
|
+
* - Sec-Fetch-Site is `same-origin` (modern browsers),
|
|
300
|
+
* - OR Sec-Fetch-Site is absent AND the Origin header matches the
|
|
301
|
+
* request URL's origin (older clients that still send Origin),
|
|
302
|
+
* - OR Sec-Fetch-Site/Origin are absent AND Referer matches the request
|
|
303
|
+
* URL's origin,
|
|
304
|
+
* - OR no Origin/Sec-Fetch-Site/Referer is present (non-browser clients like
|
|
305
|
+
* curl — CSRF is not the threat model there; blocking would break
|
|
306
|
+
* tests and CLIs).
|
|
307
|
+
*/
|
|
308
|
+
function isFirstPartyFetch(request) {
|
|
309
|
+
const site = request.headers.get("sec-fetch-site");
|
|
310
|
+
if (site && site !== SAME_ORIGIN_FETCH_SITE) return false;
|
|
311
|
+
const origin = request.headers.get("origin");
|
|
312
|
+
if (origin) try {
|
|
313
|
+
return new URL(origin).origin === new URL(request.url).origin;
|
|
314
|
+
} catch {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
if (site === SAME_ORIGIN_FETCH_SITE) return true;
|
|
318
|
+
const referer = request.headers.get("referer");
|
|
319
|
+
if (referer) try {
|
|
320
|
+
return new URL(referer).origin === new URL(request.url).origin;
|
|
321
|
+
} catch {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
async function handlePrachtRequest(options) {
|
|
327
|
+
const url = new URL(options.request.url);
|
|
328
|
+
const hasDataParam = url.searchParams.get("_data") === "1";
|
|
329
|
+
if (hasDataParam) url.searchParams.delete("_data");
|
|
330
|
+
const requestPath = getRequestPath(url);
|
|
331
|
+
const registry = options.registry ?? {};
|
|
332
|
+
const resolvedApp = getResolvedApp(options.app);
|
|
333
|
+
const headerSignalsRouteState = options.request.headers.get(ROUTE_STATE_REQUEST_HEADER) === "1";
|
|
334
|
+
const dataParamIsFirstParty = hasDataParam && isFirstPartyFetch(options.request);
|
|
335
|
+
const isRouteStateRequest = headerSignalsRouteState || dataParamIsFirstParty;
|
|
336
|
+
const exposeDiagnostics = shouldExposeServerErrors(options);
|
|
337
|
+
if (options.apiRoutes?.length) {
|
|
338
|
+
const apiMatch = matchApiRoute(options.apiRoutes, url.pathname);
|
|
339
|
+
if (apiMatch) {
|
|
340
|
+
const apiMiddlewareFiles = (options.app.api.middleware ?? []).flatMap((name) => {
|
|
341
|
+
const middlewareFile = options.app.middleware[name];
|
|
342
|
+
return middlewareFile ? [middlewareFile] : [];
|
|
343
|
+
});
|
|
344
|
+
let currentPhase = "middleware";
|
|
345
|
+
if ((options.app.api.requireSameOrigin ?? true) && !SAFE_METHODS.has(options.request.method) && !isSameOriginMutation(options.request, url)) return withDefaultSecurityHeaders(new Response("Cross-origin request blocked", {
|
|
346
|
+
status: 403,
|
|
347
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
348
|
+
}));
|
|
349
|
+
const requestSignal = AbortSignal.timeout(3e4);
|
|
350
|
+
const apiContext = options.context ?? {};
|
|
351
|
+
const apiTerminal = async () => {
|
|
352
|
+
currentPhase = "api";
|
|
353
|
+
const apiModule = await resolveRegistryModule(registry.apiModules, apiMatch.route.file);
|
|
354
|
+
if (!apiModule) throw new Error("API route module not found");
|
|
355
|
+
const handler = apiModule[options.request.method.toUpperCase()] ?? apiModule.default;
|
|
356
|
+
if (!handler) return new Response("Method not allowed", {
|
|
357
|
+
status: 405,
|
|
358
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
359
|
+
});
|
|
360
|
+
return handler({
|
|
361
|
+
request: options.request,
|
|
362
|
+
params: apiMatch.params,
|
|
363
|
+
context: apiContext,
|
|
364
|
+
signal: requestSignal,
|
|
365
|
+
url,
|
|
366
|
+
route: apiMatch.route
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
try {
|
|
370
|
+
return withDefaultSecurityHeaders(await runMiddlewareChain({
|
|
371
|
+
context: apiContext,
|
|
372
|
+
middlewareFiles: apiMiddlewareFiles,
|
|
373
|
+
params: apiMatch.params,
|
|
374
|
+
registry,
|
|
375
|
+
request: options.request,
|
|
376
|
+
route: apiMatch.route,
|
|
377
|
+
signal: requestSignal,
|
|
378
|
+
url,
|
|
379
|
+
terminal: apiTerminal
|
|
380
|
+
}));
|
|
381
|
+
} catch (error) {
|
|
382
|
+
return renderApiErrorResponse({
|
|
383
|
+
error,
|
|
384
|
+
middlewareFiles: apiMiddlewareFiles,
|
|
385
|
+
options,
|
|
386
|
+
phase: currentPhase,
|
|
387
|
+
route: apiMatch.route
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const match = matchAppRoute(resolvedApp, url.pathname);
|
|
393
|
+
if (!match) {
|
|
394
|
+
if (isRouteStateRequest) return jsonErrorResponse(createSerializedRouteError("Not found", 404, {
|
|
395
|
+
diagnostics: exposeDiagnostics ? buildRuntimeDiagnostics({
|
|
396
|
+
phase: "match",
|
|
397
|
+
status: 404
|
|
398
|
+
}) : void 0,
|
|
399
|
+
name: "Error"
|
|
400
|
+
}), { isRouteStateRequest: true });
|
|
401
|
+
return withDefaultSecurityHeaders(new Response("Not found", {
|
|
402
|
+
status: 404,
|
|
403
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
404
|
+
}));
|
|
405
|
+
}
|
|
406
|
+
if (!SAFE_METHODS.has(options.request.method)) {
|
|
407
|
+
if (isRouteStateRequest) return jsonErrorResponse(createSerializedRouteError("Method not allowed", 405, {
|
|
408
|
+
diagnostics: exposeDiagnostics ? buildRuntimeDiagnostics({
|
|
409
|
+
middlewareFiles: match.route.middlewareFiles,
|
|
410
|
+
phase: "action",
|
|
411
|
+
route: match.route,
|
|
412
|
+
shellFile: match.route.shellFile,
|
|
413
|
+
status: 405
|
|
414
|
+
}) : void 0,
|
|
415
|
+
name: "Error"
|
|
416
|
+
}), { isRouteStateRequest: true });
|
|
417
|
+
return withRouteResponseHeaders(new Response("Method not allowed", {
|
|
418
|
+
status: 405,
|
|
419
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
420
|
+
}), { isRouteStateRequest });
|
|
421
|
+
}
|
|
422
|
+
const requestSignal = AbortSignal.timeout(3e4);
|
|
423
|
+
const pageContext = options.context ?? {};
|
|
424
|
+
const routeArgs = {
|
|
425
|
+
request: options.request,
|
|
426
|
+
params: match.params,
|
|
427
|
+
context: pageContext,
|
|
428
|
+
signal: requestSignal,
|
|
429
|
+
url,
|
|
430
|
+
route: match.route
|
|
431
|
+
};
|
|
432
|
+
let routeModule;
|
|
433
|
+
let shellModule;
|
|
434
|
+
let loaderFile;
|
|
435
|
+
let currentPhase = "middleware";
|
|
436
|
+
try {
|
|
437
|
+
const routeModulePromise = resolveRegistryModule(registry.routeModules, match.route.file);
|
|
438
|
+
const shellModulePromise = match.route.shellFile ? resolveRegistryModule(registry.shellModules, match.route.shellFile) : Promise.resolve(void 0);
|
|
439
|
+
const dataFunctionsPromise = routeModulePromise.then((mod) => resolveDataFunctions(match.route, mod, registry));
|
|
440
|
+
routeModulePromise.catch(() => {});
|
|
441
|
+
shellModulePromise.catch(() => {});
|
|
442
|
+
dataFunctionsPromise.catch(() => {});
|
|
443
|
+
const pageTerminal = async () => {
|
|
444
|
+
currentPhase = "render";
|
|
445
|
+
routeModule = await routeModulePromise;
|
|
446
|
+
if (!routeModule) throw new Error("Route module not found");
|
|
447
|
+
if (!isRouteStateRequest && typeof routeModule.markdown === "string" && prefersMarkdown(options.request.headers.get("accept"))) return markdownResponse(routeModule.markdown);
|
|
448
|
+
currentPhase = "loader";
|
|
449
|
+
const { loader, loaderFile: resolvedLoaderFile } = await dataFunctionsPromise;
|
|
450
|
+
loaderFile = resolvedLoaderFile;
|
|
451
|
+
const loaderResult = loader ? await loader(routeArgs) : void 0;
|
|
452
|
+
if (loaderResult instanceof Response) return loaderResult;
|
|
453
|
+
const data = loaderResult;
|
|
454
|
+
if (isRouteStateRequest) return Response.json({ data });
|
|
455
|
+
currentPhase = "render";
|
|
456
|
+
shellModule = await shellModulePromise;
|
|
457
|
+
const [head, documentHeaders] = await Promise.all([mergeHeadMetadata(shellModule, routeModule, routeArgs, data), mergeDocumentHeaders(shellModule, routeModule, routeArgs, data)]);
|
|
458
|
+
const cssUrls = resolvePageCssUrls(options.cssManifest, match.route.shellFile, match.route.file);
|
|
459
|
+
const modulePreloadUrls = resolvePageJsUrls(options.jsManifest, match.route.shellFile, match.route.file);
|
|
460
|
+
if (match.route.render === "spa") {
|
|
461
|
+
let body = "";
|
|
462
|
+
const Shell = shellModule?.Shell;
|
|
463
|
+
const Loading = shellModule?.Loading;
|
|
464
|
+
const loadingTree = Shell != null ? h(Shell, null, Loading ? h(Loading, null) : null) : Loading ? h(Loading, null) : null;
|
|
465
|
+
if (loadingTree) {
|
|
466
|
+
const tree = h(PrachtRuntimeProvider, {
|
|
467
|
+
data: null,
|
|
468
|
+
params: match.params,
|
|
469
|
+
routeId: match.route.id ?? "",
|
|
470
|
+
routes: resolvedApp.routes,
|
|
471
|
+
url: requestPath
|
|
472
|
+
}, loadingTree);
|
|
473
|
+
body = await (await getRenderToStringAsync())(tree);
|
|
474
|
+
}
|
|
475
|
+
return htmlResponse(buildHtmlDocument({
|
|
476
|
+
head,
|
|
477
|
+
body,
|
|
478
|
+
hydrationState: {
|
|
479
|
+
url: requestPath,
|
|
480
|
+
routeId: match.route.id ?? "",
|
|
481
|
+
data: null,
|
|
482
|
+
error: null,
|
|
483
|
+
pending: true
|
|
484
|
+
},
|
|
485
|
+
clientEntryUrl: options.clientEntryUrl,
|
|
486
|
+
cssUrls,
|
|
487
|
+
modulePreloadUrls,
|
|
488
|
+
routeStatePreloadUrl: loader ? buildRouteStateUrl(requestPath) : void 0
|
|
489
|
+
}), 200, documentHeaders);
|
|
490
|
+
}
|
|
491
|
+
const DefaultComponent = typeof routeModule.default === "function" ? routeModule.default : void 0;
|
|
492
|
+
const Component = routeModule.Component ?? DefaultComponent;
|
|
493
|
+
if (!Component) throw new Error("Route has no Component or default export");
|
|
494
|
+
const Shell = shellModule?.Shell;
|
|
495
|
+
const Comp = Component;
|
|
496
|
+
const componentProps = {
|
|
497
|
+
data,
|
|
498
|
+
params: match.params
|
|
499
|
+
};
|
|
500
|
+
const componentTree = Shell ? h(Shell, null, h(Comp, componentProps)) : h(Comp, componentProps);
|
|
501
|
+
const tree = h(PrachtRuntimeProvider, {
|
|
502
|
+
data,
|
|
503
|
+
params: match.params,
|
|
504
|
+
routeId: match.route.id ?? "",
|
|
505
|
+
routes: resolvedApp.routes,
|
|
506
|
+
url: requestPath
|
|
507
|
+
}, componentTree);
|
|
508
|
+
return htmlResponse(buildHtmlDocument({
|
|
509
|
+
head,
|
|
510
|
+
body: await (await getRenderToStringAsync())(tree),
|
|
511
|
+
hydrationState: {
|
|
512
|
+
url: requestPath,
|
|
513
|
+
routeId: match.route.id ?? "",
|
|
514
|
+
data,
|
|
515
|
+
error: null
|
|
516
|
+
},
|
|
517
|
+
clientEntryUrl: options.clientEntryUrl,
|
|
518
|
+
cssUrls,
|
|
519
|
+
modulePreloadUrls
|
|
520
|
+
}), 200, documentHeaders);
|
|
521
|
+
};
|
|
522
|
+
return normalizePageResponse(await runMiddlewareChain({
|
|
523
|
+
context: pageContext,
|
|
524
|
+
middlewareFiles: match.route.middlewareFiles,
|
|
525
|
+
params: match.params,
|
|
526
|
+
registry,
|
|
527
|
+
request: options.request,
|
|
528
|
+
route: match.route,
|
|
529
|
+
signal: requestSignal,
|
|
530
|
+
url,
|
|
531
|
+
terminal: pageTerminal
|
|
532
|
+
}), { isRouteStateRequest });
|
|
533
|
+
} catch (error) {
|
|
534
|
+
return renderRouteErrorResponse({
|
|
535
|
+
error,
|
|
536
|
+
isRouteStateRequest,
|
|
537
|
+
loaderFile,
|
|
538
|
+
options,
|
|
539
|
+
phase: currentPhase,
|
|
540
|
+
routeArgs,
|
|
541
|
+
routeId: match.route.id ?? "",
|
|
542
|
+
routeModule,
|
|
543
|
+
routes: resolvedApp.routes,
|
|
544
|
+
shellFile: match.route.shellFile,
|
|
545
|
+
shellModule,
|
|
546
|
+
requestPath
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function getRequestPath(url) {
|
|
551
|
+
return `${url.pathname}${url.search}`;
|
|
552
|
+
}
|
|
553
|
+
function getResolvedApp(app) {
|
|
554
|
+
const routes = app.routes;
|
|
555
|
+
if (routes.length === 0 || isHrefRouteDefinition(routes[0])) return app;
|
|
556
|
+
return resolveApp(app);
|
|
557
|
+
}
|
|
558
|
+
function isHrefRouteDefinition(value) {
|
|
559
|
+
return Boolean(value && typeof value === "object" && "path" in value && "segments" in value && Array.isArray(value.segments));
|
|
560
|
+
}
|
|
561
|
+
//#endregion
|
|
562
|
+
//#region src/prerender.ts
|
|
563
|
+
const DANGEROUS_PRERENDER_HEADER_NAMES = new Set([
|
|
564
|
+
"authorization",
|
|
565
|
+
"proxy-authenticate",
|
|
566
|
+
"proxy-authorization",
|
|
567
|
+
"set-cookie",
|
|
568
|
+
"www-authenticate"
|
|
569
|
+
]);
|
|
570
|
+
const SECRET_SHAPED_PRERENDER_HEADER_RE = /^x-.*(?:api[-_]?key|client[-_]?secret|credential|jwt[-_]?secret|password|private[-_]?key|refresh[-_]?token|secret|session[-_]?secret|token|webhook[-_]?secret)(?:$|[-_])/i;
|
|
571
|
+
async function prerenderApp(options) {
|
|
572
|
+
const resolved = resolveApp(options.app);
|
|
573
|
+
const results = [];
|
|
574
|
+
const isgManifest = {};
|
|
575
|
+
const work = [];
|
|
576
|
+
for (const route of resolved.routes) {
|
|
577
|
+
if (route.render !== "ssg" && route.render !== "isg") continue;
|
|
578
|
+
const paths = await collectSSGPaths(route, options.registry);
|
|
579
|
+
for (const pathname of paths) work.push({
|
|
580
|
+
pathname,
|
|
581
|
+
render: route.render,
|
|
582
|
+
revalidate: route.revalidate
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
const concurrency = options.concurrency ?? 10;
|
|
586
|
+
if (!Number.isInteger(concurrency) || concurrency <= 0) throw new Error("prerenderApp({ concurrency }) expects a positive integer.");
|
|
587
|
+
for (let i = 0; i < work.length; i += concurrency) {
|
|
588
|
+
const batch = work.slice(i, i + concurrency);
|
|
589
|
+
const batchResults = await Promise.all(batch.map(async (item) => {
|
|
590
|
+
const url = new URL(item.pathname, "http://localhost");
|
|
591
|
+
const request = new Request(url, { method: "GET" });
|
|
592
|
+
const response = await handlePrachtRequest({
|
|
593
|
+
app: options.app,
|
|
594
|
+
request,
|
|
595
|
+
registry: options.registry,
|
|
596
|
+
clientEntryUrl: options.clientEntryUrl,
|
|
597
|
+
cssManifest: options.cssManifest,
|
|
598
|
+
jsManifest: options.jsManifest
|
|
599
|
+
});
|
|
600
|
+
if (response.status !== 200) {
|
|
601
|
+
console.warn(` Warning: ${item.render.toUpperCase()} route "${item.pathname}" returned status ${response.status}, skipping.`);
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
assertSafePrerenderHeaders(response.headers, item);
|
|
605
|
+
const html = await response.text();
|
|
606
|
+
return {
|
|
607
|
+
headers: Object.fromEntries(response.headers),
|
|
608
|
+
html,
|
|
609
|
+
item
|
|
610
|
+
};
|
|
611
|
+
}));
|
|
612
|
+
for (const result of batchResults) {
|
|
613
|
+
if (!result) continue;
|
|
614
|
+
results.push({
|
|
615
|
+
path: result.item.pathname,
|
|
616
|
+
html: result.html,
|
|
617
|
+
headers: result.headers
|
|
618
|
+
});
|
|
619
|
+
if (result.item.render === "isg" && result.item.revalidate) isgManifest[result.item.pathname] = { revalidate: result.item.revalidate };
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (options.withISGManifest) return {
|
|
623
|
+
pages: results,
|
|
624
|
+
isgManifest
|
|
625
|
+
};
|
|
626
|
+
return results;
|
|
627
|
+
}
|
|
628
|
+
function assertSafePrerenderHeaders(headers, item) {
|
|
629
|
+
const dangerous = [...headers.keys()].filter(isDangerousPrerenderHeader);
|
|
630
|
+
if (dangerous.length === 0) return;
|
|
631
|
+
const names = dangerous.map((name) => `"${name}"`).join(", ");
|
|
632
|
+
throw new Error(`Refusing to prerender ${item.render.toUpperCase()} route "${item.pathname}" because its document headers include ${names}. SSG/ISG document headers are serialized into public static output and replayed for every visitor. Move cookies/authentication headers to API routes, loaders, middleware responses, or SSR-only routes.`);
|
|
633
|
+
}
|
|
634
|
+
function isDangerousPrerenderHeader(name) {
|
|
635
|
+
const normalized = name.toLowerCase();
|
|
636
|
+
return DANGEROUS_PRERENDER_HEADER_NAMES.has(normalized) || SECRET_SHAPED_PRERENDER_HEADER_RE.test(normalized);
|
|
637
|
+
}
|
|
638
|
+
async function collectSSGPaths(route, registry) {
|
|
639
|
+
if (!route.segments.some((s) => s.type === "param" || s.type === "catchall")) return [route.path];
|
|
640
|
+
const routeModule = await resolveRegistryModule(registry?.routeModules, route.file);
|
|
641
|
+
if (!routeModule?.getStaticPaths) {
|
|
642
|
+
console.warn(` Warning: SSG route "${route.path}" has dynamic segments but no getStaticPaths() export, skipping.`);
|
|
643
|
+
return [];
|
|
644
|
+
}
|
|
645
|
+
return (await routeModule.getStaticPaths()).map((params) => buildPathFromSegments(route.segments, params));
|
|
646
|
+
}
|
|
647
|
+
//#endregion
|
|
648
|
+
export { handlePrachtRequest as n, prerenderApp as t };
|