bosia 1.1.1 → 1.1.3
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/core/build.ts +34 -5
- package/src/core/cache.ts +21 -11
- package/src/core/html.ts +62 -9
- package/src/core/plugins/server-timing.ts +3 -3
- package/src/core/precompress.ts +82 -0
- package/src/core/preloadMap.ts +58 -0
- package/src/core/renderer.ts +27 -42
- package/src/core/server.ts +17 -23
- package/src/core/staticManifest.ts +65 -8
package/package.json
CHANGED
package/src/core/build.ts
CHANGED
|
@@ -17,7 +17,9 @@ import { generateRouteTypes, ensureRootDirs } from "./routeTypes.ts";
|
|
|
17
17
|
import { makeBosiaPlugin } from "./plugin.ts";
|
|
18
18
|
import { makeBosiaSvelteCompiler, svelteMapCache } from "./svelteCompiler.ts";
|
|
19
19
|
import { finalizeComponentCss } from "./componentCss.ts";
|
|
20
|
+
import { buildPreloadMap } from "./preloadMap.ts";
|
|
20
21
|
import { prerenderStaticRoutes, generateStaticSite } from "./prerender.ts";
|
|
22
|
+
import { precompressDir } from "./precompress.ts";
|
|
21
23
|
import { loadEnv, classifyEnvVars } from "./env.ts";
|
|
22
24
|
import { generateEnvModules } from "./envCodegen.ts";
|
|
23
25
|
import { BOSIA_NODE_PATH, OUT_DIR, resolveBosiaBin, toPosix } from "./paths.ts";
|
|
@@ -252,6 +254,8 @@ const clientPromise = Bun.build({
|
|
|
252
254
|
// Chunks are named after their source, which for routes is `+page` — and
|
|
253
255
|
// Cloudflare's asset server answers a `+` in the path with a redirect.
|
|
254
256
|
naming: { entry: "[name]-[hash].[ext]", chunk: "chunk-[hash].[ext]" },
|
|
257
|
+
// Read below to map each route to the chunks it needs (see preloadMap.ts).
|
|
258
|
+
metafile: true,
|
|
255
259
|
minify: isProduction,
|
|
256
260
|
sourcemap: isProduction ? "none" : "linked",
|
|
257
261
|
define: {
|
|
@@ -346,14 +350,20 @@ const serverEntry = serverEntryOutput ? basename(serverEntryOutput.path) : "inde
|
|
|
346
350
|
|
|
347
351
|
// 8. Write dist/manifest.json
|
|
348
352
|
mkdirSync(OUT_DIR, { recursive: true });
|
|
353
|
+
const entryFile =
|
|
354
|
+
clientEntry ??
|
|
355
|
+
jsFiles.find((f) => f === "hydrate.js") ??
|
|
356
|
+
jsFiles.find((f) => f.startsWith("hydrate")) ??
|
|
357
|
+
"hydrate.js";
|
|
349
358
|
const distManifest = {
|
|
350
359
|
js: jsFiles,
|
|
351
360
|
css: cssFiles,
|
|
352
|
-
entry:
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
361
|
+
entry: entryFile,
|
|
362
|
+
// Per-route modulepreload list, so a page's code downloads with the entry
|
|
363
|
+
// instead of after it.
|
|
364
|
+
preload: clientResult.metafile
|
|
365
|
+
? buildPreloadMap(clientResult.metafile, manifest.pages, entryFile)
|
|
366
|
+
: {},
|
|
357
367
|
serverEntry,
|
|
358
368
|
target,
|
|
359
369
|
tw: twFile,
|
|
@@ -389,6 +399,25 @@ await prerenderStaticRoutes(manifest);
|
|
|
389
399
|
// 10. Generate static site output (HTML + client assets + public → dist/static/)
|
|
390
400
|
generateStaticSite();
|
|
391
401
|
|
|
402
|
+
// 10b. Precompress client assets + prerendered HTML (.br/.gz siblings) for the
|
|
403
|
+
// Bun server. Runs after the static mirror so dist/static — the Workers upload
|
|
404
|
+
// and static-host output — stays free of them; Workers' edge compresses itself.
|
|
405
|
+
// public/ is left raw: mostly already-compressed images, and it is the app's
|
|
406
|
+
// source dir. Precompress into dist/static and serve from there if that bites.
|
|
407
|
+
if (isProduction && target !== "workers") {
|
|
408
|
+
const t0 = performance.now();
|
|
409
|
+
const results = await Promise.all([
|
|
410
|
+
precompressDir(join(OUT_DIR, "client")),
|
|
411
|
+
precompressDir(join(OUT_DIR, "prerendered")),
|
|
412
|
+
]);
|
|
413
|
+
const files = results.reduce((n, r) => n + r.files, 0);
|
|
414
|
+
const raw = results.reduce((n, r) => n + r.rawBytes, 0);
|
|
415
|
+
const br = results.reduce((n, r) => n + r.brBytes, 0);
|
|
416
|
+
console.log(
|
|
417
|
+
`✅ Precompressed ${files} files: ${Math.round(raw / 1024)}KB → ${Math.round(br / 1024)}KB br (${Math.round(performance.now() - t0)}ms)`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
392
421
|
// 11. Workers target: a second server bundle for Cloudflare. The Bun one above
|
|
393
422
|
// still exists — prerender just booted it to crawl static routes.
|
|
394
423
|
if (target === "workers") await buildWorker();
|
package/src/core/cache.ts
CHANGED
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
// node:crypto / node:zlib rather than Bun.* — the same code runs on Bun and on
|
|
9
9
|
// Cloudflare Workers (nodejs_compat), and both stay sync there.
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
|
-
import { brotliCompressSync, gzipSync, constants as zlibConstants } from "node:zlib";
|
|
12
11
|
import type { Cookies, LoaderDeps } from "./hooks.ts";
|
|
13
12
|
import type { CookieJar } from "./cookies.ts";
|
|
14
13
|
import { dedupKey } from "./dedup.ts";
|
|
15
|
-
import { compressionOn, PRECOMPRESSED } from "./html.ts";
|
|
14
|
+
import { compressionOn, encodeBytes, PRECOMPRESSED } from "./html.ts";
|
|
16
15
|
|
|
17
16
|
// ─── Config ──────────────────────────────────────────────
|
|
18
17
|
|
|
@@ -280,7 +279,7 @@ function cacheDeleteIndexOnly(key: string, entry: CacheEntry): void {
|
|
|
280
279
|
|
|
281
280
|
// ─── Compression helpers ─────────────────────────────────
|
|
282
281
|
|
|
283
|
-
/** Build gzip + brotli copies of body. Sync
|
|
282
|
+
/** Build gzip + brotli copies of body. Sync — call it from `deferCacheWrite`. */
|
|
284
283
|
export function buildCompressedVariants(body: Bytes): {
|
|
285
284
|
gzip: Bytes | null;
|
|
286
285
|
brotli: Bytes | null;
|
|
@@ -290,24 +289,35 @@ export function buildCompressedVariants(body: Bytes): {
|
|
|
290
289
|
let gzip: Bytes | null = null;
|
|
291
290
|
let brotli: Bytes | null = null;
|
|
292
291
|
try {
|
|
293
|
-
gzip =
|
|
292
|
+
gzip = encodeBytes(body, "gzip");
|
|
294
293
|
} catch {
|
|
295
294
|
gzip = null;
|
|
296
295
|
}
|
|
297
296
|
try {
|
|
298
|
-
// Quality 5: ~20x faster than the default 11, still
|
|
299
|
-
//
|
|
300
|
-
brotli =
|
|
301
|
-
brotliCompressSync(body, {
|
|
302
|
-
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 },
|
|
303
|
-
}),
|
|
304
|
-
) as Bytes;
|
|
297
|
+
// Quality 5 (set in encodeBytes): ~20x faster than the default 11, still
|
|
298
|
+
// beats gzip on size. 11 would block the event loop ~17ms/500KB.
|
|
299
|
+
brotli = encodeBytes(body, "br");
|
|
305
300
|
} catch {
|
|
306
301
|
brotli = null;
|
|
307
302
|
}
|
|
308
303
|
return { gzip, brotli };
|
|
309
304
|
}
|
|
310
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Run a cache write after the current response has gone out. A microtask
|
|
308
|
+
* would not: it fires before the awaiting caller resumes, so gzip + brotli of
|
|
309
|
+
* the body ran ahead of the first byte. `setImmediate` yields to I/O first.
|
|
310
|
+
* Workers keeps the microtask — it compresses nothing (the edge does), and a
|
|
311
|
+
* timer after the response returns is not guaranteed to run there.
|
|
312
|
+
*
|
|
313
|
+
* On a miss the variant sent to the client is compressed twice (response +
|
|
314
|
+
* cache). Off the critical path; pass the built variant in if it ever shows up.
|
|
315
|
+
*/
|
|
316
|
+
export function deferCacheWrite(fn: () => void | Promise<void>): void {
|
|
317
|
+
if (compressionOn) setImmediate(fn);
|
|
318
|
+
else queueMicrotask(fn);
|
|
319
|
+
}
|
|
320
|
+
|
|
311
321
|
/** Concatenate multiple Uint8Array chunks into one buffer. */
|
|
312
322
|
export function concatChunks(chunks: Uint8Array[]): Bytes {
|
|
313
323
|
let total = 0;
|
package/src/core/html.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { gzipSync } from "node:zlib";
|
|
1
|
+
import { brotliCompressSync, gzipSync, constants as zlibConstants } from "node:zlib";
|
|
2
2
|
|
|
3
3
|
import { readArtifact } from "./artifacts.ts";
|
|
4
4
|
import { nonceAttr } from "./csp.ts";
|
|
@@ -24,6 +24,8 @@ export const distManifest: {
|
|
|
24
24
|
basePath?: string;
|
|
25
25
|
/** PUBLIC_* (non-static) names declared in .env files, stamped by the build. */
|
|
26
26
|
publicEnv?: string[];
|
|
27
|
+
/** Client chunks per route pattern, stamped by the build (see preloadMap.ts). */
|
|
28
|
+
preload?: Record<string, string[]>;
|
|
27
29
|
} = readArtifact("manifest.json") ?? { js: [], css: [], entry: "hydrate.js" };
|
|
28
30
|
|
|
29
31
|
export const isDev = process.env.NODE_ENV !== "production";
|
|
@@ -55,6 +57,17 @@ export function baseScript(nonce?: string): string {
|
|
|
55
57
|
: "";
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
/** modulepreload links for the chunks `pattern` needs to hydrate, so they
|
|
61
|
+
* download alongside the entry instead of after it has run. Hashed names, so
|
|
62
|
+
* no buster (same reason as ENTRY). Unknown pattern or an older dist/ without
|
|
63
|
+
* the `preload` field → nothing, and the page loads as it did before. */
|
|
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("");
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
/** Tailwind stylesheet link. Content-hashed name needs no cache buster — the
|
|
59
72
|
* hash IS the buster. Fallback keeps older dist/ artifacts (no `tw` field) styled. */
|
|
60
73
|
function twCssLink(): string {
|
|
@@ -161,6 +174,7 @@ export function buildHtml(
|
|
|
161
174
|
bodyEndExtras?: string[],
|
|
162
175
|
segments?: AppHtmlSegments,
|
|
163
176
|
metadata?: Metadata | null,
|
|
177
|
+
pattern?: string,
|
|
164
178
|
): string {
|
|
165
179
|
// An app writes <a href="/masuk">; under a base the browser has to be handed
|
|
166
180
|
// /sso/masuk or it walks off this app entirely. Only the rendered markup is
|
|
@@ -208,6 +222,11 @@ export function buildHtml(
|
|
|
208
222
|
|
|
209
223
|
const bodyEnd = bodyEndExtras?.length ? "\n " + bodyEndExtras.join("\n ") : "";
|
|
210
224
|
|
|
225
|
+
// Same hints the streaming shell sends; skipped when no JS runs at all.
|
|
226
|
+
const preloads = csr
|
|
227
|
+
? ` <link rel="modulepreload" href="${ENTRY}">${routePreloadLinks(pattern)}\n`
|
|
228
|
+
: "";
|
|
229
|
+
|
|
211
230
|
if (segments) {
|
|
212
231
|
const safeKey = safeLang(lang);
|
|
213
232
|
const headOpenInterpolated = interpolateSegment(segments.headOpen, {
|
|
@@ -225,6 +244,7 @@ export function buildHtml(
|
|
|
225
244
|
`\n ${faviconLine}${twCssLink()}\n` +
|
|
226
245
|
componentCssLinks() +
|
|
227
246
|
` <script${n}>${THEME_INIT_JS}</script>\n` +
|
|
247
|
+
preloads +
|
|
228
248
|
` ${fallbackTitle}${metaTags}${head}` +
|
|
229
249
|
headCloseInterpolated +
|
|
230
250
|
(body ? "" : `\n${SPINNER}`) +
|
|
@@ -243,7 +263,7 @@ export function buildHtml(
|
|
|
243
263
|
${metaTags} ${head}
|
|
244
264
|
${twCssLink()}
|
|
245
265
|
${componentCssLinks()} <script${n}>${THEME_INIT_JS}</script>
|
|
246
|
-
</head>
|
|
266
|
+
${preloads}</head>
|
|
247
267
|
<body>
|
|
248
268
|
<div id="app">${body}</div>${scripts}${bodyEnd}
|
|
249
269
|
</body>
|
|
@@ -257,6 +277,7 @@ export function buildHtmlShellOpen(
|
|
|
257
277
|
lang?: string,
|
|
258
278
|
nonce?: string,
|
|
259
279
|
segments?: AppHtmlSegments,
|
|
280
|
+
pattern?: string,
|
|
260
281
|
): string {
|
|
261
282
|
const key = safeLang(lang);
|
|
262
283
|
const n = nonceAttr(nonce);
|
|
@@ -270,7 +291,8 @@ export function buildHtmlShellOpen(
|
|
|
270
291
|
`\n ${faviconLine}${twCssLink()}\n` +
|
|
271
292
|
componentCssLinks() +
|
|
272
293
|
` <script${n}>${THEME_INIT_JS}</script>\n` +
|
|
273
|
-
` <link rel="modulepreload" href="${ENTRY}">`
|
|
294
|
+
` <link rel="modulepreload" href="${ENTRY}">` +
|
|
295
|
+
routePreloadLinks(pattern)
|
|
274
296
|
);
|
|
275
297
|
}
|
|
276
298
|
|
|
@@ -282,7 +304,8 @@ export function buildHtmlShellOpen(
|
|
|
282
304
|
` ${twCssLink()}\n` +
|
|
283
305
|
componentCssLinks() +
|
|
284
306
|
` <script${n}>${THEME_INIT_JS}</script>\n` +
|
|
285
|
-
` <link rel="modulepreload" href="${ENTRY}">`
|
|
307
|
+
` <link rel="modulepreload" href="${ENTRY}">` +
|
|
308
|
+
routePreloadLinks(pattern)
|
|
286
309
|
);
|
|
287
310
|
}
|
|
288
311
|
|
|
@@ -441,12 +464,43 @@ export function disableCompression(): void {
|
|
|
441
464
|
// Shared, stateless — one instance instead of a fresh allocation per response.
|
|
442
465
|
const textEncoder = new TextEncoder();
|
|
443
466
|
|
|
467
|
+
export type Encoding = "br" | "gzip";
|
|
468
|
+
|
|
469
|
+
/** Best encoding the client accepts — brotli over gzip, null for identity.
|
|
470
|
+
* A substring check, not q-value parsing: no browser sends `br;q=0`. */
|
|
471
|
+
export function pickEncoding(accept: string | null): Encoding | null {
|
|
472
|
+
if (!accept) return null;
|
|
473
|
+
if (accept.includes("br")) return "br";
|
|
474
|
+
if (accept.includes("gzip")) return "gzip";
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
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 } };
|
|
480
|
+
|
|
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);
|
|
485
|
+
return new Uint8Array(out) as Uint8Array<ArrayBuffer>;
|
|
486
|
+
}
|
|
487
|
+
|
|
444
488
|
export function compress(
|
|
445
489
|
body: string,
|
|
446
490
|
contentType: string,
|
|
447
491
|
req: Request,
|
|
448
492
|
status = 200,
|
|
449
493
|
extraHeaders?: Record<string, string>,
|
|
494
|
+
): Response {
|
|
495
|
+
return compressBytes(textEncoder.encode(body), contentType, req, status, extraHeaders);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function compressBytes(
|
|
499
|
+
bytes: Uint8Array<ArrayBuffer>,
|
|
500
|
+
contentType: string,
|
|
501
|
+
req: Request,
|
|
502
|
+
status = 200,
|
|
503
|
+
extraHeaders?: Record<string, string>,
|
|
450
504
|
): Response {
|
|
451
505
|
// Base keys lowercased so lowercased extraHeaders (e.g. loader setHeaders)
|
|
452
506
|
// override them instead of getting comma-joined by Headers.
|
|
@@ -455,15 +509,14 @@ export function compress(
|
|
|
455
509
|
vary: "Accept-Encoding",
|
|
456
510
|
...extraHeaders,
|
|
457
511
|
};
|
|
458
|
-
const
|
|
459
|
-
const bytes = textEncoder.encode(body);
|
|
512
|
+
const enc = pickEncoding(req.headers.get("accept-encoding"));
|
|
460
513
|
// Skip compression in dev — the dev proxy's fetch() auto-decompresses gzip
|
|
461
514
|
// responses but keeps the Content-Encoding header, causing ERR_CONTENT_DECODING_FAILED.
|
|
462
|
-
if (compressionOn && !isDev && bytes.length > GZIP_MIN_BYTES
|
|
463
|
-
return new Response(
|
|
515
|
+
if (compressionOn && !isDev && enc && bytes.length > GZIP_MIN_BYTES) {
|
|
516
|
+
return new Response(encodeBytes(bytes, enc), {
|
|
464
517
|
...PRECOMPRESSED,
|
|
465
518
|
status,
|
|
466
|
-
headers: { ...headers, "content-encoding":
|
|
519
|
+
headers: { ...headers, "content-encoding": enc },
|
|
467
520
|
});
|
|
468
521
|
}
|
|
469
522
|
return new Response(bytes, { status, headers });
|
|
@@ -10,9 +10,9 @@ export interface ServerTimingOptions {
|
|
|
10
10
|
/**
|
|
11
11
|
* Adds a `Server-Timing` response header that reports how long each request
|
|
12
12
|
* spent inside the framework's handler. Measured from `onRequest` to
|
|
13
|
-
* `onAfterHandle` —
|
|
14
|
-
* streaming
|
|
15
|
-
* true end-to-end value cannot be reported
|
|
13
|
+
* `onAfterHandle` — the time to build the response, not to deliver it. A
|
|
14
|
+
* streaming `+server.ts` route reports "time to start streaming". Headers must
|
|
15
|
+
* flush before the body, so a true end-to-end value cannot be reported here.
|
|
16
16
|
*/
|
|
17
17
|
export function serverTiming(options: ServerTimingOptions = {}): BosiaPlugin {
|
|
18
18
|
const headerName = options.header ?? "Server-Timing";
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
2
|
+
import { extname, join } from "path";
|
|
3
|
+
import { promisify } from "util";
|
|
4
|
+
import { brotliCompress, gzip, constants as zlibConstants } from "node:zlib";
|
|
5
|
+
|
|
6
|
+
// ─── Build-time Precompression ───────────────────────────
|
|
7
|
+
// Writes `file.br` + `file.gz` next to each compressible build output so the
|
|
8
|
+
// server hands out ready-made bytes (staticManifest.ts `serveStatic`) instead of
|
|
9
|
+
// raw ones. Build time is paid once, so both run at max quality.
|
|
10
|
+
|
|
11
|
+
const brotliAsync = promisify(brotliCompress);
|
|
12
|
+
const gzipAsync = promisify(gzip);
|
|
13
|
+
|
|
14
|
+
const COMPRESSIBLE = new Set([
|
|
15
|
+
".js",
|
|
16
|
+
".css",
|
|
17
|
+
".html",
|
|
18
|
+
".json",
|
|
19
|
+
".svg",
|
|
20
|
+
".txt",
|
|
21
|
+
".xml",
|
|
22
|
+
".webmanifest",
|
|
23
|
+
".map",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
// Same floor as the runtime compress(): below it the overhead beats the saving.
|
|
27
|
+
const MIN_BYTES = 2048;
|
|
28
|
+
|
|
29
|
+
function* files(dir: string): Generator<string> {
|
|
30
|
+
let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
|
|
31
|
+
try {
|
|
32
|
+
entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" }) as unknown as Array<{
|
|
33
|
+
name: string;
|
|
34
|
+
isDirectory(): boolean;
|
|
35
|
+
isFile(): boolean;
|
|
36
|
+
}>;
|
|
37
|
+
} catch {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const ent of entries) {
|
|
41
|
+
const abs = join(dir, ent.name);
|
|
42
|
+
if (ent.isDirectory()) yield* files(abs);
|
|
43
|
+
else if (ent.isFile()) yield abs;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type PrecompressStats = { files: number; rawBytes: number; brBytes: number };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Precompress every compressible file under `dir`. A variant is only written
|
|
51
|
+
* when it is smaller than the original, so the server can trust any sibling it
|
|
52
|
+
* finds. Missing `dir` is a no-op.
|
|
53
|
+
*/
|
|
54
|
+
export async function precompressDir(dir: string): Promise<PrecompressStats> {
|
|
55
|
+
const stats: PrecompressStats = { files: 0, rawBytes: 0, brBytes: 0 };
|
|
56
|
+
const jobs: Promise<void>[] = [];
|
|
57
|
+
for (const abs of files(dir)) {
|
|
58
|
+
if (!COMPRESSIBLE.has(extname(abs))) continue;
|
|
59
|
+
if (statSync(abs).size < MIN_BYTES) continue;
|
|
60
|
+
jobs.push(
|
|
61
|
+
(async () => {
|
|
62
|
+
const raw = readFileSync(abs);
|
|
63
|
+
const [br, gz] = await Promise.all([
|
|
64
|
+
brotliAsync(raw, {
|
|
65
|
+
params: {
|
|
66
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: zlibConstants.BROTLI_MAX_QUALITY,
|
|
67
|
+
[zlibConstants.BROTLI_PARAM_SIZE_HINT]: raw.length,
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
gzipAsync(raw, { level: 9 }),
|
|
71
|
+
]);
|
|
72
|
+
if (br.length < raw.length) writeFileSync(`${abs}.br`, br);
|
|
73
|
+
if (gz.length < raw.length) writeFileSync(`${abs}.gz`, gz);
|
|
74
|
+
stats.files++;
|
|
75
|
+
stats.rawBytes += raw.length;
|
|
76
|
+
stats.brBytes += Math.min(br.length, raw.length);
|
|
77
|
+
})(),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
await Promise.all(jobs);
|
|
81
|
+
return stats;
|
|
82
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { toPosix } from "./paths.ts";
|
|
2
|
+
import type { PageRoute } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** The slice of Bun's `BuildMetafile` this module reads. */
|
|
5
|
+
export interface PreloadMetafile {
|
|
6
|
+
outputs: Record<string, { entryPoint?: string; imports?: { path: string; kind: string }[] }>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Client chunks each page route needs before it can hydrate, keyed by route
|
|
11
|
+
* pattern, as paths relative to `dist/client` (same shape as `manifest.js`).
|
|
12
|
+
*
|
|
13
|
+
* Without this the browser only learns about a route's code after the entry has
|
|
14
|
+
* run and `import()`ed it, and only learns about that chunk's own imports after
|
|
15
|
+
* it arrives — a serial waterfall during which clicks hit dead buttons. Emitting
|
|
16
|
+
* these as modulepreload links lets them download alongside the entry.
|
|
17
|
+
*
|
|
18
|
+
* Route chunks are found by their metafile `entryPoint` (Bun makes every
|
|
19
|
+
* dynamically imported route file its own entry chunk, named relative to the
|
|
20
|
+
* app root), then followed through static imports only — a `dynamic-import`
|
|
21
|
+
* edge is code the page may never ask for. The client entry is left out: the
|
|
22
|
+
* shell already preloads it.
|
|
23
|
+
*/
|
|
24
|
+
export function buildPreloadMap(
|
|
25
|
+
metafile: PreloadMetafile,
|
|
26
|
+
pages: Pick<PageRoute, "pattern" | "page" | "layouts">[],
|
|
27
|
+
entry: string,
|
|
28
|
+
): Record<string, string[]> {
|
|
29
|
+
const clean = (p: string) => toPosix(p).replace(/^\.\//, "");
|
|
30
|
+
const outputs = new Map<string, { path: string; kind: string }[]>();
|
|
31
|
+
const chunkBySource = new Map<string, string>();
|
|
32
|
+
for (const [out, info] of Object.entries(metafile.outputs)) {
|
|
33
|
+
if (!out.endsWith(".js")) continue;
|
|
34
|
+
outputs.set(clean(out), info.imports ?? []);
|
|
35
|
+
if (info.entryPoint) chunkBySource.set(clean(info.entryPoint), clean(out));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const map: Record<string, string[]> = {};
|
|
39
|
+
for (const route of pages) {
|
|
40
|
+
const seen = new Set<string>([entry]);
|
|
41
|
+
const files: string[] = [];
|
|
42
|
+
const visit = (chunk: string) => {
|
|
43
|
+
if (seen.has(chunk)) return;
|
|
44
|
+
seen.add(chunk);
|
|
45
|
+
files.push(chunk);
|
|
46
|
+
for (const imp of outputs.get(chunk) ?? []) {
|
|
47
|
+
if (imp.kind === "import-statement") visit(clean(imp.path));
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
// Layouts first: they wrap the page, so hydration needs them just as much.
|
|
51
|
+
for (const src of [...route.layouts, route.page]) {
|
|
52
|
+
const chunk = chunkBySource.get(`src/routes/${toPosix(src)}`);
|
|
53
|
+
if (chunk) visit(chunk);
|
|
54
|
+
}
|
|
55
|
+
if (files.length) map[route.pattern] = files;
|
|
56
|
+
}
|
|
57
|
+
return map;
|
|
58
|
+
}
|
package/src/core/renderer.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
collectTags,
|
|
17
17
|
computeCacheKey,
|
|
18
18
|
concatChunks,
|
|
19
|
+
deferCacheWrite,
|
|
19
20
|
serveCached,
|
|
20
21
|
} from "./cache.ts";
|
|
21
22
|
import type { CookieJar } from "./cookies.ts";
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
buildMetadataChunk,
|
|
32
33
|
buildHtmlTail,
|
|
33
34
|
compress,
|
|
35
|
+
compressBytes,
|
|
34
36
|
isDev,
|
|
35
37
|
} from "./html.ts";
|
|
36
38
|
import type { Metadata } from "./hooks.ts";
|
|
@@ -640,7 +642,7 @@ export async function renderSSRStream(
|
|
|
640
642
|
// INVARIANT: releaseMiss must fire exactly once — a missed release() hangs
|
|
641
643
|
// coalesced waiters for the process lifetime. Every early return and throw
|
|
642
644
|
// below must stay inside this try; the cache-write path hands the release
|
|
643
|
-
// off to its cacheSet
|
|
645
|
+
// off to its deferred cacheSet by nulling releaseMiss first.
|
|
644
646
|
try {
|
|
645
647
|
// ── Pre-stream phase: resolve metadata before committing to a 200 ──
|
|
646
648
|
// Errors here return a proper error response with correct status code.
|
|
@@ -761,7 +763,7 @@ export async function renderSSRStream(
|
|
|
761
763
|
);
|
|
762
764
|
}
|
|
763
765
|
const html =
|
|
764
|
-
buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments) +
|
|
766
|
+
buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments, route.pattern) +
|
|
765
767
|
buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
|
|
766
768
|
buildHtmlTail(
|
|
767
769
|
"",
|
|
@@ -777,9 +779,7 @@ export async function renderSSRStream(
|
|
|
777
779
|
data.layoutDeps,
|
|
778
780
|
appHtmlSegments,
|
|
779
781
|
);
|
|
780
|
-
return
|
|
781
|
-
headers: { "content-type": "text/html; charset=utf-8", ...data.loaderHeaders },
|
|
782
|
-
});
|
|
782
|
+
return compress(html, "text/html; charset=utf-8", req, 200, data.loaderHeaders);
|
|
783
783
|
}
|
|
784
784
|
|
|
785
785
|
// Render-first: run render() before committing to a 200. Failure → proper error page
|
|
@@ -817,9 +817,19 @@ export async function renderSSRStream(
|
|
|
817
817
|
);
|
|
818
818
|
}
|
|
819
819
|
|
|
820
|
-
//
|
|
820
|
+
// Every chunk is built before the first byte, so the body goes out whole and
|
|
821
|
+
// compressed — a stream here never streamed, it only skipped compression.
|
|
822
|
+
// Truly progressive SSR (ROADMAP) would pipe a stream through
|
|
823
|
+
// CompressionStream instead.
|
|
821
824
|
const chunks: Uint8Array[] = [
|
|
822
|
-
enc.encode(
|
|
825
|
+
enc.encode(
|
|
826
|
+
buildHtmlShellOpen(
|
|
827
|
+
metadata?.lang,
|
|
828
|
+
nonce,
|
|
829
|
+
appHtmlSegments,
|
|
830
|
+
data.csr ? route.pattern : undefined,
|
|
831
|
+
),
|
|
832
|
+
),
|
|
823
833
|
enc.encode(buildMetadataChunk(metadata, headExtras, appHtmlSegments)),
|
|
824
834
|
enc.encode(
|
|
825
835
|
buildHtmlTail(
|
|
@@ -839,22 +849,23 @@ export async function renderSSRStream(
|
|
|
839
849
|
),
|
|
840
850
|
];
|
|
841
851
|
|
|
842
|
-
|
|
852
|
+
const fullBody = concatChunks(chunks);
|
|
853
|
+
|
|
854
|
+
// ── Response cache: write after the response has gone out ──
|
|
843
855
|
// Skip if the handler set cookies — cached response can't reproduce
|
|
844
|
-
// per-request Set-Cookie headers.
|
|
845
|
-
//
|
|
856
|
+
// per-request Set-Cookie headers. deferCacheWrite runs the variant
|
|
857
|
+
// compression after this response is flushed, not ahead of it.
|
|
846
858
|
if (cacheable && cacheKey && (cookies as any).outgoing?.length === 0) {
|
|
847
|
-
const fullBody = concatChunks(chunks);
|
|
848
859
|
// Oversized bodies skip early so they never pay compression; cacheSet
|
|
849
860
|
// re-checks as the authoritative guard.
|
|
850
861
|
if (CACHE_MAX_BODY_BYTES === 0 || fullBody.length <= CACHE_MAX_BODY_BYTES) {
|
|
851
862
|
const tags = collectTags(data.layoutDeps ?? null, data.pageDeps ?? null);
|
|
852
863
|
const keyForWrite = cacheKey;
|
|
853
|
-
// Hand the gate release to the write
|
|
864
|
+
// Hand the gate release to the deferred write: waiters resume only
|
|
854
865
|
// after cacheSet ran, so their re-check hits.
|
|
855
866
|
const rel = releaseMiss;
|
|
856
867
|
releaseMiss = null;
|
|
857
|
-
|
|
868
|
+
deferCacheWrite(() => {
|
|
858
869
|
try {
|
|
859
870
|
const { gzip, brotli } = buildCompressedVariants(fullBody);
|
|
860
871
|
cacheSet(
|
|
@@ -877,35 +888,7 @@ export async function renderSSRStream(
|
|
|
877
888
|
}
|
|
878
889
|
}
|
|
879
890
|
|
|
880
|
-
|
|
881
|
-
let cancelled = false;
|
|
882
|
-
const onAbort = () => {
|
|
883
|
-
cancelled = true;
|
|
884
|
-
};
|
|
885
|
-
req.signal.addEventListener("abort", onAbort, { once: true });
|
|
886
|
-
|
|
887
|
-
const stream = new ReadableStream<Uint8Array>({
|
|
888
|
-
pull(controller) {
|
|
889
|
-
if (cancelled || i >= chunks.length) {
|
|
890
|
-
controller.close();
|
|
891
|
-
req.signal.removeEventListener("abort", onAbort);
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
|
-
controller.enqueue(chunks[i++]);
|
|
895
|
-
if (i >= chunks.length) {
|
|
896
|
-
controller.close();
|
|
897
|
-
req.signal.removeEventListener("abort", onAbort);
|
|
898
|
-
}
|
|
899
|
-
},
|
|
900
|
-
cancel() {
|
|
901
|
-
cancelled = true;
|
|
902
|
-
req.signal.removeEventListener("abort", onAbort);
|
|
903
|
-
},
|
|
904
|
-
});
|
|
905
|
-
|
|
906
|
-
return new Response(stream, {
|
|
907
|
-
headers: { "content-type": "text/html; charset=utf-8", ...data.loaderHeaders },
|
|
908
|
-
});
|
|
891
|
+
return compressBytes(fullBody, "text/html; charset=utf-8", req, 200, data.loaderHeaders);
|
|
909
892
|
} finally {
|
|
910
893
|
releaseMiss?.();
|
|
911
894
|
}
|
|
@@ -1031,6 +1014,7 @@ export async function renderPageWithFormData(
|
|
|
1031
1014
|
bodyEndExtras,
|
|
1032
1015
|
appHtmlSegments,
|
|
1033
1016
|
metadata,
|
|
1017
|
+
route.pattern,
|
|
1034
1018
|
);
|
|
1035
1019
|
return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
|
|
1036
1020
|
}
|
|
@@ -1065,6 +1049,7 @@ export async function renderPageWithFormData(
|
|
|
1065
1049
|
bodyEndExtras,
|
|
1066
1050
|
appHtmlSegments,
|
|
1067
1051
|
metadata,
|
|
1052
|
+
route.pattern,
|
|
1068
1053
|
);
|
|
1069
1054
|
return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
|
|
1070
1055
|
}
|
package/src/core/server.ts
CHANGED
|
@@ -25,7 +25,12 @@ import { dev500WithPlugins } from "./dev-500.ts";
|
|
|
25
25
|
import { OUT_DIR } from "./paths.ts";
|
|
26
26
|
import { stripBase, withBase } from "./basePath.ts";
|
|
27
27
|
import { currentBase } from "./appBase.ts";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
buildPrerenderManifest,
|
|
30
|
+
buildStaticManifest,
|
|
31
|
+
lookupStatic,
|
|
32
|
+
serveStatic,
|
|
33
|
+
} from "./staticManifest.ts";
|
|
29
34
|
import { dedup } from "./dedup.ts";
|
|
30
35
|
import {
|
|
31
36
|
CACHE_ENABLED,
|
|
@@ -35,6 +40,7 @@ import {
|
|
|
35
40
|
cacheSet,
|
|
36
41
|
coalesceMiss,
|
|
37
42
|
computeCacheKey,
|
|
43
|
+
deferCacheWrite,
|
|
38
44
|
serveCached,
|
|
39
45
|
warnUncoveredCookies,
|
|
40
46
|
} from "./cache.ts";
|
|
@@ -410,12 +416,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
410
416
|
// api-before-static ordering intact for every user-facing path.
|
|
411
417
|
if (staticManifest && (path.startsWith("/dist/") || path.startsWith("/__bosia/"))) {
|
|
412
418
|
const hit = lookupStatic(staticManifest, path);
|
|
413
|
-
if (hit)
|
|
414
|
-
return new Response(
|
|
415
|
-
Bun.file(hit.absPath),
|
|
416
|
-
hit.cacheControl ? { headers: { "Cache-Control": hit.cacheControl } } : undefined,
|
|
417
|
-
);
|
|
418
|
-
}
|
|
419
|
+
if (hit) return serveStatic(hit, request);
|
|
419
420
|
}
|
|
420
421
|
|
|
421
422
|
// API routes (+server.ts) — resolve with `.json` alias preference.
|
|
@@ -428,7 +429,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
428
429
|
if (warmingUp) return new Response(null, { status: 404 });
|
|
429
430
|
// INVARIANT: once set, releaseApiMiss must fire exactly once — a missed
|
|
430
431
|
// release() hangs coalesced waiters for the process lifetime. The cache
|
|
431
|
-
// write path hands it off to its
|
|
432
|
+
// write path hands it off to its deferred write by nulling it first.
|
|
432
433
|
let releaseApiMiss: (() => void) | null = null;
|
|
433
434
|
try {
|
|
434
435
|
const mod = await apiMatch.route.module();
|
|
@@ -518,11 +519,11 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
518
519
|
const extraHeaders = captureCacheableHeaders(response.headers);
|
|
519
520
|
const contentType = responseContentType || "application/octet-stream";
|
|
520
521
|
const keyForWrite = apiCacheKey;
|
|
521
|
-
// Hand the gate release to the write
|
|
522
|
+
// Hand the gate release to the deferred write: waiters resume only
|
|
522
523
|
// after the cacheSet attempt, so their re-check hits.
|
|
523
524
|
const rel = releaseApiMiss;
|
|
524
525
|
releaseApiMiss = null;
|
|
525
|
-
|
|
526
|
+
deferCacheWrite(async () => {
|
|
526
527
|
try {
|
|
527
528
|
const buf = new Uint8Array(await cloned.arrayBuffer());
|
|
528
529
|
// Oversized bodies skip early so they never pay compression;
|
|
@@ -588,12 +589,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
588
589
|
// Prod fast path: single Map lookup, no per-request stat calls.
|
|
589
590
|
if (staticManifest) {
|
|
590
591
|
const hit = lookupStatic(staticManifest, path);
|
|
591
|
-
if (hit)
|
|
592
|
-
return new Response(
|
|
593
|
-
Bun.file(hit.absPath),
|
|
594
|
-
hit.cacheControl ? { headers: { "Cache-Control": hit.cacheControl } } : undefined,
|
|
595
|
-
);
|
|
596
|
-
}
|
|
592
|
+
if (hit) return serveStatic(hit, request);
|
|
597
593
|
return new Response("Not Found", { status: 404 });
|
|
598
594
|
}
|
|
599
595
|
// Dev: keep the per-request fallthrough so files dropped into `public/`
|
|
@@ -646,13 +642,11 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
646
642
|
// Keys come from a boot-time walk of `dist/prerendered/`, not from the
|
|
647
643
|
// URL, so no safePath needed — a non-matching path is just a miss.
|
|
648
644
|
const key = path === "/" ? "/" : path.replace(/\/$/, "");
|
|
649
|
-
const
|
|
650
|
-
if (
|
|
651
|
-
return
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
"Cache-Control": "public, max-age=3600",
|
|
655
|
-
},
|
|
645
|
+
const hit = prerenderManifest.get(key);
|
|
646
|
+
if (hit) {
|
|
647
|
+
return serveStatic(hit, request, {
|
|
648
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
649
|
+
"Cache-Control": "public, max-age=3600",
|
|
656
650
|
});
|
|
657
651
|
}
|
|
658
652
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "fs";
|
|
2
2
|
import { basename, join, resolve as resolvePath } from "path";
|
|
3
|
+
import { PRECOMPRESSED, pickEncoding } from "./html.ts";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
/** `br`/`gz`: absolute paths of build-time precompressed siblings (precompress.ts). */
|
|
6
|
+
export type StaticEntry = { absPath: string; cacheControl?: string; br?: string; gz?: string };
|
|
5
7
|
export type StaticManifest = Map<string, StaticEntry>;
|
|
6
8
|
|
|
7
9
|
const HASHED_BASENAME = /\-[a-z0-9]{8,}\.[a-z]+$/;
|
|
@@ -49,9 +51,16 @@ export function buildStaticManifest(outDir: string): StaticManifest {
|
|
|
49
51
|
|
|
50
52
|
const clientRoot = join(outAbs, "client");
|
|
51
53
|
if (existsSync(clientRoot)) {
|
|
52
|
-
for (const
|
|
53
|
-
const cacheControl = HASHED_BASENAME.test(basename(rel))
|
|
54
|
-
|
|
54
|
+
for (const entry of withSiblings(walk(clientRoot))) {
|
|
55
|
+
const cacheControl = HASHED_BASENAME.test(basename(entry.rel))
|
|
56
|
+
? IMMUTABLE_CACHE
|
|
57
|
+
: DEFAULT_CACHE;
|
|
58
|
+
addOnce(manifest, `/dist/client/${entry.rel}`, {
|
|
59
|
+
absPath: entry.abs,
|
|
60
|
+
cacheControl,
|
|
61
|
+
br: entry.br,
|
|
62
|
+
gz: entry.gz,
|
|
63
|
+
});
|
|
55
64
|
}
|
|
56
65
|
}
|
|
57
66
|
|
|
@@ -99,6 +108,54 @@ export function buildStaticManifest(outDir: string): StaticManifest {
|
|
|
99
108
|
return manifest;
|
|
100
109
|
}
|
|
101
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Fold `x.br` / `x.gz` into the entry for `x`. The variants are not URLs of
|
|
113
|
+
* their own — a request for `/chunk.js.br` is a miss, like any file the build
|
|
114
|
+
* never meant to publish. A `.br` with no original is kept as a plain file.
|
|
115
|
+
*/
|
|
116
|
+
function withSiblings(
|
|
117
|
+
all: Iterable<{ abs: string; rel: string }>,
|
|
118
|
+
): Array<{ abs: string; rel: string; br?: string; gz?: string }> {
|
|
119
|
+
const list = [...all];
|
|
120
|
+
const byRel = new Map(list.map((f) => [f.rel, f]));
|
|
121
|
+
const out: Array<{ abs: string; rel: string; br?: string; gz?: string }> = [];
|
|
122
|
+
for (const f of list) {
|
|
123
|
+
const ext = f.rel.endsWith(".br") ? ".br" : f.rel.endsWith(".gz") ? ".gz" : null;
|
|
124
|
+
if (ext && byRel.has(f.rel.slice(0, -ext.length))) continue;
|
|
125
|
+
out.push({
|
|
126
|
+
...f,
|
|
127
|
+
br: byRel.get(`${f.rel}.br`)?.abs,
|
|
128
|
+
gz: byRel.get(`${f.rel}.gz`)?.abs,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Response for a manifest hit, precompressed when the build left a variant the
|
|
136
|
+
* client accepts. Content-Type comes from the original — `Bun.file("x.js.br")`
|
|
137
|
+
* would infer the wrong one. `Vary` only when variants exist: a raw-only file
|
|
138
|
+
* answers every client the same way.
|
|
139
|
+
*/
|
|
140
|
+
export function serveStatic(
|
|
141
|
+
entry: StaticEntry,
|
|
142
|
+
req: Request,
|
|
143
|
+
headers: Record<string, string> = {},
|
|
144
|
+
): Response {
|
|
145
|
+
const out: Record<string, string> = { ...headers };
|
|
146
|
+
if (entry.cacheControl) out["Cache-Control"] = entry.cacheControl;
|
|
147
|
+
if (!entry.br && !entry.gz) return new Response(Bun.file(entry.absPath), { headers: out });
|
|
148
|
+
|
|
149
|
+
out["Vary"] = "Accept-Encoding";
|
|
150
|
+
const enc = pickEncoding(req.headers.get("accept-encoding"));
|
|
151
|
+
const variant = enc === "br" ? (entry.br ?? entry.gz) : enc === "gzip" ? entry.gz : undefined;
|
|
152
|
+
if (!variant) return new Response(Bun.file(entry.absPath), { headers: out });
|
|
153
|
+
|
|
154
|
+
out["Content-Type"] ??= Bun.file(entry.absPath).type;
|
|
155
|
+
out["Content-Encoding"] = variant === entry.br ? "br" : "gzip";
|
|
156
|
+
return new Response(Bun.file(variant), { ...PRECOMPRESSED, headers: out });
|
|
157
|
+
}
|
|
158
|
+
|
|
102
159
|
export function lookupStatic(manifest: StaticManifest, urlPath: string): StaticEntry | null {
|
|
103
160
|
const raw = urlPath.split("?")[0];
|
|
104
161
|
// Manifest keys are raw filenames; URLs arrive percent-encoded.
|
|
@@ -117,10 +174,10 @@ export function lookupStatic(manifest: StaticManifest, urlPath: string): StaticE
|
|
|
117
174
|
* On collision the `…/index.html` variant wins (mirrors the old runtime
|
|
118
175
|
* candidate order). Replaces per-request `Bun.file().exists()` probes.
|
|
119
176
|
*/
|
|
120
|
-
export function buildPrerenderManifest(outDir: string): Map<string,
|
|
121
|
-
const manifest = new Map<string,
|
|
177
|
+
export function buildPrerenderManifest(outDir: string): Map<string, StaticEntry> {
|
|
178
|
+
const manifest = new Map<string, StaticEntry>();
|
|
122
179
|
const root = join(resolvePath(outDir), "prerendered");
|
|
123
|
-
for (const { abs, rel } of walk(root)) {
|
|
180
|
+
for (const { abs, rel, br, gz } of withSiblings(walk(root))) {
|
|
124
181
|
if (!rel.endsWith(".html")) continue;
|
|
125
182
|
let key: string;
|
|
126
183
|
let isIndex = false;
|
|
@@ -133,7 +190,7 @@ export function buildPrerenderManifest(outDir: string): Map<string, string> {
|
|
|
133
190
|
} else {
|
|
134
191
|
key = `/${rel.slice(0, -".html".length)}`;
|
|
135
192
|
}
|
|
136
|
-
if (isIndex || !manifest.has(key)) manifest.set(key, abs);
|
|
193
|
+
if (isIndex || !manifest.has(key)) manifest.set(key, { absPath: abs, br, gz });
|
|
137
194
|
}
|
|
138
195
|
return manifest;
|
|
139
196
|
}
|