bosia 0.2.3 → 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.
Files changed (86) hide show
  1. package/README.md +39 -39
  2. package/package.json +56 -54
  3. package/src/ambient.d.ts +31 -0
  4. package/src/cli/add.ts +120 -114
  5. package/src/cli/build.ts +10 -10
  6. package/src/cli/create.ts +142 -137
  7. package/src/cli/dev.ts +8 -8
  8. package/src/cli/feat.ts +266 -258
  9. package/src/cli/index.ts +51 -42
  10. package/src/cli/registry.ts +136 -115
  11. package/src/cli/start.ts +17 -17
  12. package/src/cli/test.ts +25 -0
  13. package/src/core/build.ts +72 -56
  14. package/src/core/client/App.svelte +177 -156
  15. package/src/core/client/appState.svelte.ts +33 -31
  16. package/src/core/client/enhance.ts +83 -78
  17. package/src/core/client/hydrate.ts +95 -81
  18. package/src/core/client/prefetch.ts +101 -94
  19. package/src/core/client/router.svelte.ts +64 -51
  20. package/src/core/cookies.ts +70 -66
  21. package/src/core/cors.ts +44 -35
  22. package/src/core/csrf.ts +38 -38
  23. package/src/core/dedup.ts +17 -17
  24. package/src/core/dev.ts +165 -168
  25. package/src/core/env.ts +155 -148
  26. package/src/core/envCodegen.ts +73 -73
  27. package/src/core/errors.ts +48 -49
  28. package/src/core/hooks.ts +50 -50
  29. package/src/core/html.ts +184 -145
  30. package/src/core/matcher.ts +130 -121
  31. package/src/core/paths.ts +8 -10
  32. package/src/core/plugin.ts +113 -107
  33. package/src/core/prerender.ts +191 -122
  34. package/src/core/renderer.ts +359 -286
  35. package/src/core/routeFile.ts +140 -127
  36. package/src/core/routeTypes.ts +144 -83
  37. package/src/core/scanner.ts +125 -95
  38. package/src/core/server.ts +538 -424
  39. package/src/core/types.ts +25 -20
  40. package/src/lib/index.ts +8 -8
  41. package/src/lib/utils.ts +44 -30
  42. package/templates/default/.prettierignore +5 -0
  43. package/templates/default/.prettierrc.json +9 -0
  44. package/templates/default/README.md +5 -5
  45. package/templates/default/package.json +22 -18
  46. package/templates/default/src/app.css +80 -80
  47. package/templates/default/src/app.d.ts +3 -3
  48. package/templates/default/src/routes/+error.svelte +7 -10
  49. package/templates/default/src/routes/+layout.svelte +2 -2
  50. package/templates/default/src/routes/+page.svelte +31 -29
  51. package/templates/default/src/routes/about/+page.svelte +3 -3
  52. package/templates/default/tsconfig.json +20 -20
  53. package/templates/demo/.prettierignore +5 -0
  54. package/templates/demo/.prettierrc.json +9 -0
  55. package/templates/demo/README.md +9 -9
  56. package/templates/demo/package.json +22 -17
  57. package/templates/demo/src/app.css +80 -80
  58. package/templates/demo/src/app.d.ts +3 -3
  59. package/templates/demo/src/hooks.server.ts +9 -9
  60. package/templates/demo/src/routes/(public)/+layout.svelte +45 -23
  61. package/templates/demo/src/routes/(public)/+page.svelte +96 -67
  62. package/templates/demo/src/routes/(public)/about/+page.svelte +13 -25
  63. package/templates/demo/src/routes/(public)/all/[...catchall]/+page.svelte +24 -28
  64. package/templates/demo/src/routes/(public)/blog/+page.svelte +55 -46
  65. package/templates/demo/src/routes/(public)/blog/[slug]/+page.server.ts +36 -38
  66. package/templates/demo/src/routes/(public)/blog/[slug]/+page.svelte +60 -42
  67. package/templates/demo/src/routes/+error.svelte +10 -7
  68. package/templates/demo/src/routes/+layout.server.ts +4 -4
  69. package/templates/demo/src/routes/+layout.svelte +2 -2
  70. package/templates/demo/src/routes/actions-test/+page.server.ts +16 -16
  71. package/templates/demo/src/routes/actions-test/+page.svelte +49 -49
  72. package/templates/demo/src/routes/api/hello/+server.ts +25 -25
  73. package/templates/demo/tsconfig.json +20 -20
  74. package/templates/todo/.prettierignore +5 -0
  75. package/templates/todo/.prettierrc.json +9 -0
  76. package/templates/todo/README.md +9 -9
  77. package/templates/todo/package.json +22 -17
  78. package/templates/todo/src/app.css +80 -80
  79. package/templates/todo/src/app.d.ts +7 -7
  80. package/templates/todo/src/hooks.server.ts +9 -9
  81. package/templates/todo/src/routes/+error.svelte +10 -7
  82. package/templates/todo/src/routes/+layout.server.ts +4 -4
  83. package/templates/todo/src/routes/+layout.svelte +2 -2
  84. package/templates/todo/src/routes/+page.svelte +44 -44
  85. package/templates/todo/template.json +1 -1
  86. package/templates/todo/tsconfig.json +20 -20
