bosia 1.1.4 → 1.1.5

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.
@@ -15,7 +15,6 @@ import {
15
15
  coalesceMiss,
16
16
  collectTags,
17
17
  computeCacheKey,
18
- concatChunks,
19
18
  deferCacheWrite,
20
19
  serveCached,
21
20
  } from "./cache.ts";
@@ -35,6 +34,7 @@ import {
35
34
  compressBytes,
36
35
  encodeForRequest,
37
36
  isDev,
37
+ withPreloadLink,
38
38
  type Encoded,
39
39
  } from "./html.ts";
40
40
  import type { Metadata } from "./hooks.ts";
@@ -358,20 +358,17 @@ function makeDepends(deps: LoaderDeps): (...keys: string[]) => void {
358
358
  // Runs layout + page server loaders for a given URL.
359
359
  // Used by both SSR and the /__bosia/data JSON endpoint.
360
360
  //
361
- // `mask` controls selective re-runs from the client data endpoint:
362
- // - undefined → run everything (SSR, first nav)
363
- // - layouts[i] === true → run that layout; false → skip, emit null
364
- // - page === true → run page; false → skip, emit null
365
- // When skipped, the parent() chain still receives the *combined parent
366
- // data* contributed by previously-cached layers. The client already holds
367
- // each skipped layer's data in its loader cache and forwards it as
368
- // `parentSnapshots` (depth → data) in the request body, so downstream
369
- // loaders that DO re-run see real parent() data, not `{}`. The response
370
- // slot stays `null` (client renders that layer from its cache).
361
+ // `mask` controls which layers' data the client data endpoint sends back:
362
+ // - undefined → send everything (SSR, first nav)
363
+ // - layouts[i] === false → the layout still RUNS, but its slot is emitted as
364
+ // null (the client renders that layer from its loader cache)
365
+ // - page === false → page loader is skipped, emit null
371
366
  //
372
- // Trust boundary: parentSnapshots are a client-supplied perf hint, never
373
- // authoritative. Anything authz-related must read `event.locals` (populated
374
- // in hooks.server.ts), never `parent()`.
367
+ // Layout loaders are never skipped on the server. They are where apps gate a
368
+ // route group (`if (!locals.user) throw redirect(...)`), and the mask comes from
369
+ // the client — honouring it would let any caller switch a guard off with
370
+ // `?_invalidated=…`. Their output also feeds `parent()` for the loaders below,
371
+ // so it has to be the server's own, never data the client sends.
375
372
 
376
373
  export type LoaderMask = {
377
374
  page: boolean;
@@ -383,10 +380,9 @@ export async function loadRouteData(
383
380
  locals: Record<string, any>,
384
381
  req: Request,
385
382
  cookies: Cookies,
386
- metadataData: Record<string, any> | null = null,
383
+ metadataData: Record<string, any> | null | Promise<Record<string, any> | null> = null,
387
384
  match?: RouteMatch<(typeof serverRoutes)[number]> | null,
388
385
  mask?: LoaderMask,
389
- parentSnapshots?: Record<number, Record<string, any>>,
390
386
  ) {
391
387
  match ??= findMatch(serverRoutes, url.pathname);
392
388
  if (!match) return null;
@@ -404,18 +400,9 @@ export async function loadRouteData(
404
400
 
405
401
  // Run layout server loaders root → leaf, each gets parent() data
406
402
  for (const ls of route.layoutServers) {
407
- const skip = mask && mask.layouts[ls.depth] === false;
403
+ // Runs regardless of the mask — see the trust note above loadRouteData.
404
+ const omit = mask && mask.layouts[ls.depth] === false;
408
405
  try {
409
- if (skip) {
410
- layoutData[ls.depth] = null;
411
- layoutDeps[ls.depth] = null;
412
- // Skipped layers contribute their client-cached data (forwarded as
413
- // parentSnapshots) to the parent chain, so downstream loaders that DO
414
- // re-run see real parent() data. Falls back to {} when no snapshot was
415
- // sent. Perf hint only — never authoritative for authz (use locals).
416
- parentData = { ...parentData, ...(parentSnapshots?.[ls.depth] ?? {}) };
417
- continue;
418
- }
419
406
  const mod = await ls.loader();
420
407
  if (typeof mod.load === "function" && !warmingUp) {
421
408
  // Snapshot per layer so loaders cannot mutate the shared accumulator,
@@ -427,6 +414,7 @@ export async function loadRouteData(
427
414
  (await withTimeout(
428
415
  mod.load({
429
416
  params: trackedParams(params, deps),
417
+ route: { id: route.id },
430
418
  url: trackedUrl(url, deps),
431
419
  locals,
432
420
  cookies: trackedCookies(cookies, deps),
@@ -440,12 +428,12 @@ export async function loadRouteData(
440
428
  LOAD_TIMEOUT,
441
429
  `layout load (depth=${ls.depth}, ${url.pathname})`,
442
430
  )) ?? {};
443
- layoutData[ls.depth] = result;
444
- layoutDeps[ls.depth] = deps;
431
+ layoutData[ls.depth] = omit ? null : result;
432
+ layoutDeps[ls.depth] = omit ? null : deps;
445
433
  parentData = { ...parentData, ...result };
446
434
  } else {
447
- layoutData[ls.depth] = {};
448
- layoutDeps[ls.depth] = emptyDeps();
435
+ layoutData[ls.depth] = omit ? null : {};
436
+ layoutDeps[ls.depth] = omit ? null : emptyDeps();
449
437
  }
450
438
  } catch (err) {
451
439
  if (isRedirect(err)) throw err;
@@ -494,12 +482,13 @@ export async function loadRouteData(
494
482
  (await withTimeout(
495
483
  mod.load({
496
484
  params: trackedParams(params, deps),
485
+ route: { id: route.id },
497
486
  url: trackedUrl(url, deps),
498
487
  locals,
499
488
  cookies: trackedCookies(cookies, deps),
500
489
  parent,
501
490
  fetch: trackedFetch(fetch, origin, deps),
502
- metadata: metadataData,
491
+ metadata: await metadataData,
503
492
  depends: makeDepends(deps),
504
493
  platform: getPlatform(),
505
494
  setHeaders,
@@ -564,7 +553,15 @@ export async function loadMetadata(
564
553
  const fetch = makeFetch(req, url);
565
554
  return (
566
555
  (await withTimeout(
567
- mod.metadata({ params, url, locals, cookies, fetch, platform: getPlatform() }),
556
+ mod.metadata({
557
+ params,
558
+ route: { id: route.id },
559
+ url,
560
+ locals,
561
+ cookies,
562
+ fetch,
563
+ platform: getPlatform(),
564
+ }),
568
565
  METADATA_TIMEOUT,
569
566
  `metadata (${url.pathname})`,
570
567
  )) ?? null
@@ -646,12 +643,53 @@ export async function renderSSRStream(
646
643
  // below must stay inside this try; the cache-write path hands the release
647
644
  // off to its deferred cacheSet by nulling releaseMiss first.
648
645
  try {
649
- // ── Pre-stream phase: resolve metadata before committing to a 200 ──
650
- // Errors here return a proper error response with correct status code.
646
+ // ── Pre-stream phase: metadata(), loaders, module imports and plugin
647
+ // fragments all start together, and all settle before committing to a 200,
648
+ // so a Redirect/HttpError from any of them still gets a proper response.
649
+ // Layout loaders still run in order before the page load(), which also
650
+ // waits for metadata.data (see loadRouteData).
651
+ const metadataP = loadMetadata(route, params, url, locals, cookies, req).then(
652
+ (value) => ({ ok: true as const, value }),
653
+ (err: unknown) => ({ ok: false as const, err }),
654
+ );
655
+ const dataP = Promise.all([
656
+ loadRouteData(
657
+ url,
658
+ locals,
659
+ req,
660
+ cookies,
661
+ // A failed metadata() hands load() null, as before — and when the
662
+ // failure ends the request, the result below is never read.
663
+ metadataP.then((m) => (m.ok ? (m.value?.data ?? null) : null)),
664
+ match,
665
+ ),
666
+ Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
667
+ // pageMod is already loaded when the cache flag was unknown.
668
+ pageMod ?? route.pageModule(),
669
+ ]);
670
+ // Settled by the early returns below without being read.
671
+ dataP.catch(() => {});
672
+ const fragmentsP = metadataP.then((m) => {
673
+ const renderCtx: RenderContext = {
674
+ request: req,
675
+ url,
676
+ route: { pattern: route.pattern },
677
+ metadata: m.ok ? m.value : null,
678
+ };
679
+ return Promise.all([
680
+ pluginRenderFragments("head", renderCtx),
681
+ pluginRenderFragments("bodyEnd", renderCtx),
682
+ ]);
683
+ });
684
+ fragmentsP.catch(() => {});
685
+
686
+ // Metadata errors return a proper error response with correct status code.
651
687
  let metadata: Metadata | null = null;
652
- try {
653
- metadata = await loadMetadata(route, params, url, locals, cookies, req);
654
- } catch (err) {
688
+ const meta = await metadataP;
689
+ if (meta.ok) {
690
+ metadata = meta.value;
691
+ } else {
692
+ const err = meta.err;
655
693
  if (isRedirect(err)) {
656
694
  // Not Response.redirect(): it rejects relative URLs on Workers.
657
695
  return new Response(null, { status: err.status, headers: { Location: err.location } });
@@ -675,21 +713,12 @@ export async function renderSSRStream(
675
713
  // Continue with null metadata — don't break the page for a metadata failure
676
714
  }
677
715
 
678
- // ── Pre-stream phase: run load() + module imports in parallel before committing to a 200 ──
679
- // This ensures HttpError/Redirect from load() can return a proper response before any bytes are sent.
680
- const metadataData = metadata?.data ?? null;
681
716
  let data: Awaited<ReturnType<typeof loadRouteData>>;
682
717
  let layoutMods: any[];
683
718
 
684
719
  try {
685
- // pageMod is already loaded when the cache flag was unknown; otherwise
686
- // fold its import into this parallel block instead of a serial await.
687
720
  let pm: any;
688
- [data, layoutMods, pm] = await Promise.all([
689
- loadRouteData(url, locals, req, cookies, metadataData, match),
690
- Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
691
- pageMod ?? route.pageModule(),
692
- ]);
721
+ [data, layoutMods, pm] = await dataP;
693
722
  pageMod = pm;
694
723
  } catch (err) {
695
724
  if (isRedirect(err))
@@ -741,16 +770,7 @@ export async function renderSSRStream(
741
770
  nonce,
742
771
  );
743
772
 
744
- const renderCtx: RenderContext = {
745
- request: req,
746
- url,
747
- route: { pattern: route.pattern },
748
- metadata,
749
- };
750
- const [headExtras, bodyEndExtras] = await Promise.all([
751
- pluginRenderFragments("head", renderCtx),
752
- pluginRenderFragments("bodyEnd", renderCtx),
753
- ]);
773
+ const [headExtras, bodyEndExtras] = await fragmentsP;
754
774
 
755
775
  // SSR always runs every loader, so coerce types from the optional sparse shape.
756
776
  const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
@@ -781,7 +801,13 @@ export async function renderSSRStream(
781
801
  data.layoutDeps,
782
802
  appHtmlSegments,
783
803
  );
784
- return compress(html, "text/html; charset=utf-8", req, 200, data.loaderHeaders);
804
+ return compress(
805
+ html,
806
+ "text/html; charset=utf-8",
807
+ req,
808
+ 200,
809
+ withPreloadLink(data.loaderHeaders, route.pattern, true),
810
+ );
785
811
  }
786
812
 
787
813
  // Render-first: run render() before committing to a 200. Failure → proper error page
@@ -823,17 +849,15 @@ export async function renderSSRStream(
823
849
  // compressed — a stream here never streamed, it only skipped compression.
824
850
  // Truly progressive SSR (ROADMAP) would pipe a stream through
825
851
  // CompressionStream instead.
826
- const chunks: Uint8Array[] = [
827
- enc.encode(
828
- buildHtmlShellOpen(
829
- metadata?.lang,
830
- nonce,
831
- appHtmlSegments,
832
- data.csr ? route.pattern : undefined,
833
- ),
834
- ),
835
- enc.encode(buildMetadataChunk(metadata, headExtras, appHtmlSegments)),
836
- enc.encode(
852
+ // One string, encoded once: three encodes plus a concat copied every byte twice.
853
+ const fullBody = enc.encode(
854
+ buildHtmlShellOpen(
855
+ metadata?.lang,
856
+ nonce,
857
+ appHtmlSegments,
858
+ data.csr ? route.pattern : undefined,
859
+ ) +
860
+ buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
837
861
  buildHtmlTail(
838
862
  body,
839
863
  head,
@@ -848,10 +872,9 @@ export async function renderSSRStream(
848
872
  data.layoutDeps,
849
873
  appHtmlSegments,
850
874
  ),
851
- ),
852
- ];
853
-
854
- const fullBody = concatChunks(chunks);
875
+ ) as Uint8Array<ArrayBuffer>;
876
+ // Stored with the cache entry too, so hits carry the same Link header.
877
+ const headers = withPreloadLink(data.loaderHeaders, route.pattern, data.csr) ?? {};
855
878
  // Set only on a cache write: the client's variant, encoded once at cache
856
879
  // quality and shared by the response and the cache entry.
857
880
  let sent: Encoded | null | undefined;
@@ -890,7 +913,7 @@ export async function renderSSRStream(
890
913
  brotli,
891
914
  contentType: "text/html; charset=utf-8",
892
915
  status: 200,
893
- extraHeaders: data.loaderHeaders,
916
+ extraHeaders: headers,
894
917
  tags,
895
918
  },
896
919
  cookies,
@@ -902,7 +925,7 @@ export async function renderSSRStream(
902
925
  }
903
926
  }
904
927
 
905
- return compressBytes(fullBody, "text/html; charset=utf-8", req, 200, data.loaderHeaders, sent);
928
+ return compressBytes(fullBody, "text/html; charset=utf-8", req, 200, headers, sent);
906
929
  } finally {
907
930
  releaseMiss?.();
908
931
  }
@@ -48,7 +48,7 @@ function sortRoutes<T extends { pattern: string }>(routes: T[]): T[] {
48
48
  }
49
49
 
50
50
  export function generateRoutesFile(manifest: RouteManifest): void {
51
- const lines: string[] = ["// AUTO-GENERATED by bosia build — do not edit\n"];
51
+ const lines: string[] = ["// AUTO-GENERATED by bosia build — do not edit\n", ONCE_HELPER];
52
52
 
53
53
  const pages = sortRoutes(manifest.pages);
54
54
  const apis = sortRoutes(manifest.apis);
@@ -56,6 +56,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
56
56
  // clientRoutes
57
57
  lines.push("export const clientRoutes: Array<{");
58
58
  lines.push(" pattern: string;");
59
+ lines.push(" id: string;");
59
60
  lines.push(" page: () => Promise<any>;");
60
61
  lines.push(" layouts: (() => Promise<any>)[];");
61
62
  lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
@@ -92,6 +93,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
92
93
  .join(", ");
93
94
  lines.push(" {");
94
95
  lines.push(` pattern: ${JSON.stringify(clientPattern(r.pattern))},`);
96
+ lines.push(` id: ${JSON.stringify(r.id)},`);
95
97
  lines.push(` page: () => import(${JSON.stringify(toImportPath(r.page))}),`);
96
98
  lines.push(` layouts: [${layoutImports}],`);
97
99
  lines.push(` errorPages: [${errorPageImports}],`);
@@ -111,6 +113,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
111
113
  // serverRoutes
112
114
  lines.push("export const serverRoutes: Array<{");
113
115
  lines.push(" pattern: string;");
116
+ lines.push(" id: string;");
114
117
  lines.push(" pageModule: () => Promise<any>;");
115
118
  lines.push(" layoutModules: (() => Promise<any>)[];");
116
119
  lines.push(" pageServer: (() => Promise<any>) | null;");
@@ -120,28 +123,19 @@ export function generateRoutesFile(manifest: RouteManifest): void {
120
123
  lines.push(" cache: boolean | null;");
121
124
  lines.push("}> = [");
122
125
  for (const r of pages) {
123
- const layoutImports = r.layouts
124
- .map((l) => `() => import(${JSON.stringify(toImportPath(l))})`)
125
- .join(", ");
126
+ const layoutImports = r.layouts.map((l) => serverImport(l)).join(", ");
126
127
  const layoutServerImports = r.layoutServers
127
- .map(
128
- (ls) =>
129
- `{ loader: () => import(${JSON.stringify(toImportPath(ls.path))}), depth: ${ls.depth} }`,
130
- )
128
+ .map((ls) => `{ loader: ${serverImport(ls.path)}, depth: ${ls.depth} }`)
131
129
  .join(", ");
132
130
  const errorPageImports = r.errorPages
133
- .map(
134
- (ep) =>
135
- `{ loader: () => import(${JSON.stringify(toImportPath(ep.path))}), depth: ${ep.depth} }`,
136
- )
131
+ .map((ep) => `{ loader: ${serverImport(ep.path)}, depth: ${ep.depth} }`)
137
132
  .join(", ");
138
133
  lines.push(" {");
139
134
  lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
140
- lines.push(` pageModule: () => import(${JSON.stringify(toImportPath(r.page))}),`);
135
+ lines.push(` id: ${JSON.stringify(r.id)},`);
136
+ lines.push(` pageModule: ${serverImport(r.page)},`);
141
137
  lines.push(` layoutModules: [${layoutImports}],`);
142
- lines.push(
143
- ` pageServer: ${r.pageServer ? `() => import(${JSON.stringify(toImportPath(r.pageServer))})` : "null"},`,
144
- );
138
+ lines.push(` pageServer: ${r.pageServer ? serverImport(r.pageServer) : "null"},`);
145
139
  lines.push(` layoutServers: [${layoutServerImports}],`);
146
140
  lines.push(` errorPages: [${errorPageImports}],`);
147
141
  lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
@@ -153,12 +147,14 @@ export function generateRoutesFile(manifest: RouteManifest): void {
153
147
  // apiRoutes
154
148
  lines.push("export const apiRoutes: Array<{");
155
149
  lines.push(" pattern: string;");
150
+ lines.push(" id: string;");
156
151
  lines.push(" module: () => Promise<any>;");
157
152
  lines.push("}> = [");
158
153
  for (const r of apis) {
159
154
  lines.push(" {");
160
155
  lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
161
- lines.push(` module: () => import(${JSON.stringify(toImportPath(r.server))}),`);
156
+ lines.push(` id: ${JSON.stringify(r.id)},`);
157
+ lines.push(` module: ${serverImport(r.server)},`);
162
158
  lines.push(" },");
163
159
  }
164
160
  lines.push("];\n");
@@ -166,9 +162,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
166
162
  // errorPage
167
163
  const ep = manifest.errorPage;
168
164
  lines.push(
169
- `export const errorPage: (() => Promise<any>) | null = ${
170
- ep ? `() => import(${JSON.stringify(toImportPath(ep))})` : "null"
171
- };\n`,
165
+ `export const errorPage: (() => Promise<any>) | null = ${ep ? serverImport(ep) : "null"};\n`,
172
166
  );
173
167
 
174
168
  mkdirSync(".bosia", { recursive: true });
@@ -197,6 +191,7 @@ function generateClientRoutesFile(
197
191
 
198
192
  lines.push("export const clientRoutes: Array<{");
199
193
  lines.push(" pattern: string;");
194
+ lines.push(" id: string;");
200
195
  lines.push(" page: () => Promise<any>;");
201
196
  lines.push(" layouts: (() => Promise<any>)[];");
202
197
  lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
@@ -227,6 +222,7 @@ function generateClientRoutesFile(
227
222
  .join(", ");
228
223
  lines.push(" {");
229
224
  lines.push(` pattern: ${JSON.stringify(clientPattern(r.pattern))},`);
225
+ lines.push(` id: ${JSON.stringify(r.id)},`);
230
226
  lines.push(` page: () => import(${JSON.stringify(toImportPath(r.page))}),`);
231
227
  lines.push(` layouts: [${layoutImports}],`);
232
228
  lines.push(` errorPages: [${errorPageImports}],`);
@@ -253,6 +249,26 @@ function generateClientRoutesFile(
253
249
  writeFileSync(".bosia/routes.client.ts", lines.join("\n"));
254
250
  }
255
251
 
252
+ // The renderer calls these thunks several times per request (metadata, load,
253
+ // render), and a repeated `import()` still resolves the specifier each time even
254
+ // though the module is cached — ~8% of CPU on an uncached SSR request. Each thunk
255
+ // keeps its first promise instead. A rejected import is dropped, so the next
256
+ // request retries it rather than replaying the failure forever.
257
+ const ONCE_HELPER = `function once<T>(load: () => Promise<T>): () => Promise<T> {
258
+ let p: Promise<T> | undefined;
259
+ return () =>
260
+ (p ??= load().catch((err) => {
261
+ p = undefined;
262
+ throw err;
263
+ }));
264
+ }
265
+ `;
266
+
267
+ /** A memoized server-side import thunk for `src/routes/<routePath>`. */
268
+ function serverImport(routePath: string): string {
269
+ return `once(() => import(${JSON.stringify(toImportPath(routePath))}))`;
270
+ }
271
+
256
272
  // Import path from .bosia/routes.ts to src/routes/<routePath>
257
273
  function toImportPath(routePath: string): string {
258
274
  return "../src/routes/" + toPosix(routePath);
@@ -142,6 +142,7 @@ export function scanRoutes(): RouteManifest {
142
142
  if (items.some((i) => i.isFile() && i.name === "+server.ts")) {
143
143
  apis.push({
144
144
  pattern: toUrlPath(urlSegments),
145
+ id: `/${dir}`,
145
146
  server: rel("+server.ts"),
146
147
  });
147
148
  }
@@ -158,6 +159,7 @@ export function scanRoutes(): RouteManifest {
158
159
  const pageFile = rel("+page.svelte");
159
160
  pages.push({
160
161
  pattern: toUrlPath(urlSegments),
162
+ id: `/${dir}`,
161
163
  page: pageFile,
162
164
  layouts: [...currentLayouts],
163
165
  pageServer: pageServerFile,