bosia 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -39
- package/package.json +56 -53
- package/src/ambient.d.ts +31 -0
- package/src/cli/add.ts +120 -114
- package/src/cli/build.ts +10 -10
- package/src/cli/create.ts +142 -137
- package/src/cli/dev.ts +8 -8
- package/src/cli/feat.ts +291 -132
- package/src/cli/index.ts +51 -42
- package/src/cli/registry.ts +136 -115
- package/src/cli/start.ts +17 -17
- package/src/cli/test.ts +25 -0
- package/src/core/build.ts +72 -56
- package/src/core/client/App.svelte +177 -153
- package/src/core/client/appState.svelte.ts +57 -0
- package/src/core/client/enhance.ts +112 -0
- package/src/core/client/hydrate.ts +97 -65
- package/src/core/client/prefetch.ts +101 -94
- package/src/core/client/router.svelte.ts +64 -51
- package/src/core/cookies.ts +70 -66
- package/src/core/cors.ts +44 -35
- package/src/core/csrf.ts +38 -38
- package/src/core/dedup.ts +17 -17
- package/src/core/dev.ts +165 -168
- package/src/core/env.ts +155 -128
- package/src/core/envCodegen.ts +73 -73
- package/src/core/errors.ts +48 -49
- package/src/core/hooks.ts +50 -50
- package/src/core/html.ts +192 -139
- package/src/core/matcher.ts +130 -121
- package/src/core/paths.ts +8 -10
- package/src/core/plugin.ts +113 -107
- package/src/core/prerender.ts +191 -118
- package/src/core/renderer.ts +359 -265
- package/src/core/routeFile.ts +140 -127
- package/src/core/routeTypes.ts +144 -83
- package/src/core/scanner.ts +125 -95
- package/src/core/server.ts +543 -370
- package/src/core/types.ts +25 -20
- package/src/lib/client.ts +12 -0
- package/src/lib/index.ts +8 -8
- package/src/lib/utils.ts +44 -30
- package/templates/default/.prettierignore +5 -0
- package/templates/default/.prettierrc.json +9 -0
- package/templates/default/README.md +5 -5
- package/templates/default/package.json +22 -18
- package/templates/default/src/app.css +80 -80
- package/templates/default/src/app.d.ts +3 -3
- package/templates/default/src/routes/+error.svelte +7 -10
- package/templates/default/src/routes/+layout.svelte +2 -2
- package/templates/default/src/routes/+page.svelte +31 -29
- package/templates/default/src/routes/about/+page.svelte +3 -3
- package/templates/default/tsconfig.json +20 -20
- package/templates/demo/.prettierignore +5 -0
- package/templates/demo/.prettierrc.json +9 -0
- package/templates/demo/README.md +9 -9
- package/templates/demo/package.json +22 -17
- package/templates/demo/src/app.css +80 -80
- package/templates/demo/src/app.d.ts +3 -3
- package/templates/demo/src/hooks.server.ts +9 -9
- package/templates/demo/src/routes/(public)/+layout.svelte +45 -23
- package/templates/demo/src/routes/(public)/+page.svelte +96 -67
- package/templates/demo/src/routes/(public)/about/+page.svelte +13 -25
- package/templates/demo/src/routes/(public)/all/[...catchall]/+page.svelte +24 -28
- package/templates/demo/src/routes/(public)/blog/+page.svelte +55 -46
- package/templates/demo/src/routes/(public)/blog/[slug]/+page.server.ts +36 -38
- package/templates/demo/src/routes/(public)/blog/[slug]/+page.svelte +60 -42
- package/templates/demo/src/routes/+error.svelte +10 -7
- package/templates/demo/src/routes/+layout.server.ts +4 -4
- package/templates/demo/src/routes/+layout.svelte +2 -2
- package/templates/demo/src/routes/actions-test/+page.server.ts +16 -16
- package/templates/demo/src/routes/actions-test/+page.svelte +49 -49
- package/templates/demo/src/routes/api/hello/+server.ts +25 -25
- package/templates/demo/tsconfig.json +20 -20
- package/templates/todo/.prettierignore +5 -0
- package/templates/todo/.prettierrc.json +9 -0
- package/templates/todo/README.md +9 -9
- package/templates/todo/package.json +22 -17
- package/templates/todo/src/app.css +80 -80
- package/templates/todo/src/app.d.ts +7 -7
- package/templates/todo/src/hooks.server.ts +9 -9
- package/templates/todo/src/routes/+error.svelte +10 -7
- package/templates/todo/src/routes/+layout.server.ts +4 -4
- package/templates/todo/src/routes/+layout.svelte +2 -2
- package/templates/todo/src/routes/+page.svelte +44 -44
- package/templates/todo/template.json +1 -1
- package/templates/todo/tsconfig.json +20 -20
package/src/core/renderer.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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,179 +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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
url: URL,
|
|
116
|
+
locals: Record<string, any>,
|
|
117
|
+
req: Request,
|
|
118
|
+
cookies: Cookies,
|
|
119
|
+
metadataData: Record<string, any> | null = null,
|
|
108
120
|
) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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 };
|
|
160
192
|
}
|
|
161
193
|
|
|
162
194
|
// ─── Metadata Loader ─────────────────────────────────────
|
|
163
195
|
|
|
164
196
|
export async function loadMetadata(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
197
|
+
route: any,
|
|
198
|
+
params: Record<string, string>,
|
|
199
|
+
url: URL,
|
|
200
|
+
locals: Record<string, any>,
|
|
201
|
+
cookies: Cookies,
|
|
202
|
+
req: Request,
|
|
171
203
|
): Promise<Metadata | null> {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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;
|
|
184
222
|
}
|
|
185
223
|
|
|
186
224
|
// ─── Streaming SSR Renderer ──────────────────────────────
|
|
187
225
|
|
|
188
226
|
export async function renderSSRStream(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
227
|
+
url: URL,
|
|
228
|
+
locals: Record<string, any>,
|
|
229
|
+
req: Request,
|
|
230
|
+
cookies: Cookies,
|
|
193
231
|
): Promise<Response | null> {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
+
});
|
|
276
343
|
}
|
|
277
344
|
|
|
278
345
|
// ─── Form Action Page Renderer ───────────────────────────
|
|
@@ -280,60 +347,87 @@ export async function renderSSRStream(
|
|
|
280
347
|
// Uses non-streaming buildHtml so we can control the status code.
|
|
281
348
|
|
|
282
349
|
export async function renderPageWithFormData(
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
350
|
+
url: URL,
|
|
351
|
+
locals: Record<string, any>,
|
|
352
|
+
req: Request,
|
|
353
|
+
cookies: Cookies,
|
|
354
|
+
formData: any,
|
|
355
|
+
status: number,
|
|
289
356
|
): Promise<Response> {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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);
|
|
317
403
|
}
|
|
318
404
|
|
|
319
405
|
// ─── Error Page Renderer ──────────────────────────────────
|
|
320
406
|
|
|
321
|
-
export async function renderErrorPage(
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
+
});
|
|
339
433
|
}
|