@@ -5,36 +5,43 @@ import { serverRoutes, errorPage } from "bosia:routes";
5
5
  import type { Cookies } from "./hooks.ts";
6
6
  import { HttpError, Redirect } from "./errors.ts";
7
7
  import App from "./client/App.svelte";
8
- import { buildHtml, buildHtmlShellOpen, buildMetadataChunk, buildHtmlTail, compress, isDev } from "./html.ts";
8
+ import {
9
+ buildHtml,
10
+ buildHtmlShellOpen,
11
+ buildMetadataChunk,
12
+ buildHtmlTail,
13
+ compress,
14
+ isDev,
15
+ } from "./html.ts";
9
16
  import type { Metadata } from "./hooks.ts";
10
17
 
11
18
  // ─── Timeout Helpers ─────────────────────────────────────
12
19
 
13
20
  class LoadTimeoutError extends Error {
14
- constructor(label: string, ms: number) {
15
- super(`${label} timed out after ${ms}ms`);
16
- this.name = "LoadTimeoutError";
17
- }
21
+ constructor(label: string, ms: number) {
22
+ super(`${label} timed out after ${ms}ms`);
23
+ this.name = "LoadTimeoutError";
24
+ }
18
25
  }
19
26
 
20
27
  function parseTimeout(raw: string | undefined, fallback: number): number {
21
- if (!raw || raw === "Infinity") return 0;
22
- const n = parseInt(raw, 10);
23
- return Number.isFinite(n) && n > 0 ? n : fallback;
28
+ if (!raw || raw === "Infinity") return 0;
29
+ const n = parseInt(raw, 10);
30
+ return Number.isFinite(n) && n > 0 ? n : fallback;
24
31
  }
25
32
 
26
33
  const LOAD_TIMEOUT = parseTimeout(process.env.LOAD_TIMEOUT, 5000);
27
34
  const METADATA_TIMEOUT = parseTimeout(process.env.METADATA_TIMEOUT, 3000);
28
35
 
29
36
  function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
30
- if (ms <= 0) return promise;
31
- let timer: Timer;
32
- return Promise.race([
33
- promise.finally(() => clearTimeout(timer)),
34
- new Promise<never>((_, reject) =>
35
- timer = setTimeout(() => reject(new LoadTimeoutError(label, ms)), ms)
36
- ),
37
- ]);
37
+ if (ms <= 0) return promise;
38
+ let timer: Timer;
39
+ return Promise.race([
40
+ promise.finally(() => clearTimeout(timer)),
41
+ new Promise<never>(
42
+ (_, reject) => (timer = setTimeout(() => reject(new LoadTimeoutError(label, ms)), ms)),
43
+ ),
44
+ ]);
38
45
  }
39
46
 
40
47
  // ─── Internal-Host Allowlist ─────────────────────────────
@@ -43,20 +50,23 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
43
50
  // (third-party APIs) gets no Cookie header by default.
44
51
 
