bosia 1.1.3 → 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.
package/src/core/html.ts CHANGED
@@ -62,18 +62,84 @@ export function baseScript(nonce?: string): string {
62
62
  * no buster (same reason as ENTRY). Unknown pattern or an older dist/ without
63
63
  * the `preload` field → nothing, and the page loads as it did before. */
64
64
  export function routePreloadLinks(pattern?: string): string {
65
- if (!pattern) return "";
66
- return (distManifest.preload?.[pattern] ?? [])
67
- .map((f) => `\n <link rel="modulepreload" href="${DIST}/${f}">`)
68
- .join("");
65
+ const preload = distManifest.preload;
66
+ if (!pattern || !preload) return "";
67
+ let byPattern = preloadLinks.get(preload);
68
+ if (!byPattern) preloadLinks.set(preload, (byPattern = new Map()));
69
+ let links = byPattern.get(pattern);
70
+ if (links === undefined) {
71
+ links = (preload[pattern] ?? [])
72
+ .map((f) => `\n <link rel="modulepreload" href="${DIST}/${f}">`)
73
+ .join("");
74
+ byPattern.set(pattern, links);
75
+ }
76
+ return links;
69
77
  }
70
78
 
79
+ // One header value per route pattern (plus "" for no pattern), built on first
80
+ // use. Bounded by the number of routes.
81
+ const linkHeaders = new Map<string, string>();
82
+
83
+ /**
84
+ * `Link` response header naming the stylesheets and, when the page hydrates,
85
+ * the client entry and the route's chunks. The document lists the same files,
86
+ * but a CDN that sends 103 Early Hints (Cloudflare) reads them off this header
87
+ * and lets the browser start downloading while the loaders still run.
88
+ * Prod only — dev URLs carry a cache buster and nothing sits in front.
89
+ */
90
+ export function preloadLinkHeader(pattern: string | undefined, csr: boolean): string | null {
91
+ if (isDev) return null;
92
+ const key = `${csr ? "1" : "0"}${pattern ?? ""}`;
93
+ let value = linkHeaders.get(key);
94
+ if (value === undefined) {
95
+ const tw = distManifest.tw ? `${DIST}/${distManifest.tw}` : TW_CSS;
96
+ const parts = [tw, ...(distManifest.css ?? []).map((f) => `${DIST}/${f}`)].map(
97
+ (href) => `<${href}>; rel=preload; as=style`,
98
+ );
99
+ if (csr) {
100
+ const scripts = [
101
+ ENTRY,
102
+ ...(distManifest.preload?.[pattern ?? ""] ?? []).map((f) => `${DIST}/${f}`),
103
+ ];
104
+ for (const href of scripts) parts.push(`<${href}>; rel=modulepreload`);
105
+ }
106
+ value = parts.join(", ");
107
+ linkHeaders.set(key, value);
108
+ }
109
+ return value;
110
+ }
111
+
112
+ /** `extra` with the preload `Link` header added — after any `link` a loader set. */
113
+ export function withPreloadLink(
114
+ extra: Record<string, string> | undefined,
115
+ pattern: string | undefined,
116
+ csr: boolean,
117
+ ): Record<string, string> | undefined {
118
+ const link = preloadLinkHeader(pattern, csr);
119
+ if (!link) return extra;
120
+ const own = extra?.["link"];
121
+ return { ...extra, link: own ? `${own}, ${link}` : link };
122
+ }
123
+
124
+ // The head fragments below are the same on every request, so each is built
125
+ // once and reused for as long as the manifest field it reads stays the same
126
+ // object (tests swap them out).
127
+ const preloadLinks = new WeakMap<object, Map<string, string>>();
128
+
71
129
  /** Tailwind stylesheet link. Content-hashed name needs no cache buster — the
72
130
  * hash IS the buster. Fallback keeps older dist/ artifacts (no `tw` field) styled. */
131
+ let twLink: { tw: string | undefined; html: string } | null = null;
73
132
  function twCssLink(): string {
74
- return distManifest.tw
75
- ? `<link rel="stylesheet" href="${DIST}/${distManifest.tw}">`
76
- : `<link rel="stylesheet" href="${TW_CSS}${cacheBust}">`;
133
+ const tw = distManifest.tw;
134
+ if (!twLink || twLink.tw !== tw) {
135
+ twLink = {
136
+ tw,
137
+ html: tw
138
+ ? `<link rel="stylesheet" href="${DIST}/${tw}">`
139
+ : `<link rel="stylesheet" href="${TW_CSS}${cacheBust}">`,
140
+ };
141
+ }
142
+ return twLink.html;
77
143
  }
78
144
 
79
145
  /** The build-time component stylesheet (scoped `<style>` blocks, concatenated).
@@ -83,10 +149,18 @@ function twCssLink(): string {
83
149
  * is settled on source order. Linking before Tailwind would silently flip
84
150
  * which one wins. Each entry carries its own indent and newline, so an app with
85
151
  * no scoped styles at all contributes nothing rather than a blank line. */
152
+ let cssLinks: { css: string[] | undefined; html: string } | null = null;
86
153
  function componentCssLinks(): string {
87
- return (distManifest.css ?? [])
88
- .map((f: string) => ` <link rel="stylesheet" href="${DIST}/${f}">\n`)
89
- .join("");
154
+ const css = distManifest.css;
155
+ if (!cssLinks || cssLinks.css !== css) {
156
+ cssLinks = {
157
+ css,
158
+ html: (css ?? [])
159
+ .map((f: string) => ` <link rel="stylesheet" href="${DIST}/${f}">\n`)
160
+ .join(""),
161
+ };
162
+ }
163
+ return cssLinks.html;
90
164
  }
91
165
 
92
166
  /** Inline theme bootstrap — runs before paint to avoid FOUC. theme ∈ light|dark|system (missing = system). */
@@ -464,27 +538,74 @@ export function disableCompression(): void {
464
538
  // Shared, stateless — one instance instead of a fresh allocation per response.
465
539
  const textEncoder = new TextEncoder();
466
540
 
467
- export type Encoding = "br" | "gzip";
541
+ /** Encodings stored ahead of time — cache entries and build-time static files. */
542
+ export type StoredEncoding = "br" | "gzip";
543
+ export type Encoding = StoredEncoding | "zstd";
468
544
 
469
- /** Best encoding the client accepts — brotli over gzip, null for identity.
545
+ /** Best stored encoding the client accepts — brotli over gzip, null for identity.
470
546
  * A substring check, not q-value parsing: no browser sends `br;q=0`. */
471
- export function pickEncoding(accept: string | null): Encoding | null {
547
+ export function pickEncoding(accept: string | null): StoredEncoding | null {
472
548
  if (!accept) return null;
473
549
  if (accept.includes("br")) return "br";
474
550
  if (accept.includes("gzip")) return "gzip";
475
551
  return null;
476
552
  }
477
553
 
478
- // Quality 5: close to gzip's speed with a better ratio — runs per request.
479
- const BROTLI_RUNTIME = { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 } };
554
+ /** Best encoding for a body compressed on the spot. zstd comes first: at
555
+ * level 3 it matches brotli q3's size on an HTML page in about half the CPU
556
+ * (55µs vs 95µs for 7KB). Current Chrome and Firefox send it; everyone else
557
+ * falls back to `pickEncoding`. */
558
+ export function pickRequestEncoding(accept: string | null): Encoding | null {
559
+ if (accept?.includes("zstd")) return "zstd";
560
+ return pickEncoding(accept);
561
+ }
480
562
 
481
- /** Compress bytes on the request path. The one place runtime quality is set —
482
- * cache.ts builds its stored variants through here too. */
483
- export function encodeBytes(bytes: Uint8Array, enc: Encoding): Uint8Array<ArrayBuffer> {
484
- const out = enc === "br" ? brotliCompressSync(bytes, BROTLI_RUNTIME) : gzipSync(bytes);
563
+ // Per-request brotli runs once per response, so it favors speed: q3 is ~2x
564
+ // faster than q5 for ~5–25% bigger output. Cached variants are built once and
565
+ // served many times, so they take q5. 11 (the default) would block the event
566
+ // loop ~17ms per 500KB.
567
+ export type Quality = "request" | "cache";
568
+ const BROTLI = {
569
+ request: { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 3 } },
570
+ cache: { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 } },
571
+ };
572
+
573
+ // Bun's native gzip is ~40% faster than node:zlib's at the same level and
574
+ // size. Workers has no `Bun`, but never compresses (see compressionOn).
575
+ const hasBun = typeof Bun !== "undefined";
576
+
577
+ /** The one place runtime compression quality is set — cache.ts builds its
578
+ * stored variants through here too. gzip keeps zlib's default level. */
579
+ export function encodeBytes(
580
+ bytes: Uint8Array,
581
+ enc: Encoding,
582
+ quality: Quality = "request",
583
+ ): Uint8Array<ArrayBuffer> {
584
+ if (enc === "zstd") return Bun.zstdCompressSync(bytes, { level: 3 }) as Uint8Array<ArrayBuffer>;
585
+ if (enc === "gzip" && hasBun)
586
+ return Bun.gzipSync(bytes as Uint8Array<ArrayBuffer>) as Uint8Array<ArrayBuffer>;
587
+ const out = enc === "br" ? brotliCompressSync(bytes, BROTLI[quality]) : gzipSync(bytes);
485
588
  return new Uint8Array(out) as Uint8Array<ArrayBuffer>;
486
589
  }
487
590
 
591
+ export type Encoded = { enc: Encoding; encoded: Uint8Array<ArrayBuffer> };
592
+
593
+ /** The body this client gets, or null when it goes out uncompressed. */
594
+ export function encodeForRequest(
595
+ bytes: Uint8Array<ArrayBuffer>,
596
+ req: Request,
597
+ quality: Quality = "request",
598
+ ): Encoded | null {
599
+ const accept = req.headers.get("accept-encoding");
600
+ // A cache-quality body is also stored in the cache entry, which only keeps
601
+ // brotli and gzip copies — so it never picks zstd.
602
+ const enc = quality === "cache" ? pickEncoding(accept) : pickRequestEncoding(accept);
603
+ // Skip compression in dev — the dev proxy's fetch() auto-decompresses gzip
604
+ // responses but keeps the Content-Encoding header, causing ERR_CONTENT_DECODING_FAILED.
605
+ if (!compressionOn || isDev || !enc || bytes.length <= GZIP_MIN_BYTES) return null;
606
+ return { enc, encoded: encodeBytes(bytes, enc, quality) };
607
+ }
608
+
488
609
  export function compress(
489
610
  body: string,
490
611
  contentType: string,
@@ -495,12 +616,15 @@ export function compress(
495
616
  return compressBytes(textEncoder.encode(body), contentType, req, status, extraHeaders);
496
617
  }
497
618
 
619
+ /** `encoded`: the result of `encodeForRequest` when the caller already has it
620
+ * (a cache write reuses the same bytes); omitted, it is built here. */
498
621
  export function compressBytes(
499
622
  bytes: Uint8Array<ArrayBuffer>,
500
623
  contentType: string,
501
624
  req: Request,
502
625
  status = 200,
503
626
  extraHeaders?: Record<string, string>,
627
+ encoded: Encoded | null = encodeForRequest(bytes, req),
504
628
  ): Response {
505
629
  // Base keys lowercased so lowercased extraHeaders (e.g. loader setHeaders)
506
630
  // override them instead of getting comma-joined by Headers.
@@ -509,14 +633,11 @@ export function compressBytes(
509
633
  vary: "Accept-Encoding",
510
634
  ...extraHeaders,
511
635
  };
512
- const enc = pickEncoding(req.headers.get("accept-encoding"));
513
- // Skip compression in dev — the dev proxy's fetch() auto-decompresses gzip
514
- // responses but keeps the Content-Encoding header, causing ERR_CONTENT_DECODING_FAILED.
515
- if (compressionOn && !isDev && enc && bytes.length > GZIP_MIN_BYTES) {
516
- return new Response(encodeBytes(bytes, enc), {
636
+ if (encoded) {
637
+ return new Response(encoded.encoded, {
517
638
  ...PRECOMPRESSED,
518
639
  status,
519
- headers: { ...headers, "content-encoding": enc },
640
+ headers: { ...headers, "content-encoding": encoded.enc },
520
641
  });
521
642
  }
522
643
  return new Response(bytes, { status, headers });
@@ -2,7 +2,7 @@ import { parse, compile } from "svelte/compiler";
2
2
  import MagicString from "magic-string";
3
3
  import { relative } from "node:path";
4
4
  import type { BunPlugin } from "bun";
5
- import { svelteMapCache } from "../../svelteCompiler.ts";
5
+ import { cachedCompile, svelteMapCache } from "../../svelteCompiler.ts";
6
6
  import { lineColFromOffset } from "../../sourceLoc.ts";
7
7
  import { toPosix } from "../../paths.ts";
8
8
  import { collectComponentCss } from "../../componentCss.ts";
@@ -133,21 +133,35 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
133
133
  const rel = toPosix(relative(cwd, args.path));
134
134
  const transformed = injectLocs(source, rel);
135
135
 
136
- const result = compile(transformed, {
137
- filename: args.path,
138
- generate,
139
- dev,
140
- hmr: dev,
141
- // Mirror the prod compiler (svelteCompiler.ts): external on both
142
- // targets, with the client's rules harvested into one stylesheet
143
- // that the head links. This plugin registers ahead of the main
144
- // compiler and its onLoad wins in dev, so letting the two drift
145
- // here is how dev and prod stop agreeing about first paint.
146
- css: "external",
147
- preserveWhitespace: dev,
148
- preserveComments: dev,
149
- cssHash: ({ css }) => `svelte-${fnv(css)}`,
150
- });
136
+ // Reused across `bosia dev` rebuilds when nothing it depends on changed
137
+ // (see cachedCompile in svelteCompiler.ts).
138
+ const { value: result } = await cachedCompile(
139
+ ["inspector", args.path, generate, dev, transformed],
140
+ () => {
141
+ const out = compile(transformed, {
142
+ filename: args.path,
143
+ generate,
144
+ dev,
145
+ hmr: dev,
146
+ // Mirror the prod compiler (svelteCompiler.ts): external on both
147
+ // targets, with the client's rules harvested into one stylesheet
148
+ // that the head links. This plugin registers ahead of the main
149
+ // compiler and its onLoad wins in dev, so letting the two drift
150
+ // here is how dev and prod stop agreeing about first paint.
151
+ css: "external",
152
+ preserveWhitespace: dev,
153
+ preserveComments: dev,
154
+ cssHash: ({ css }) => `svelte-${fnv(css)}`,
155
+ });
156
+ return {
157
+ js: {
158
+ code: out.js.code,
159
+ map: typeof out.js.map === "string" ? JSON.parse(out.js.map) : out.js.map,
160
+ },
161
+ css: out.css ? { code: out.css.code } : null,
162
+ };
163
+ },
164
+ );
151
165
 
152
166
  // Store Svelte's compile-step map. Bun's bundler doesn't chain
153
167
  // onLoad sourcemaps, so the final bundle map resolves stack frames
@@ -164,8 +178,7 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
164
178
  // line numbers differ. The resolver translates browser-side stack
165
179
  // frames (delivered via SSE), which run client code.
166
180
  if (dev && generate === "client" && result.js.map) {
167
- const m = typeof result.js.map === "string" ? JSON.parse(result.js.map) : result.js.map;
168
- svelteMapCache.set(args.path, m);
181
+ svelteMapCache.set(args.path, result.js.map);
169
182
  }
170
183
 
171
184
  // Client only — see the same guard in svelteCompiler.ts.
@@ -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";
@@ -33,7 +32,10 @@ import {
33
32
  buildHtmlTail,
34
33
  compress,
35
34
  compressBytes,
35
+ encodeForRequest,
36
36
  isDev,
37
+ withPreloadLink,
38
+ type Encoded,
37
39
  } from "./html.ts";
38
40
  import type { Metadata } from "./hooks.ts";
39
41
  import { loadPlugins } from "./config.ts";
@@ -356,20 +358,17 @@ function makeDepends(deps: LoaderDeps): (...keys: string[]) => void {
356
358
  // Runs layout + page server loaders for a given URL.
357
359
  // Used by both SSR and the /__bosia/data JSON endpoint.
358
360
  //
359
- // `mask` controls selective re-runs from the client data endpoint:
360
- // - undefined → run everything (SSR, first nav)
361
- // - layouts[i] === true → run that layout; false → skip, emit null
362
- // - page === true → run page; false → skip, emit null
363
- // When skipped, the parent() chain still receives the *combined parent
364
- // data* contributed by previously-cached layers. The client already holds
365
- // each skipped layer's data in its loader cache and forwards it as
366
- // `parentSnapshots` (depth → data) in the request body, so downstream
367
- // loaders that DO re-run see real parent() data, not `{}`. The response
368
- // 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
369
366
  //
370
- // Trust boundary: parentSnapshots are a client-supplied perf hint, never
371
- // authoritative. Anything authz-related must read `event.locals` (populated
372
- // 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.
373
372
 
374
373
  export type LoaderMask = {
375
374
  page: boolean;
@@ -381,10 +380,9 @@ export async function loadRouteData(
381
380
  locals: Record<string, any>,
382
381
  req: Request,
383
382
  cookies: Cookies,
384
- metadataData: Record<string, any> | null = null,
383
+ metadataData: Record<string, any> | null | Promise<Record<string, any> | null> = null,
385
384
  match?: RouteMatch<(typeof serverRoutes)[number]> | null,
386
385
  mask?: LoaderMask,
387
- parentSnapshots?: Record<number, Record<string, any>>,
388
386
  ) {
389
387
  match ??= findMatch(serverRoutes, url.pathname);
390
388
  if (!match) return null;
@@ -402,18 +400,9 @@ export async function loadRouteData(
402
400
 
403
401
  // Run layout server loaders root → leaf, each gets parent() data
404
402
  for (const ls of route.layoutServers) {
405
- 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;
406
405
  try {
407
- if (skip) {
408
- layoutData[ls.depth] = null;
409
- layoutDeps[ls.depth] = null;
410
- // Skipped layers contribute their client-cached data (forwarded as
411
- // parentSnapshots) to the parent chain, so downstream loaders that DO
412
- // re-run see real parent() data. Falls back to {} when no snapshot was
413
- // sent. Perf hint only — never authoritative for authz (use locals).
414
- parentData = { ...parentData, ...(parentSnapshots?.[ls.depth] ?? {}) };
415
- continue;
416
- }
417
406
  const mod = await ls.loader();
418
407
  if (typeof mod.load === "function" && !warmingUp) {
419
408
  // Snapshot per layer so loaders cannot mutate the shared accumulator,
@@ -425,6 +414,7 @@ export async function loadRouteData(
425
414
  (await withTimeout(
426
415
  mod.load({
427
416
  params: trackedParams(params, deps),
417
+ route: { id: route.id },
428
418
  url: trackedUrl(url, deps),
429
419
  locals,
430
420
  cookies: trackedCookies(cookies, deps),
@@ -438,12 +428,12 @@ export async function loadRouteData(
438
428
  LOAD_TIMEOUT,
439
429
  `layout load (depth=${ls.depth}, ${url.pathname})`,
440
430
  )) ?? {};
441
- layoutData[ls.depth] = result;
442
- layoutDeps[ls.depth] = deps;
431
+ layoutData[ls.depth] = omit ? null : result;
432
+ layoutDeps[ls.depth] = omit ? null : deps;
443
433
  parentData = { ...parentData, ...result };
444
434
  } else {
445
- layoutData[ls.depth] = {};
446
- layoutDeps[ls.depth] = emptyDeps();
435
+ layoutData[ls.depth] = omit ? null : {};
436
+ layoutDeps[ls.depth] = omit ? null : emptyDeps();
447
437
  }
448
438
  } catch (err) {
449
439
  if (isRedirect(err)) throw err;
@@ -492,12 +482,13 @@ export async function loadRouteData(
492
482
  (await withTimeout(
493
483
  mod.load({
494
484
  params: trackedParams(params, deps),
485
+ route: { id: route.id },
495
486
  url: trackedUrl(url, deps),
496
487
  locals,
497
488
  cookies: trackedCookies(cookies, deps),
498
489
  parent,
499
490
  fetch: trackedFetch(fetch, origin, deps),
500
- metadata: metadataData,
491
+ metadata: await metadataData,
501
492
  depends: makeDepends(deps),
502
493
  platform: getPlatform(),
503
494
  setHeaders,
@@ -562,7 +553,15 @@ export async function loadMetadata(
562
553
  const fetch = makeFetch(req, url);
563
554
  return (
564
555
  (await withTimeout(
565
- 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
+ }),
566
565
  METADATA_TIMEOUT,
567
566
  `metadata (${url.pathname})`,
568
567
  )) ?? null
@@ -644,12 +643,53 @@ export async function renderSSRStream(
644
643
  // below must stay inside this try; the cache-write path hands the release
645
644
  // off to its deferred cacheSet by nulling releaseMiss first.
646
645
  try {
647
- // ── Pre-stream phase: resolve metadata before committing to a 200 ──
648
- // 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.
649
687
  let metadata: Metadata | null = null;
650
- try {
651
- metadata = await loadMetadata(route, params, url, locals, cookies, req);
652
- } catch (err) {
688
+ const meta = await metadataP;
689
+ if (meta.ok) {
690
+ metadata = meta.value;
691
+ } else {
692
+ const err = meta.err;
653
693
  if (isRedirect(err)) {
654
694
  // Not Response.redirect(): it rejects relative URLs on Workers.
655
695
  return new Response(null, { status: err.status, headers: { Location: err.location } });
@@ -673,21 +713,12 @@ export async function renderSSRStream(
673
713
  // Continue with null metadata — don't break the page for a metadata failure
674
714
  }
675
715
 
676
- // ── Pre-stream phase: run load() + module imports in parallel before committing to a 200 ──
677
- // This ensures HttpError/Redirect from load() can return a proper response before any bytes are sent.
678
- const metadataData = metadata?.data ?? null;
679
716
  let data: Awaited<ReturnType<typeof loadRouteData>>;
680
717
  let layoutMods: any[];
681
718
 
682
719
  try {
683
- // pageMod is already loaded when the cache flag was unknown; otherwise
684
- // fold its import into this parallel block instead of a serial await.
685
720
  let pm: any;
686
- [data, layoutMods, pm] = await Promise.all([
687
- loadRouteData(url, locals, req, cookies, metadataData, match),
688
- Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
689
- pageMod ?? route.pageModule(),
690
- ]);
721
+ [data, layoutMods, pm] = await dataP;
691
722
  pageMod = pm;
692
723
  } catch (err) {
693
724
  if (isRedirect(err))
@@ -739,16 +770,7 @@ export async function renderSSRStream(
739
770
  nonce,
740
771
  );
741
772
 
742
- const renderCtx: RenderContext = {
743
- request: req,
744
- url,
745
- route: { pattern: route.pattern },
746
- metadata,
747
- };
748
- const [headExtras, bodyEndExtras] = await Promise.all([
749
- pluginRenderFragments("head", renderCtx),
750
- pluginRenderFragments("bodyEnd", renderCtx),
751
- ]);
773
+ const [headExtras, bodyEndExtras] = await fragmentsP;
752
774
 
753
775
  // SSR always runs every loader, so coerce types from the optional sparse shape.
754
776
  const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
@@ -779,7 +801,13 @@ export async function renderSSRStream(
779
801
  data.layoutDeps,
780
802
  appHtmlSegments,
781
803
  );
782
- 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
+ );
783
811
  }
784
812
 
785
813
  // Render-first: run render() before committing to a 200. Failure → proper error page
@@ -821,17 +849,15 @@ export async function renderSSRStream(
821
849
  // compressed — a stream here never streamed, it only skipped compression.
822
850
  // Truly progressive SSR (ROADMAP) would pipe a stream through
823
851
  // CompressionStream instead.
824
- const chunks: Uint8Array[] = [
825
- enc.encode(
826
- buildHtmlShellOpen(
827
- metadata?.lang,
828
- nonce,
829
- appHtmlSegments,
830
- data.csr ? route.pattern : undefined,
831
- ),
832
- ),
833
- enc.encode(buildMetadataChunk(metadata, headExtras, appHtmlSegments)),
834
- 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) +
835
861
  buildHtmlTail(
836
862
  body,
837
863
  head,
@@ -846,10 +872,12 @@ export async function renderSSRStream(
846
872
  data.layoutDeps,
847
873
  appHtmlSegments,
848
874
  ),
849
- ),
850
- ];
851
-
852
- 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) ?? {};
878
+ // Set only on a cache write: the client's variant, encoded once at cache
879
+ // quality and shared by the response and the cache entry.
880
+ let sent: Encoded | null | undefined;
853
881
 
854
882
  // ── Response cache: write after the response has gone out ──
855
883
  // Skip if the handler set cookies — cached response can't reproduce
@@ -865,9 +893,18 @@ export async function renderSSRStream(
865
893
  // after cacheSet ran, so their re-check hits.
866
894
  const rel = releaseMiss;
867
895
  releaseMiss = null;
896
+ const encoded = encodeForRequest(fullBody, req, "cache");
897
+ sent = encoded;
868
898
  deferCacheWrite(() => {
869
899
  try {
870
- const { gzip, brotli } = buildCompressedVariants(fullBody);
900
+ const { gzip, brotli } = buildCompressedVariants(
901
+ fullBody,
902
+ encoded?.enc === "br"
903
+ ? { brotli: encoded.encoded }
904
+ : encoded
905
+ ? { gzip: encoded.encoded }
906
+ : {},
907
+ );
871
908
  cacheSet(
872
909
  keyForWrite,
873
910
  {
@@ -876,7 +913,7 @@ export async function renderSSRStream(
876
913
  brotli,
877
914
  contentType: "text/html; charset=utf-8",
878
915
  status: 200,
879
- extraHeaders: data.loaderHeaders,
916
+ extraHeaders: headers,
880
917
  tags,
881
918
  },
882
919
  cookies,
@@ -888,7 +925,7 @@ export async function renderSSRStream(
888
925
  }
889
926
  }
890
927
 
891
- return compressBytes(fullBody, "text/html; charset=utf-8", req, 200, data.loaderHeaders);
928
+ return compressBytes(fullBody, "text/html; charset=utf-8", req, 200, headers, sent);
892
929
  } finally {
893
930
  releaseMiss?.();
894
931
  }