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/package.json +1 -1
- package/src/ambient.d.ts +3 -0
- package/src/cli/add.ts +15 -12
- package/src/cli/block.ts +18 -16
- package/src/cli/feat.ts +31 -11
- package/src/cli/page.ts +18 -16
- package/src/cli/registry.ts +117 -20
- package/src/cli/theme.ts +7 -2
- package/src/core/assetCache.ts +124 -0
- package/src/core/backend.ts +8 -1
- package/src/core/build.ts +7 -2
- package/src/core/cache.ts +51 -23
- package/src/core/client/App.svelte +15 -26
- package/src/core/client/page.svelte.ts +15 -0
- package/src/core/client/prefetch.ts +55 -60
- package/src/core/dev.ts +11 -1
- package/src/core/env.ts +18 -1
- package/src/core/etag.ts +13 -0
- package/src/core/hooks.ts +17 -0
- package/src/core/html.ts +146 -25
- package/src/core/plugins/inspector/bun-plugin.ts +31 -18
- package/src/core/renderer.ts +114 -77
- package/src/core/routeFile.ts +36 -20
- package/src/core/scanner.ts +2 -0
- package/src/core/server.bun.ts +4 -0
- package/src/core/server.ts +294 -94
- package/src/core/staticManifest.ts +127 -14
- package/src/core/svelteCompiler.ts +96 -27
- package/src/core/types.ts +4 -0
- package/templates/default/.env.example +7 -0
- package/templates/demo/.env.example +7 -0
- package/templates/shop/.env.example +3 -0
- package/templates/shop/src/hooks.server.ts +12 -2
- package/templates/shop/src/routes/(private)/+layout.server.ts +2 -6
- package/templates/store/.env.example +3 -0
- package/templates/store/src/hooks.server.ts +12 -2
- package/templates/store/src/routes/(private)/+layout.server.ts +2 -6
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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):
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
513
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
dev,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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.
|
package/src/core/renderer.ts
CHANGED
|
@@ -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
|
|
360
|
-
// - undefined →
|
|
361
|
-
// - layouts[i] ===
|
|
362
|
-
//
|
|
363
|
-
//
|
|
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
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
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
|
-
|
|
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({
|
|
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:
|
|
648
|
-
//
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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,
|
|
928
|
+
return compressBytes(fullBody, "text/html; charset=utf-8", req, 200, headers, sent);
|
|
892
929
|
} finally {
|
|
893
930
|
releaseMiss?.();
|
|
894
931
|
}
|