45
52
  const INTERNAL_HOSTS: Set<string> = (() => {
46
- const raw = process.env.INTERNAL_HOSTS?.split(",").map(s => s.trim()).filter(Boolean) ?? [];
47
- const valid = new Set<string>();
48
- for (const entry of raw) {
49
- try {
50
- valid.add(new URL(entry).origin);
51
- } catch {
52
- console.warn(`⚠️ INTERNAL_HOSTS: ignoring invalid origin "${entry}"`);
53
- }
54
- }
55
- return valid;
53
+ const raw =
54
+ process.env.INTERNAL_HOSTS?.split(",")
55
+ .map((s) => s.trim())
56
+ .filter(Boolean) ?? [];
57
+ const valid = new Set<string>();
58
+ for (const entry of raw) {
59
+ try {
60
+ valid.add(new URL(entry).origin);
61
+ } catch {
62
+ console.warn(`⚠️ INTERNAL_HOSTS: ignoring invalid origin "${entry}"`);
63
+ }
64
+ }
65
+ return valid;
56
66
  })();
57
67
 
58
68
  if (INTERNAL_HOSTS.size > 0) {
59
- console.log(`🍪 Internal hosts (cookies forwarded): ${[...INTERNAL_HOSTS].join(", ")}`);
69
+ console.log(`🍪 Internal hosts (cookies forwarded): ${[...INTERNAL_HOSTS].join(", ")}`);
60
70
  }
61
71
 
62
72
  // ─── Session-Aware Fetch ─────────────────────────────────
