bosia 0.4.4 → 0.5.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/package.json +1 -1
- package/src/cli/add.ts +4 -2
- package/src/cli/block.ts +94 -0
- package/src/cli/fonts.ts +61 -0
- package/src/cli/index.ts +19 -6
- package/src/cli/theme.ts +88 -0
- package/src/core/client/App.svelte +121 -5
- package/src/core/client/appState.svelte.ts +24 -37
- package/src/core/client/enhance.ts +6 -2
- package/src/core/client/hydrate.ts +51 -3
- package/src/core/client/loaderCache.ts +127 -0
- package/src/core/client/navigation.ts +59 -0
- package/src/core/client/prefetch.ts +48 -3
- package/src/core/cors.ts +57 -11
- package/src/core/csp.ts +47 -0
- package/src/core/csrf.ts +8 -5
- package/src/core/dev.ts +14 -2
- package/src/core/errors.ts +4 -3
- package/src/core/hooks.ts +37 -1
- package/src/core/html.ts +68 -26
- package/src/core/prerender.ts +11 -0
- package/src/core/renderer.ts +346 -35
- package/src/core/routeFile.ts +26 -0
- package/src/core/safePath.ts +14 -0
- package/src/core/server.ts +103 -15
- package/src/lib/client.ts +1 -0
- package/src/lib/index.ts +1 -0
package/src/core/renderer.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { render } from "svelte/server";
|
|
|
3
3
|
import { findMatch } from "./matcher.ts";
|
|
4
4
|
import { serverRoutes, errorPage } from "bosia:routes";
|
|
5
5
|
import type { RouteMatch } from "./types.ts";
|
|
6
|
-
import type { Cookies } from "./hooks.ts";
|
|
6
|
+
import type { Cookies, LoaderDeps } from "./hooks.ts";
|
|
7
|
+
import { CSP_ENABLED } from "./csp.ts";
|
|
7
8
|
import { HttpError, Redirect } from "./errors.ts";
|
|
8
9
|
import { pickErrorPage, type ErrorOrigin } from "./errorMatch.ts";
|
|
9
10
|
import App from "./client/App.svelte";
|
|
@@ -157,9 +158,134 @@ function stampErrorContext(
|
|
|
157
158
|
e.partialLayoutData ??= [...partialLayoutData];
|
|
158
159
|
}
|
|
159
160
|
|
|
161
|
+
// ─── Per-Loader Dependency Tracking ──────────────────────
|
|
162
|
+
// Wraps `params`, `url`, `cookies`, and `fetch` with proxies/closures
|
|
163
|
+
// that record every key read during a single loader run. The client
|
|
164
|
+
// cache uses these records to skip re-runs on subsequent navigations
|
|
165
|
+
// when none of the tracked inputs changed.
|
|
166
|
+
|
|
167
|
+
function emptyDeps(): LoaderDeps {
|
|
168
|
+
return {
|
|
169
|
+
keys: [],
|
|
170
|
+
urls: [],
|
|
171
|
+
params: [],
|
|
172
|
+
searchParams: [],
|
|
173
|
+
cookies: [],
|
|
174
|
+
uses_url: false,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function trackedParams(params: Record<string, string>, deps: LoaderDeps): Record<string, string> {
|
|
179
|
+
return new Proxy(params, {
|
|
180
|
+
get(target, prop) {
|
|
181
|
+
if (typeof prop === "string") {
|
|
182
|
+
if (!deps.params.includes(prop)) deps.params.push(prop);
|
|
183
|
+
}
|
|
184
|
+
return Reflect.get(target, prop);
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const URL_TRACKED_PROPS = new Set(["pathname", "origin", "hash", "href", "host", "hostname"]);
|
|
190
|
+
|
|
191
|
+
function trackedUrl(url: URL, deps: LoaderDeps): URL {
|
|
192
|
+
const trackedSearch = new Proxy(url.searchParams, {
|
|
193
|
+
get(target, prop) {
|
|
194
|
+
if (prop === "get" || prop === "has" || prop === "getAll") {
|
|
195
|
+
return (key: string) => {
|
|
196
|
+
if (typeof key === "string" && !deps.searchParams.includes(key)) {
|
|
197
|
+
deps.searchParams.push(key);
|
|
198
|
+
}
|
|
199
|
+
return (target as any)[prop](key);
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const value = Reflect.get(target, prop);
|
|
203
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
return new Proxy(url, {
|
|
207
|
+
get(target, prop) {
|
|
208
|
+
if (prop === "searchParams") return trackedSearch;
|
|
209
|
+
if (prop === "search") {
|
|
210
|
+
// Whole search string read → treat as broad searchParams dep
|
|
211
|
+
deps.uses_url = true;
|
|
212
|
+
return target.search;
|
|
213
|
+
}
|
|
214
|
+
if (typeof prop === "string" && URL_TRACKED_PROPS.has(prop)) {
|
|
215
|
+
deps.uses_url = true;
|
|
216
|
+
}
|
|
217
|
+
const value = Reflect.get(target, prop);
|
|
218
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function trackedCookies(cookies: Cookies, deps: LoaderDeps): Cookies {
|
|
224
|
+
return {
|
|
225
|
+
get(name: string) {
|
|
226
|
+
if (!deps.cookies.includes(name)) deps.cookies.push(name);
|
|
227
|
+
return cookies.get(name);
|
|
228
|
+
},
|
|
229
|
+
getAll() {
|
|
230
|
+
// Broad read — treat as wildcard; record empty marker so any cookie
|
|
231
|
+
// invalidation triggers re-run. Use a sentinel "*" to mean "all".
|
|
232
|
+
if (!deps.cookies.includes("*")) deps.cookies.push("*");
|
|
233
|
+
return cookies.getAll();
|
|
234
|
+
},
|
|
235
|
+
set(name, value, options) {
|
|
236
|
+
cookies.set(name, value, options);
|
|
237
|
+
},
|
|
238
|
+
delete(name, options) {
|
|
239
|
+
cookies.delete(name, options);
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function trackedFetch(
|
|
245
|
+
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
|
|
246
|
+
origin: string,
|
|
247
|
+
deps: LoaderDeps,
|
|
248
|
+
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
|
249
|
+
return (input, init) => {
|
|
250
|
+
let href: string | null = null;
|
|
251
|
+
try {
|
|
252
|
+
if (typeof input === "string") href = new URL(input, origin).href;
|
|
253
|
+
else if (input instanceof URL) href = input.href;
|
|
254
|
+
else href = new URL(input.url, origin).href;
|
|
255
|
+
} catch {
|
|
256
|
+
href = null;
|
|
257
|
+
}
|
|
258
|
+
if (href && !deps.urls.includes(href)) deps.urls.push(href);
|
|
259
|
+
return fetch(input, init);
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function makeDepends(deps: LoaderDeps): (...keys: string[]) => void {
|
|
264
|
+
return (...keys: string[]) => {
|
|
265
|
+
for (const k of keys) {
|
|
266
|
+
if (typeof k === "string" && !deps.keys.includes(k)) deps.keys.push(k);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
160
271
|
// ─── Route Data Loader ───────────────────────────────────
|
|
161
272
|
// Runs layout + page server loaders for a given URL.
|
|
162
273
|
// Used by both SSR and the /__bosia/data JSON endpoint.
|
|
274
|
+
//
|
|
275
|
+
// `mask` controls selective re-runs from the client data endpoint:
|
|
276
|
+
// - undefined → run everything (SSR, first nav)
|
|
277
|
+
// - layouts[i] === true → run that layout; false → skip, emit null
|
|
278
|
+
// - page === true → run page; false → skip, emit null
|
|
279
|
+
// When skipped, the parent() chain still receives the *combined parent
|
|
280
|
+
// data* contributed by previously-cached layers, which the client
|
|
281
|
+
// reconstructs and forwards in the request body. For now (initial
|
|
282
|
+
// implementation), skipped loaders contribute `{}` to parent and the
|
|
283
|
+
// response slot is `null`; the client merges with its cached data.
|
|
284
|
+
|
|
285
|
+
export type LoaderMask = {
|
|
286
|
+
page: boolean;
|
|
287
|
+
layouts: boolean[];
|
|
288
|
+
};
|
|
163
289
|
|
|
164
290
|
export async function loadRouteData(
|
|
165
291
|
url: URL,
|
|
@@ -168,89 +294,155 @@ export async function loadRouteData(
|
|
|
168
294
|
cookies: Cookies,
|
|
169
295
|
metadataData: Record<string, any> | null = null,
|
|
170
296
|
match?: RouteMatch<(typeof serverRoutes)[number]> | null,
|
|
297
|
+
mask?: LoaderMask,
|
|
171
298
|
) {
|
|
172
299
|
match ??= findMatch(serverRoutes, url.pathname);
|
|
173
300
|
if (!match) return null;
|
|
174
301
|
|
|
175
302
|
const { route, params } = match;
|
|
176
303
|
const fetch = makeFetch(req, url);
|
|
177
|
-
const
|
|
304
|
+
const origin = url.origin;
|
|
305
|
+
const layoutData: (Record<string, any> | null)[] = [];
|
|
306
|
+
const layoutDeps: (LoaderDeps | null)[] = [];
|
|
178
307
|
let parentData: Record<string, any> = {};
|
|
179
308
|
|
|
180
309
|
// Run layout server loaders root → leaf, each gets parent() data
|
|
181
310
|
for (const ls of route.layoutServers) {
|
|
311
|
+
const skip = mask && mask.layouts[ls.depth] === false;
|
|
182
312
|
try {
|
|
313
|
+
if (skip) {
|
|
314
|
+
layoutData[ls.depth] = null;
|
|
315
|
+
layoutDeps[ls.depth] = null;
|
|
316
|
+
// Skipped layers contribute {} to the parent chain. The client
|
|
317
|
+
// already has their data and renders it from cache, so dependent
|
|
318
|
+
// loaders that DO re-run will see stale parent() data here. This
|
|
319
|
+
// is the same trade-off SvelteKit makes; loaders that need fresh
|
|
320
|
+
// upstream data should call `depends()` on a shared key.
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
183
323
|
const mod = await ls.loader();
|
|
184
324
|
if (typeof mod.load === "function") {
|
|
185
325
|
// Snapshot per layer so loaders cannot mutate the shared accumulator,
|
|
186
326
|
// preserving the same isolation semantics as the previous merge-on-call code.
|
|
187
327
|
const snapshot = { ...parentData };
|
|
188
328
|
const parent = async () => snapshot;
|
|
329
|
+
const deps = emptyDeps();
|
|
189
330
|
const result =
|
|
190
331
|
(await withTimeout(
|
|
191
|
-
mod.load({
|
|
332
|
+
mod.load({
|
|
333
|
+
params: trackedParams(params, deps),
|
|
334
|
+
url: trackedUrl(url, deps),
|
|
335
|
+
locals,
|
|
336
|
+
cookies: trackedCookies(cookies, deps),
|
|
337
|
+
parent,
|
|
338
|
+
fetch: trackedFetch(fetch, origin, deps),
|
|
339
|
+
metadata: null,
|
|
340
|
+
depends: makeDepends(deps),
|
|
341
|
+
}),
|
|
192
342
|
LOAD_TIMEOUT,
|
|
193
343
|
`layout load (depth=${ls.depth}, ${url.pathname})`,
|
|
194
344
|
)) ?? {};
|
|
195
345
|
layoutData[ls.depth] = result;
|
|
346
|
+
layoutDeps[ls.depth] = deps;
|
|
196
347
|
parentData = { ...parentData, ...result };
|
|
348
|
+
} else {
|
|
349
|
+
layoutData[ls.depth] = {};
|
|
350
|
+
layoutDeps[ls.depth] = emptyDeps();
|
|
197
351
|
}
|
|
198
352
|
} catch (err) {
|
|
199
353
|
if (err instanceof Redirect) throw err;
|
|
200
354
|
if (err instanceof HttpError) {
|
|
201
|
-
stampErrorContext(
|
|
355
|
+
stampErrorContext(
|
|
356
|
+
err,
|
|
357
|
+
ls.depth,
|
|
358
|
+
"layout",
|
|
359
|
+
layoutData.map((d) => d ?? {}),
|
|
360
|
+
);
|
|
202
361
|
throw err;
|
|
203
362
|
}
|
|
204
363
|
if (isDev) console.error("Layout server load error:", err);
|
|
205
364
|
else console.error("Layout server load error:", (err as Error).message ?? err);
|
|
206
365
|
const wrapped = new HttpError(500, "Internal Server Error");
|
|
207
|
-
stampErrorContext(
|
|
366
|
+
stampErrorContext(
|
|
367
|
+
wrapped,
|
|
368
|
+
ls.depth,
|
|
369
|
+
"layout",
|
|
370
|
+
layoutData.map((d) => d ?? {}),
|
|
371
|
+
);
|
|
208
372
|
throw wrapped;
|
|
209
373
|
}
|
|
210
374
|
}
|
|
211
375
|
|
|
212
376
|
// Run page server loader
|
|
213
|
-
let pageData: Record<string, any> =
|
|
377
|
+
let pageData: Record<string, any> | null = null;
|
|
378
|
+
let pageDeps: LoaderDeps | null = null;
|
|
214
379
|
let csr = true;
|
|
215
380
|
let ssr = true;
|
|
381
|
+
const skipPage = mask && mask.page === false;
|
|
216
382
|
if (route.pageServer) {
|
|
217
383
|
try {
|
|
218
384
|
const mod = await route.pageServer();
|
|
219
385
|
if (mod.csr === false) csr = false;
|
|
220
386
|
if (mod.ssr === false) ssr = false;
|
|
221
|
-
if (
|
|
387
|
+
if (skipPage) {
|
|
388
|
+
pageData = null;
|
|
389
|
+
pageDeps = null;
|
|
390
|
+
} else if (typeof mod.load === "function") {
|
|
222
391
|
const snapshot = { ...parentData };
|
|
223
392
|
const parent = async () => snapshot;
|
|
393
|
+
const deps = emptyDeps();
|
|
224
394
|
pageData =
|
|
225
395
|
(await withTimeout(
|
|
226
396
|
mod.load({
|
|
227
|
-
params,
|
|
228
|
-
url,
|
|
397
|
+
params: trackedParams(params, deps),
|
|
398
|
+
url: trackedUrl(url, deps),
|
|
229
399
|
locals,
|
|
230
|
-
cookies,
|
|
400
|
+
cookies: trackedCookies(cookies, deps),
|
|
231
401
|
parent,
|
|
232
|
-
fetch,
|
|
402
|
+
fetch: trackedFetch(fetch, origin, deps),
|
|
233
403
|
metadata: metadataData,
|
|
404
|
+
depends: makeDepends(deps),
|
|
234
405
|
}),
|
|
235
406
|
LOAD_TIMEOUT,
|
|
236
407
|
`page load (${url.pathname})`,
|
|
237
408
|
)) ?? {};
|
|
409
|
+
pageDeps = deps;
|
|
410
|
+
} else {
|
|
411
|
+
pageData = {};
|
|
412
|
+
pageDeps = emptyDeps();
|
|
238
413
|
}
|
|
239
414
|
} catch (err) {
|
|
240
415
|
if (err instanceof Redirect) throw err;
|
|
241
416
|
if (err instanceof HttpError) {
|
|
242
|
-
stampErrorContext(
|
|
417
|
+
stampErrorContext(
|
|
418
|
+
err,
|
|
419
|
+
route.layoutModules.length,
|
|
420
|
+
"page",
|
|
421
|
+
layoutData.map((d) => d ?? {}),
|
|
422
|
+
);
|
|
243
423
|
throw err;
|
|
244
424
|
}
|
|
245
425
|
if (isDev) console.error("Page server load error:", err);
|
|
246
426
|
else console.error("Page server load error:", (err as Error).message ?? err);
|
|
247
427
|
const wrapped = new HttpError(500, "Internal Server Error");
|
|
248
|
-
stampErrorContext(
|
|
428
|
+
stampErrorContext(
|
|
429
|
+
wrapped,
|
|
430
|
+
route.layoutModules.length,
|
|
431
|
+
"page",
|
|
432
|
+
layoutData.map((d) => d ?? {}),
|
|
433
|
+
);
|
|
249
434
|
throw wrapped;
|
|
250
435
|
}
|
|
436
|
+
} else {
|
|
437
|
+
pageData = {};
|
|
438
|
+
pageDeps = emptyDeps();
|
|
251
439
|
}
|
|
252
440
|
|
|
253
|
-
|
|
441
|
+
// `params` are always attached to pageData for client-side router consumption.
|
|
442
|
+
// When pageData is skipped, the client merges its cached pageData with current
|
|
443
|
+
// route params separately, so we keep the `null` sentinel here.
|
|
444
|
+
const pageOut = pageData === null ? null : { ...pageData, params };
|
|
445
|
+
return { pageData: pageOut, layoutData, csr, ssr, pageDeps, layoutDeps };
|
|
254
446
|
}
|
|
255
447
|
|
|
256
448
|
// ─── Metadata Loader ─────────────────────────────────────
|
|
@@ -296,6 +488,7 @@ export async function renderSSRStream(
|
|
|
296
488
|
if (!match) return null;
|
|
297
489
|
|
|
298
490
|
const { route, params } = match;
|
|
491
|
+
const nonce = CSP_ENABLED && typeof locals.nonce === "string" ? locals.nonce : undefined;
|
|
299
492
|
|
|
300
493
|
// ── Pre-stream phase: resolve metadata before committing to a 200 ──
|
|
301
494
|
// Errors here return a proper error response with correct status code.
|
|
@@ -307,7 +500,17 @@ export async function renderSSRStream(
|
|
|
307
500
|
return Response.redirect(err.location, err.status);
|
|
308
501
|
}
|
|
309
502
|
if (err instanceof HttpError) {
|
|
310
|
-
return renderErrorPage(
|
|
503
|
+
return renderErrorPage(
|
|
504
|
+
err.status,
|
|
505
|
+
err.message,
|
|
506
|
+
url,
|
|
507
|
+
req,
|
|
508
|
+
route,
|
|
509
|
+
undefined,
|
|
510
|
+
undefined,
|
|
511
|
+
undefined,
|
|
512
|
+
nonce,
|
|
513
|
+
);
|
|
311
514
|
}
|
|
312
515
|
if (isDev) console.error("Metadata load error:", err);
|
|
313
516
|
else console.error("Metadata load error:", (err as Error).message ?? err);
|
|
@@ -344,14 +547,36 @@ export async function renderSSRStream(
|
|
|
344
547
|
e.errorDepth,
|
|
345
548
|
e.errorOrigin,
|
|
346
549
|
e.partialLayoutData,
|
|
550
|
+
nonce,
|
|
347
551
|
);
|
|
348
552
|
}
|
|
349
553
|
if (isDev) console.error("SSR load error:", err);
|
|
350
554
|
else console.error("SSR load error:", (err as Error).message ?? err);
|
|
351
|
-
return renderErrorPage(
|
|
555
|
+
return renderErrorPage(
|
|
556
|
+
500,
|
|
557
|
+
"Internal Server Error",
|
|
558
|
+
url,
|
|
559
|
+
req,
|
|
560
|
+
route,
|
|
561
|
+
undefined,
|
|
562
|
+
undefined,
|
|
563
|
+
undefined,
|
|
564
|
+
nonce,
|
|
565
|
+
);
|
|
352
566
|
}
|
|
353
567
|
|
|
354
|
-
if (!data)
|
|
568
|
+
if (!data)
|
|
569
|
+
return renderErrorPage(
|
|
570
|
+
404,
|
|
571
|
+
"Not Found",
|
|
572
|
+
url,
|
|
573
|
+
req,
|
|
574
|
+
undefined,
|
|
575
|
+
undefined,
|
|
576
|
+
undefined,
|
|
577
|
+
undefined,
|
|
578
|
+
nonce,
|
|
579
|
+
);
|
|
355
580
|
|
|
356
581
|
const enc = new TextEncoder();
|
|
357
582
|
const renderCtx: RenderContext = {
|
|
@@ -365,6 +590,10 @@ export async function renderSSRStream(
|
|
|
365
590
|
pluginRenderFragments("bodyEnd", renderCtx),
|
|
366
591
|
]);
|
|
367
592
|
|
|
593
|
+
// SSR always runs every loader, so coerce types from the optional sparse shape.
|
|
594
|
+
const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
|
|
595
|
+
const pageDataFull = data.pageData ?? {};
|
|
596
|
+
|
|
368
597
|
// ssr=false → no render() needed; ship shell + hydration as a single response.
|
|
369
598
|
// ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
|
|
370
599
|
if (!data.ssr) {
|
|
@@ -374,9 +603,21 @@ export async function renderSSRStream(
|
|
|
374
603
|
);
|
|
375
604
|
}
|
|
376
605
|
const html =
|
|
377
|
-
buildHtmlShellOpen(metadata?.lang) +
|
|
606
|
+
buildHtmlShellOpen(metadata?.lang, nonce) +
|
|
378
607
|
buildMetadataChunk(metadata, headExtras) +
|
|
379
|
-
buildHtmlTail(
|
|
608
|
+
buildHtmlTail(
|
|
609
|
+
"",
|
|
610
|
+
"",
|
|
611
|
+
pageDataFull,
|
|
612
|
+
layoutDataFull,
|
|
613
|
+
true,
|
|
614
|
+
null,
|
|
615
|
+
false,
|
|
616
|
+
bodyEndExtras,
|
|
617
|
+
nonce,
|
|
618
|
+
data.pageDeps,
|
|
619
|
+
data.layoutDeps,
|
|
620
|
+
);
|
|
380
621
|
return new Response(html, {
|
|
381
622
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
382
623
|
});
|
|
@@ -391,8 +632,8 @@ export async function renderSSRStream(
|
|
|
391
632
|
ssrMode: true,
|
|
392
633
|
ssrPageComponent: pageMod.default,
|
|
393
634
|
ssrLayoutComponents: layoutMods.map((m: any) => m.default),
|
|
394
|
-
ssrPageData:
|
|
395
|
-
ssrLayoutData:
|
|
635
|
+
ssrPageData: pageDataFull,
|
|
636
|
+
ssrLayoutData: layoutDataFull,
|
|
396
637
|
},
|
|
397
638
|
}));
|
|
398
639
|
} catch (err) {
|
|
@@ -407,24 +648,28 @@ export async function renderSSRStream(
|
|
|
407
648
|
route,
|
|
408
649
|
route.layoutModules.length,
|
|
409
650
|
"page",
|
|
410
|
-
|
|
651
|
+
layoutDataFull,
|
|
652
|
+
nonce,
|
|
411
653
|
);
|
|
412
654
|
}
|
|
413
655
|
|
|
414
656
|
// Pre-compute all chunks; pull-based stream gives Bun native backpressure.
|
|
415
657
|
const chunks: Uint8Array[] = [
|
|
416
|
-
enc.encode(buildHtmlShellOpen(metadata?.lang)),
|
|
658
|
+
enc.encode(buildHtmlShellOpen(metadata?.lang, nonce)),
|
|
417
659
|
enc.encode(buildMetadataChunk(metadata, headExtras)),
|
|
418
660
|
enc.encode(
|
|
419
661
|
buildHtmlTail(
|
|
420
662
|
body,
|
|
421
663
|
head,
|
|
422
|
-
|
|
423
|
-
|
|
664
|
+
pageDataFull,
|
|
665
|
+
layoutDataFull,
|
|
424
666
|
data.csr,
|
|
425
667
|
null,
|
|
426
668
|
true,
|
|
427
669
|
bodyEndExtras,
|
|
670
|
+
nonce,
|
|
671
|
+
data.pageDeps,
|
|
672
|
+
data.layoutDeps,
|
|
428
673
|
),
|
|
429
674
|
),
|
|
430
675
|
];
|
|
@@ -473,8 +718,20 @@ export async function renderPageWithFormData(
|
|
|
473
718
|
status: number,
|
|
474
719
|
match?: RouteMatch<(typeof serverRoutes)[number]> | null,
|
|
475
720
|
): Promise<Response> {
|
|
721
|
+
const nonce = CSP_ENABLED && typeof locals.nonce === "string" ? locals.nonce : undefined;
|
|
476
722
|
match ??= findMatch(serverRoutes, url.pathname);
|
|
477
|
-
if (!match)
|
|
723
|
+
if (!match)
|
|
724
|
+
return renderErrorPage(
|
|
725
|
+
404,
|
|
726
|
+
"Not Found",
|
|
727
|
+
url,
|
|
728
|
+
req,
|
|
729
|
+
undefined,
|
|
730
|
+
undefined,
|
|
731
|
+
undefined,
|
|
732
|
+
undefined,
|
|
733
|
+
nonce,
|
|
734
|
+
);
|
|
478
735
|
|
|
479
736
|
const { route } = match;
|
|
480
737
|
|
|
@@ -485,7 +742,22 @@ export async function renderPageWithFormData(
|
|
|
485
742
|
Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
|
|
486
743
|
]);
|
|
487
744
|
|
|
488
|
-
if (!data)
|
|
745
|
+
if (!data)
|
|
746
|
+
return renderErrorPage(
|
|
747
|
+
404,
|
|
748
|
+
"Not Found",
|
|
749
|
+
url,
|
|
750
|
+
req,
|
|
751
|
+
undefined,
|
|
752
|
+
undefined,
|
|
753
|
+
undefined,
|
|
754
|
+
undefined,
|
|
755
|
+
nonce,
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
// Form-action re-render always runs every loader (no client mask).
|
|
759
|
+
const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
|
|
760
|
+
const pageDataFull = data.pageData ?? {};
|
|
489
761
|
|
|
490
762
|
if (!data.ssr) {
|
|
491
763
|
if (!data.csr && isDev) {
|
|
@@ -496,12 +768,15 @@ export async function renderPageWithFormData(
|
|
|
496
768
|
const html = buildHtml(
|
|
497
769
|
"",
|
|
498
770
|
"",
|
|
499
|
-
|
|
500
|
-
|
|
771
|
+
pageDataFull,
|
|
772
|
+
layoutDataFull,
|
|
501
773
|
true,
|
|
502
774
|
formData,
|
|
503
775
|
undefined,
|
|
504
776
|
false,
|
|
777
|
+
nonce,
|
|
778
|
+
data.pageDeps,
|
|
779
|
+
data.layoutDeps,
|
|
505
780
|
);
|
|
506
781
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
507
782
|
}
|
|
@@ -511,13 +786,25 @@ export async function renderPageWithFormData(
|
|
|
511
786
|
ssrMode: true,
|
|
512
787
|
ssrPageComponent: pageMod.default,
|
|
513
788
|
ssrLayoutComponents: layoutMods.map((m: any) => m.default),
|
|
514
|
-
ssrPageData:
|
|
515
|
-
ssrLayoutData:
|
|
789
|
+
ssrPageData: pageDataFull,
|
|
790
|
+
ssrLayoutData: layoutDataFull,
|
|
516
791
|
ssrFormData: formData,
|
|
517
792
|
},
|
|
518
793
|
});
|
|
519
794
|
|
|
520
|
-
const html = buildHtml(
|
|
795
|
+
const html = buildHtml(
|
|
796
|
+
body,
|
|
797
|
+
head,
|
|
798
|
+
pageDataFull,
|
|
799
|
+
layoutDataFull,
|
|
800
|
+
data.csr,
|
|
801
|
+
formData,
|
|
802
|
+
undefined,
|
|
803
|
+
true,
|
|
804
|
+
nonce,
|
|
805
|
+
data.pageDeps,
|
|
806
|
+
data.layoutDeps,
|
|
807
|
+
);
|
|
521
808
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
522
809
|
}
|
|
523
810
|
|
|
@@ -535,8 +822,12 @@ export async function renderErrorPage(
|
|
|
535
822
|
route?: any,
|
|
536
823
|
errorDepth?: number,
|
|
537
824
|
errorOrigin?: ErrorOrigin,
|
|
538
|
-
partialLayoutData?: Record<string, any>[],
|
|
825
|
+
partialLayoutData?: (Record<string, any> | null)[],
|
|
826
|
+
nonce?: string,
|
|
539
827
|
): Promise<Response> {
|
|
828
|
+
// Strip the nonce from emitted scripts when CSP is off — the attribute
|
|
829
|
+
// is dead bytes without a matching policy header.
|
|
830
|
+
if (!CSP_ENABLED) nonce = undefined;
|
|
540
831
|
// 1. Nested boundary
|
|
541
832
|
if (route && errorDepth !== undefined && route.errorPages?.length) {
|
|
542
833
|
const origin = errorOrigin ?? "page";
|
|
@@ -567,7 +858,17 @@ export async function renderErrorPage(
|
|
|
567
858
|
},
|
|
568
859
|
});
|
|
569
860
|
// csr=false: no client hydration on the error page itself.
|
|
570
|
-
const html = buildHtml(
|
|
861
|
+
const html = buildHtml(
|
|
862
|
+
body,
|
|
863
|
+
head,
|
|
864
|
+
{ status, message },
|
|
865
|
+
layoutData,
|
|
866
|
+
false,
|
|
867
|
+
null,
|
|
868
|
+
undefined,
|
|
869
|
+
true,
|
|
870
|
+
nonce,
|
|
871
|
+
);
|
|
571
872
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
572
873
|
} catch (err) {
|
|
573
874
|
if (isDev) console.error("Nested error page render failed:", err);
|
|
@@ -591,7 +892,17 @@ export async function renderErrorPage(
|
|
|
591
892
|
const { body, head } = render(mod.default, {
|
|
592
893
|
props: { error: { status, message } },
|
|
593
894
|
});
|
|
594
|
-
const html = buildHtml(
|
|
895
|
+
const html = buildHtml(
|
|
896
|
+
body,
|
|
897
|
+
head,
|
|
898
|
+
{ status, message },
|
|
899
|
+
[],
|
|
900
|
+
false,
|
|
901
|
+
null,
|
|
902
|
+
undefined,
|
|
903
|
+
true,
|
|
904
|
+
nonce,
|
|
905
|
+
);
|
|
595
906
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
596
907
|
} catch (err) {
|
|
597
908
|
if (isDev) console.error("Error page render failed:", err);
|
package/src/core/routeFile.ts
CHANGED
|
@@ -40,6 +40,11 @@ export function generateRoutesFile(manifest: RouteManifest): void {
|
|
|
40
40
|
lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
|
|
41
41
|
lines.push(" hasServerData: boolean;");
|
|
42
42
|
lines.push(' trailingSlash: "never" | "always" | "ignore";');
|
|
43
|
+
// Stable ids per node, used by the client loader cache to detect whether
|
|
44
|
+
// a layout/page at a given depth is the same as last nav's (skip) or new
|
|
45
|
+
// (re-run). One id per layout in order, plus an optional page id.
|
|
46
|
+
lines.push(" pageId: string | null;");
|
|
47
|
+
lines.push(" layoutIds: (string | null)[];");
|
|
43
48
|
lines.push("}> = [");
|
|
44
49
|
for (const r of pages) {
|
|
45
50
|
const layoutImports = r.layouts
|
|
@@ -52,6 +57,15 @@ export function generateRoutesFile(manifest: RouteManifest): void {
|
|
|
52
57
|
)
|
|
53
58
|
.join(", ");
|
|
54
59
|
const hasServerData = !!(r.pageServer || r.layoutServers.length > 0);
|
|
60
|
+
const pageId = r.pageServer ? JSON.stringify(r.pageServer) : "null";
|
|
61
|
+
// Map +layout.server.ts paths to their layout depth, leaving nulls for
|
|
62
|
+
// layouts that have no server loader. Length matches layouts.length so
|
|
63
|
+
// client cache can index by depth.
|
|
64
|
+
const layoutIdByDepth: (string | null)[] = new Array(r.layouts.length).fill(null);
|
|
65
|
+
for (const ls of r.layoutServers) layoutIdByDepth[ls.depth] = ls.path;
|
|
66
|
+
const layoutIds = layoutIdByDepth
|
|
67
|
+
.map((id) => (id === null ? "null" : JSON.stringify(id)))
|
|
68
|
+
.join(", ");
|
|
55
69
|
lines.push(" {");
|
|
56
70
|
lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
|
|
57
71
|
lines.push(` page: () => import(${JSON.stringify(toImportPath(r.page))}),`);
|
|
@@ -59,6 +73,8 @@ export function generateRoutesFile(manifest: RouteManifest): void {
|
|
|
59
73
|
lines.push(` errorPages: [${errorPageImports}],`);
|
|
60
74
|
lines.push(` hasServerData: ${hasServerData},`);
|
|
61
75
|
lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
|
|
76
|
+
lines.push(` pageId: ${pageId},`);
|
|
77
|
+
lines.push(` layoutIds: [${layoutIds}],`);
|
|
62
78
|
lines.push(" },");
|
|
63
79
|
}
|
|
64
80
|
lines.push("];\n");
|
|
@@ -157,6 +173,8 @@ function generateClientRoutesFile(
|
|
|
157
173
|
lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
|
|
158
174
|
lines.push(" hasServerData: boolean;");
|
|
159
175
|
lines.push(' trailingSlash: "never" | "always" | "ignore";');
|
|
176
|
+
lines.push(" pageId: string | null;");
|
|
177
|
+
lines.push(" layoutIds: (string | null)[];");
|
|
160
178
|
lines.push("}> = [");
|
|
161
179
|
for (const r of pages) {
|
|
162
180
|
const layoutImports = r.layouts
|
|
@@ -169,6 +187,12 @@ function generateClientRoutesFile(
|
|
|
169
187
|
)
|
|
170
188
|
.join(", ");
|
|
171
189
|
const hasServerData = !!(r.pageServer || r.layoutServers.length > 0);
|
|
190
|
+
const pageId = r.pageServer ? JSON.stringify(r.pageServer) : "null";
|
|
191
|
+
const layoutIdByDepth: (string | null)[] = new Array(r.layouts.length).fill(null);
|
|
192
|
+
for (const ls of r.layoutServers) layoutIdByDepth[ls.depth] = ls.path;
|
|
193
|
+
const layoutIds = layoutIdByDepth
|
|
194
|
+
.map((id) => (id === null ? "null" : JSON.stringify(id)))
|
|
195
|
+
.join(", ");
|
|
172
196
|
lines.push(" {");
|
|
173
197
|
lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
|
|
174
198
|
lines.push(` page: () => import(${JSON.stringify(toImportPath(r.page))}),`);
|
|
@@ -176,6 +200,8 @@ function generateClientRoutesFile(
|
|
|
176
200
|
lines.push(` errorPages: [${errorPageImports}],`);
|
|
177
201
|
lines.push(` hasServerData: ${hasServerData},`);
|
|
178
202
|
lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
|
|
203
|
+
lines.push(` pageId: ${pageId},`);
|
|
204
|
+
lines.push(` layoutIds: [${layoutIds}],`);
|
|
179
205
|
lines.push(" },");
|
|
180
206
|
}
|
|
181
207
|
lines.push("];\n");
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { join, resolve as resolvePath } from "path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve `untrusted` relative to `base` and verify the result stays inside
|
|
5
|
+
* `base`. Returns the absolute resolved path on success, or `null` when the
|
|
6
|
+
* resolved location escapes the base directory (traversal, absolute path
|
|
7
|
+
* pointing elsewhere, etc.). Use this on every untrusted path segment before
|
|
8
|
+
* touching the filesystem.
|
|
9
|
+
*/
|
|
10
|
+
export function safePath(base: string, untrusted: string): string | null {
|
|
11
|
+
const root = resolvePath(base);
|
|
12
|
+
const full = resolvePath(join(base, untrusted));
|
|
13
|
+
return full.startsWith(root + "/") || full === root ? full : null;
|
|
14
|
+
}
|