@pracht/core 0.7.0 → 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.
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 };