@@ -66,33 +76,35 @@ if (INTERNAL_HOSTS.size > 0) {
66
76
  // to arbitrary third-party hosts (which would leak the session token).
67
77
 
68
78
  function makeFetch(req: Request, url: URL) {
69
- const cookie = req.headers.get("cookie") ?? "";
70
- const sameOrigin = url.origin;
71
-
72
- return (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
73
- let targetOrigin: string | null = null;
74
- let resolved: RequestInfo | URL = input;
75
-
76
- try {
77
- if (typeof input === "string") {
78
- const parsed = new URL(input, sameOrigin);
79
- targetOrigin = parsed.origin;
80
- resolved = parsed.href;
81
- } else if (input instanceof URL) {
82
- targetOrigin = input.origin;
83
- } else {
84
- targetOrigin = new URL(input.url).origin;
85
- }
86
- } catch {
87
- targetOrigin = null;
88
- }
89
-
90
- const headers = new Headers(init?.headers);
91
- const trusted = targetOrigin !== null && (targetOrigin === sameOrigin || INTERNAL_HOSTS.has(targetOrigin));
92
- if (cookie && trusted && !headers.has("cookie")) headers.set("cookie", cookie);
93
-
94
- return globalThis.fetch(resolved, { ...init, headers });
95
- };
79
+ const cookie = req.headers.get("cookie") ?? "";
80
+ const sameOrigin = url.origin;
81
+
82
+ return (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
83
+ let targetOrigin: string | null = null;
84
+ let resolved: RequestInfo | URL = input;
85
+
86
+ try {
87
+ if (typeof input === "string") {
88
+ const parsed = new URL(input, sameOrigin);
89
+ targetOrigin = parsed.origin;
90
+ resolved = parsed.href;
91
+ } else if (input instanceof URL) {
92
+ targetOrigin = input.origin;
93
+ } else {
94
+ targetOrigin = new URL(input.url).origin;
95
+ }
96
+ } catch {
97
+ targetOrigin = null;
98
+ }
99
+
100
+ const headers = new Headers(init?.headers);
101
+ const trusted =
102
+ targetOrigin !== null &&
103
+ (targetOrigin === sameOrigin || INTERNAL_HOSTS.has(targetOrigin));
104
+ if (cookie && trusted && !headers.has("cookie")) headers.set("cookie", cookie);
105
+
106
+ return globalThis.fetch(resolved, { ...init, headers });
107
+ };
96
108
  }
97
109
 
98
110
  // ─── Route Data Loader ───────────────────────────────────
@@ -100,192 +112,234 @@ function makeFetch(req: Request, url: URL) {
100
112
  // Used by both SSR and the /__bosia/data JSON endpoint.
101
113
 
102
114
  export async function loadRouteData(
103
- url: URL,
104
- locals: Record<string, any>,
105
- req: Request,
106
- cookies: Cookies,
107
- metadataData: Record<string, any> | null = null,
115
+ url: URL,
116
+ locals: Record<string, any>,
117
+ req: Request,
118
+ cookies: Cookies,
119
+ metadataData: Record<string, any> | null = null,
108
120
  ) {
109
- const match = findMatch(serverRoutes, url.pathname);
110
- if (!match) return null;
111
-
112
- const { route, params } = match;
113
- const fetch = makeFetch(req, url);
114
- const layoutData: Record<string, any>[] = [];
115
-
116
- // Run layout server loaders root → leaf, each gets parent() data
117
- for (const ls of route.layoutServers) {
118
- try {
119
- const mod = await ls.loader();
120
- if (typeof mod.load === "function") {
121
- const parent = async () => {
122
- const merged: Record<string, any> = {};
123
- for (let d = 0; d < ls.depth; d++) Object.assign(merged, layoutData[d] ?? {});
124
- return merged;
125
- };
126
- layoutData[ls.depth] = (await withTimeout(mod.load({ params, url, locals, cookies, parent, fetch, metadata: null }), LOAD_TIMEOUT, `layout load (depth=${ls.depth}, ${url.pathname})`)) ?? {};
127
- }
128
- } catch (err) {
129
- if (err instanceof HttpError || err instanceof Redirect) throw err;
130
- if (isDev) console.error("Layout server load error:", err);
131
- else console.error("Layout server load error:", (err as Error).message ?? err);
132
- throw new HttpError(500, "Internal Server Error");
133
- }
134
- }
135
-
136
- // Run page server loader
137
- let pageData: Record<string, any> = {};
138
- let csr = true;
139
- let ssr = true;
140
- if (route.pageServer) {
141
- try {
142
- const mod = await route.pageServer();
143
- if (mod.csr === false) csr = false;
144
- if (mod.ssr === false) ssr = false;
145
- if (typeof mod.load === "function") {
146
- const parent = async () => {
147
- const merged: Record<string, any> = {};
148
- for (const d of layoutData) if (d) Object.assign(merged, d);
149
- return merged;
150
- };
151
- pageData = (await withTimeout(mod.load({ params, url, locals, cookies, parent, fetch, metadata: metadataData }), LOAD_TIMEOUT, `page load (${url.pathname})`)) ?? {};
152
- }
153
- } catch (err) {
154
- if (err instanceof HttpError || err instanceof Redirect) throw err;
155
- if (isDev) console.error("Page server load error:", err);
156
- else console.error("Page server load error:", (err as Error).message ?? err);
157
- throw new HttpError(500, "Internal Server Error");
158
- }
159
- }
160
-
161
- return { pageData: { ...pageData, params }, layoutData, csr, ssr };
121
+ const match = findMatch(serverRoutes, url.pathname);
122
+ if (!match) return null;
123
+
124
+ const { route, params } = match;
125
+ const fetch = makeFetch(req, url);
126
+ const layoutData: Record<string, any>[] = [];
127
+
128
+ // Run layout server loaders root → leaf, each gets parent() data
129
+ for (const ls of route.layoutServers) {
130
+ try {
131
+ const mod = await ls.loader();
132
+ if (typeof mod.load === "function") {
133
+ const parent = async () => {
134
+ const merged: Record<string, any> = {};
135
+ for (let d = 0; d < ls.depth; d++) Object.assign(merged, layoutData[d] ?? {});
136
+ return merged;
137
+ };
138
+ layoutData[ls.depth] =
139
+ (await withTimeout(
140
+ mod.load({ params, url, locals, cookies, parent, fetch, metadata: null }),
141
+ LOAD_TIMEOUT,
142
+ `layout load (depth=${ls.depth}, ${url.pathname})`,
143
+ )) ?? {};
144
+ }
145
+ } catch (err) {
146
+ if (err instanceof HttpError || err instanceof Redirect) throw err;
147
+ if (isDev) console.error("Layout server load error:", err);
148
+ else console.error("Layout server load error:", (err as Error).message ?? err);
149
+ throw new HttpError(500, "Internal Server Error");
150
+ }
151
+ }
152
+
153
+ // Run page server loader
154
+ let pageData: Record<string, any> = {};
155
+ let csr = true;
156
+ let ssr = true;
157
+ if (route.pageServer) {
158
+ try {
159
+ const mod = await route.pageServer();
160
+ if (mod.csr === false) csr = false;
161
+ if (mod.ssr === false) ssr = false;
162
+ if (typeof mod.load === "function") {
163
+ const parent = async () => {
164
+ const merged: Record<string, any> = {};
165
+ for (const d of layoutData) if (d) Object.assign(merged, d);
166
+ return merged;
167
+ };
168
+ pageData =
169
+ (await withTimeout(
170
+ mod.load({
171
+ params,
172
+ url,
173
+ locals,
174
+ cookies,
175
+ parent,
176
+ fetch,
177
+ metadata: metadataData,
178
+ }),
179
+ LOAD_TIMEOUT,
180
+ `page load (${url.pathname})`,
181
+ )) ?? {};
182
+ }
183
+ } catch (err) {
184
+ if (err instanceof HttpError || err instanceof Redirect) throw err;
185
+ if (isDev) console.error("Page server load error:", err);
186
+ else console.error("Page server load error:", (err as Error).message ?? err);
187
+ throw new HttpError(500, "Internal Server Error");
188
+ }
189
+ }
190
+
191
+ return { pageData: { ...pageData, params }, layoutData, csr, ssr };
162
192
  }
163
193
 
164
194
  // ─── Metadata Loader ─────────────────────────────────────
165
195
 
166
196
  export async function loadMetadata(
167
- route: any,
168
- params: Record<string, string>,
169
- url: URL,
170
- locals: Record<string, any>,
171
- cookies: Cookies,
172
- req: Request,
197
+ route: any,
198
+ params: Record<string, string>,
199
+ url: URL,
200
+ locals: Record<string, any>,
201
+ cookies: Cookies,
202
+ req: Request,
173
203
  ): Promise<Metadata | null> {
174
- if (!route.pageServer) return null;
175
- try {
176
- const mod = await route.pageServer();
177
- if (typeof mod.metadata === "function") {
178
- const fetch = makeFetch(req, url);
179
- return (await withTimeout(mod.metadata({ params, url, locals, cookies, fetch }), METADATA_TIMEOUT, `metadata (${url.pathname})`)) ?? null;
180
- }
181
- } catch (err) {
182
- if (isDev) console.error("Metadata load error:", err);
183
- else console.error("Metadata load error:", (err as Error).message ?? err);
184
- }
185
- return null;
204
+ if (!route.pageServer) return null;
205
+ try {
206
+ const mod = await route.pageServer();
207
+ if (typeof mod.metadata === "function") {
208
+ const fetch = makeFetch(req, url);
209
+ return (
210
+ (await withTimeout(
211
+ mod.metadata({ params, url, locals, cookies, fetch }),
212
+ METADATA_TIMEOUT,
213
+ `metadata (${url.pathname})`,
214
+ )) ?? null
215
+ );
216
+ }
217
+ } catch (err) {
218
+ if (isDev) console.error("Metadata load error:", err);
219
+ else console.error("Metadata load error:", (err as Error).message ?? err);
220
+ }
221
+ return null;
186
222
  }
187
223
 
188
224
  // ─── Streaming SSR Renderer ──────────────────────────────
189
225
 
190
226
  export async function renderSSRStream(
191
- url: URL,
192
- locals: Record<string, any>,
193
- req: Request,
194
- cookies: Cookies,
227
+ url: URL,
228
+ locals: Record<string, any>,
229
+ req: Request,
230
+ cookies: Cookies,
195
231
  ): Promise<Response | null> {
196
- const match = findMatch(serverRoutes, url.pathname);
197
- if (!match) return null;
198
-
199
- const { route, params } = match;
200
-
201
- // ── Pre-stream phase: resolve metadata before committing to a 200 ──
202
- // Errors here return a proper error response with correct status code.
203
- let metadata: Metadata | null = null;
204
- try {
205
- metadata = await loadMetadata(route, params, url, locals, cookies, req);
206
- } catch (err) {
207
- if (err instanceof Redirect) {
208
- return Response.redirect(err.location, err.status);
209
- }
210
- if (err instanceof HttpError) {
211
- return renderErrorPage(err.status, err.message, url, req);
212
- }
213
- if (isDev) console.error("Metadata load error:", err);
214
- else console.error("Metadata load error:", (err as Error).message ?? err);
215
- // Continue with null metadata — don't break the page for a metadata failure
216
- }
217
-
218
- // ── Pre-stream phase: run load() + module imports in parallel before committing to a 200 ──
219
- // This ensures HttpError/Redirect from load() can return a proper response before any bytes are sent.
220
- const metadataData = metadata?.data ?? null;
221
- let data: Awaited<ReturnType<typeof loadRouteData>>;
222
- let pageMod: any;
223
- let layoutMods: any[];
224
-
225
- try {
226
- [data, pageMod, layoutMods] = await Promise.all([
227
- loadRouteData(url, locals, req, cookies, metadataData),
228
- route.pageModule(),
229
- Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
230
- ]);
231
- } catch (err) {
232
- if (err instanceof Redirect) return Response.redirect(err.location, err.status);
233
- if (err instanceof HttpError) return renderErrorPage(err.status, err.message, url, req);
234
- if (isDev) console.error("SSR load error:", err);
235
- else console.error("SSR load error:", (err as Error).message ?? err);
236
- return renderErrorPage(500, "Internal Server Error", url, req);
237
- }
238
-
239
- if (!data) return renderErrorPage(404, "Not Found", url, req);
240
-
241
- const enc = new TextEncoder();
242
-
243
- const stream = new ReadableStream<Uint8Array>({
244
- async start(controller) {
245
- // Chunk 1: head opening (CSS, modulepreload — cached per lang)
246
- controller.enqueue(enc.encode(buildHtmlShellOpen(metadata?.lang)));
247
-
248
- // Chunk 2: metadata tags, close </head>, open <body> + spinner
249
- controller.enqueue(enc.encode(buildMetadataChunk(metadata)));
250
-
251
- try {
252
- if (!data!.ssr) {
253
- // ssr=false → skip render(); ship empty shell + hydration scripts.
254
- // ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
255
- if (!data!.csr && isDev) {
256
- console.warn(`⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`);
257
- }
258
- controller.enqueue(enc.encode(buildHtmlTail("", "", data!.pageData, data!.layoutData, true, null, false)));
259
- controller.close();
260
- return;
261
- }
262
-
263
- const { body, head } = render(App, {
264
- props: {
265
- ssrMode: true,
266
- ssrPageComponent: pageMod.default,
267
- ssrLayoutComponents: layoutMods.map((m: any) => m.default),
268
- ssrPageData: data!.pageData,
269
- ssrLayoutData: data!.layoutData,
270
- },
271
- });
272
-
273
- // Chunk 3: rendered content
274
- controller.enqueue(enc.encode(buildHtmlTail(body, head, data!.pageData, data!.layoutData, data!.csr)));
275
- controller.close();
276
- } catch (err) {
277
- // Only render() can throw here data is already loaded successfully
278
- if (isDev) console.error("SSR render error:", err);
279
- else console.error("SSR render error:", (err as Error).message ?? err);
280
- controller.enqueue(enc.encode(`<p>Internal Server Error</p></body></html>`));
281
- controller.close();
282
- }
283
- },
284
- });
285
-
286
- return new Response(stream, {
287
- headers: { "Content-Type": "text/html; charset=utf-8" },
288
- });
232
+ const match = findMatch(serverRoutes, url.pathname);
233
+ if (!match) return null;
234
+
235
+ const { route, params } = match;
236
+
237
+ // ── Pre-stream phase: resolve metadata before committing to a 200 ──
238
+ // Errors here return a proper error response with correct status code.
239
+ let metadata: Metadata | null = null;
240
+ try {
241
+ metadata = await loadMetadata(route, params, url, locals, cookies, req);
242
+ } catch (err) {
243
+ if (err instanceof Redirect) {
244
+ return Response.redirect(err.location, err.status);
245
+ }
246
+ if (err instanceof HttpError) {
247
+ return renderErrorPage(err.status, err.message, url, req);
248
+ }
249
+ if (isDev) console.error("Metadata load error:", err);
250
+ else console.error("Metadata load error:", (err as Error).message ?? err);
251
+ // Continue with null metadata — don't break the page for a metadata failure
252
+ }
253
+
254
+ // ── Pre-stream phase: run load() + module imports in parallel before committing to a 200 ──
255
+ // This ensures HttpError/Redirect from load() can return a proper response before any bytes are sent.
256
+ const metadataData = metadata?.data ?? null;
257
+ let data: Awaited<ReturnType<typeof loadRouteData>>;
258
+ let pageMod: any;
259
+ let layoutMods: any[];
260
+
261
+ try {
262
+ [data, pageMod, layoutMods] = await Promise.all([
263
+ loadRouteData(url, locals, req, cookies, metadataData),
264
+ route.pageModule(),
265
+ Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
266
+ ]);
267
+ } catch (err) {
268
+ if (err instanceof Redirect) return Response.redirect(err.location, err.status);
269
+ if (err instanceof HttpError) return renderErrorPage(err.status, err.message, url, req);
270
+ if (isDev) console.error("SSR load error:", err);
271
+ else console.error("SSR load error:", (err as Error).message ?? err);
272
+ return renderErrorPage(500, "Internal Server Error", url, req);
273
+ }
274
+
275
+ if (!data) return renderErrorPage(404, "Not Found", url, req);
276
+
277
+ const enc = new TextEncoder();
278
+
279
+ const stream = new ReadableStream<Uint8Array>({
280
+ async start(controller) {
281
+ // Chunk 1: head opening (CSS, modulepreload — cached per lang)
282
+ controller.enqueue(enc.encode(buildHtmlShellOpen(metadata?.lang)));
283
+
284
+ // Chunk 2: metadata tags, close </head>, open <body> + spinner
285
+ controller.enqueue(enc.encode(buildMetadataChunk(metadata)));
286
+
287
+ try {
288
+ if (!data!.ssr) {
289
+ // ssr=false → skip render(); ship empty shell + hydration scripts.
290
+ // ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
291
+ if (!data!.csr && isDev) {
292
+ console.warn(
293
+ `⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`,
294
+ );
295
+ }
296
+ controller.enqueue(
297
+ enc.encode(
298
+ buildHtmlTail(
299
+ "",
300
+ "",
301
+ data!.pageData,
302
+ data!.layoutData,
303
+ true,
304
+ null,
305
+ false,
306
+ ),
307
+ ),
308
+ );
309
+ controller.close();
310
+ return;
311
+ }
312
+
313
+ const { body, head } = render(App, {
314
+ props: {
315
+ ssrMode: true,
316
+ ssrPageComponent: pageMod.default,
317
+ ssrLayoutComponents: layoutMods.map((m: any) => m.default),
318
+ ssrPageData: data!.pageData,
319
+ ssrLayoutData: data!.layoutData,
320
+ },
321
+ });
322
+
323
+ // Chunk 3: rendered content
324
+ controller.enqueue(
325
+ enc.encode(
326
+ buildHtmlTail(body, head, data!.pageData, data!.layoutData, data!.csr),
327
+ ),
328
+ );
329
+ controller.close();
330
+ } catch (err) {
331
+ // Only render() can throw here — data is already loaded successfully
332
+ if (isDev) console.error("SSR render error:", err);
333
+ else console.error("SSR render error:", (err as Error).message ?? err);
334
+ controller.enqueue(enc.encode(`<p>Internal Server Error</p></body></html>`));
335
+ controller.close();
336
+ }
337
+ },
338
+ });
339
+
340
+ return new Response(stream, {
341
+ headers: { "Content-Type": "text/html; charset=utf-8" },
342
+ });
289
343
  }
290
344
 
291
345
  // ─── Form Action Page Renderer ───────────────────────────
@@ -293,68 +347,87 @@ export async function renderSSRStream(
293
347
  // Uses non-streaming buildHtml so we can control the status code.
294
348
 
295
349
  export async function renderPageWithFormData(
296
- url: URL,
297
- locals: Record<string, any>,
298
- req: Request,
299
- cookies: Cookies,
300
- formData: any,
301
- status: number,
350
+ url: URL,
351
+ locals: Record<string, any>,
352
+ req: Request,
353
+ cookies: Cookies,
354
+ formData: any,
355
+ status: number,
302
356
  ): Promise<Response> {
303
- const match = findMatch(serverRoutes, url.pathname);
304
- if (!match) return renderErrorPage(404, "Not Found", url, req);
305
-
306
- const { route } = match;
307
-
308
- // Load components + data in parallel
309
- const [data, pageMod, layoutMods] = await Promise.all([
310
- loadRouteData(url, locals, req, cookies),
311
- route.pageModule(),
312
- Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
313
- ]);
314
-
315
- if (!data) return renderErrorPage(404, "Not Found", url, req);
316
-
317
- if (!data.ssr) {
318
- if (!data.csr && isDev) {
319
- console.warn(`⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`);
320
- }
321
- const html = buildHtml("", "", data.pageData, data.layoutData, true, formData, undefined, false);
322
- return compress(html, "text/html; charset=utf-8", req, status);
323
- }
324
-
325
- const { body, head } = render(App, {
326
- props: {
327
- ssrMode: true,
328
- ssrPageComponent: pageMod.default,
329
- ssrLayoutComponents: layoutMods.map((m: any) => m.default),
330
- ssrPageData: data.pageData,
331
- ssrLayoutData: data.layoutData,
332
- ssrFormData: formData,
333
- },
334
- });
335
-
336
- const html = buildHtml(body, head, data.pageData, data.layoutData, data.csr, formData);
337
- return compress(html, "text/html; charset=utf-8", req, status);
357
+ const match = findMatch(serverRoutes, url.pathname);
358
+ if (!match) return renderErrorPage(404, "Not Found", url, req);
359
+
360
+ const { route } = match;
361
+
362
+ // Load components + data in parallel
363
+ const [data, pageMod, layoutMods] = await Promise.all([
364
+ loadRouteData(url, locals, req, cookies),
365
+ route.pageModule(),
366
+ Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
367
+ ]);
368
+
369
+ if (!data) return renderErrorPage(404, "Not Found", url, req);
370
+
371
+ if (!data.ssr) {
372
+ if (!data.csr && isDev) {
373
+ console.warn(
374
+ `⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`,
375
+ );
376
+ }
377
+ const html = buildHtml(
378
+ "",
379
+ "",
380
+ data.pageData,
381
+ data.layoutData,
382
+ true,
383
+ formData,
384
+ undefined,
385
+ false,
386
+ );
387
+ return compress(html, "text/html; charset=utf-8", req, status);
388
+ }
389
+
390
+ const { body, head } = render(App, {
391
+ props: {
392
+ ssrMode: true,
393
+ ssrPageComponent: pageMod.default,
394
+ ssrLayoutComponents: layoutMods.map((m: any) => m.default),
395
+ ssrPageData: data.pageData,
396
+ ssrLayoutData: data.layoutData,
397
+ ssrFormData: formData,
398
+ },
399
+ });
400
+
401
+ const html = buildHtml(body, head, data.pageData, data.layoutData, data.csr, formData);
402
+ return compress(html, "text/html; charset=utf-8", req, status);
338
403
  }
339
404
 
340
405
  // ─── Error Page Renderer ──────────────────────────────────
341
406
 
342
- export async function renderErrorPage(status: number, message: string, url: URL, req: Request): Promise<Response> {
343
- if (errorPage) {
344
- try {
345
- const mod = await errorPage();
346
- // Render the error component directly — NOT through App.svelte.
347
- // App.svelte always remaps ssrPageData to a `data` prop, but +error.svelte
348
- // expects `error` as a direct prop: `let { error } = $props()`.
349
- const { body, head } = render(mod.default, {
350
- props: { error: { status, message } },
351
- });
352
- const html = buildHtml(body, head, { status, message }, [], false);
353
- return compress(html, "text/html; charset=utf-8", req, status);
354
- } catch (err) {
355
- if (isDev) console.error("Error page render failed:", err);
356
- else console.error("Error page render failed:", (err as Error).message ?? err);
357
- }
358
- }
359
- return new Response(message, { status, headers: { "Content-Type": "text/plain; charset=utf-8" } });
407
+ export async function renderErrorPage(
408
+ status: number,
409
+ message: string,
410
+ url: URL,
411
+ req: Request,
412
+ ): Promise<Response> {
413
+ if (errorPage) {
414
+ try {
415
+ const mod = await errorPage();
416
+ // Render the error component directly — NOT through App.svelte.
417
+ // App.svelte always remaps ssrPageData to a `data` prop, but +error.svelte
418
+ // expects `error` as a direct prop: `let { error } = $props()`.
419
+ const { body, head } = render(mod.default, {
420
+ props: { error: { status, message } },
421
+ });
422
+ const html = buildHtml(body, head, { status, message }, [], false);
423
+ return compress(html, "text/html; charset=utf-8", req, status);
424
+ } catch (err) {
425
+ if (isDev) console.error("Error page render failed:", err);
426
+ else console.error("Error page render failed:", (err as Error).message ?? err);
427
+ }
428
+ }
429
+ return new Response(message, {
430
+ status,
431
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
432
+ });
360
433
  }