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.
@@ -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 layoutData: Record<string, any>[] = [];
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({ params, url, locals, cookies, parent, fetch, metadata: null }),
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(err, ls.depth, "layout", layoutData);
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(wrapped, ls.depth, "layout", layoutData);
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 (typeof mod.load === "function") {
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(err, route.layoutModules.length, "page", layoutData);
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(wrapped, route.layoutModules.length, "page", layoutData);
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
- return { pageData: { ...pageData, params }, layoutData, csr, ssr };
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(err.status, err.message, url, req, route);
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(500, "Internal Server Error", url, req, route);
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) return renderErrorPage(404, "Not Found", url, req);
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("", "", data.pageData, data.layoutData, true, null, false, bodyEndExtras);
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: data.pageData,
395
- ssrLayoutData: data.layoutData,
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
- data.layoutData,
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
- data.pageData,
423
- data.layoutData,
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) return renderErrorPage(404, "Not Found", url, req);
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) return renderErrorPage(404, "Not Found", url, req);
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
- data.pageData,
500
- data.layoutData,
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: data.pageData,
515
- ssrLayoutData: data.layoutData,
789
+ ssrPageData: pageDataFull,
790
+ ssrLayoutData: layoutDataFull,
516
791
  ssrFormData: formData,
517
792
  },
518
793
  });
519
794
 
520
- const html = buildHtml(body, head, data.pageData, data.layoutData, data.csr, formData);
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(body, head, { status, message }, layoutData, false);
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(body, head, { status, message }, [], false);
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);
@@ -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
+ }