From 91a616334b3b7917cad50d7510ddd21d69f02f4b Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:56:16 -0400 Subject: [PATCH 1/3] record the size and content hash of client and prerendered files for adapters --- .changeset/quiet-hashes-arrive.md | 5 + .changeset/sizes-from-compress.md | 7 ++ .../35-migrating-to-sveltekit-3.md | 2 +- packages/adapter-bun/index.js | 117 +++++++++--------- packages/adapter-bun/test/adapter.spec.ts | 58 ++++++--- packages/adapter-node/index.js | 111 +++++++---------- packages/kit/src/core/adapt/builder.js | 44 ++++++- packages/kit/src/core/adapt/builder.spec.js | 26 +++- .../output/client/.vite/manifest.json | 1 + .../fixtures/client-files/output/client/a.txt | 1 + .../client-files/output/client/sub/b.txt | 1 + packages/kit/src/core/postbuild/prerender.js | 43 ++++--- packages/kit/src/exports/public.d.ts | 15 ++- packages/kit/src/types/private.d.ts | 20 ++- packages/kit/types/index.d.ts | 35 +++++- 15 files changed, 318 insertions(+), 168 deletions(-) create mode 100644 .changeset/quiet-hashes-arrive.md create mode 100644 .changeset/sizes-from-compress.md create mode 100644 packages/kit/src/core/adapt/fixtures/client-files/output/client/.vite/manifest.json create mode 100644 packages/kit/src/core/adapt/fixtures/client-files/output/client/a.txt create mode 100644 packages/kit/src/core/adapt/fixtures/client-files/output/client/sub/b.txt diff --git a/.changeset/quiet-hashes-arrive.md b/.changeset/quiet-hashes-arrive.md new file mode 100644 index 000000000000..e9a3a6cf967b --- /dev/null +++ b/.changeset/quiet-hashes-arrive.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: record the size and content hash of every client and prerendered file for adapters, as `builder.clientFiles` and on `builder.prerendered` diff --git a/.changeset/sizes-from-compress.md b/.changeset/sizes-from-compress.md new file mode 100644 index 000000000000..2d8a2e77cefe --- /dev/null +++ b/.changeset/sizes-from-compress.md @@ -0,0 +1,7 @@ +--- +'@sveltejs/kit': major +'@sveltejs/adapter-node': major +'@sveltejs/adapter-bun': major +--- + +breaking: `builder.compress` returns the sizes of the compressed variants instead of a list of files diff --git a/documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md b/documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md index b2575ab27882..ca97fbdde865 100644 --- a/documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md +++ b/documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md @@ -517,7 +517,7 @@ For adapter authors, there are some additional changes: - adapters can augment the Vite config with additional plugins - `builder.config.kit` no longer exists — the configuration now lives at the top level - `builder.createEntries` has been removed — use `builder.writeClient`, `builder.writeServer` and `builder.writePrerendered` directly -- `builder.compress` returns a list of compressed files +- `builder.compress` returns the files it compressed, with the sizes of their `.gz` and `.br` variants - `builder.mkdirp` and `builder.rimraf` are deprecated in favour of `node:fs` methods - `builder.generateManifest` has been removed — use `builder.generateServerInstance` to replace it, and `builder.manifest` to access the manifest diff --git a/packages/adapter-bun/index.js b/packages/adapter-bun/index.js index ae34a56c7230..04f1e01e34fb 100644 --- a/packages/adapter-bun/index.js +++ b/packages/adapter-bun/index.js @@ -31,57 +31,45 @@ function is_dotfile(file) { .some((segment, i) => segment.startsWith('.') && !(i === 0 && segment === '.well-known')); } -// bounds open file handles while every asset hashes concurrently -const MAX_OPEN_FILES = 64; -let open_files = 0; -/** @type {Array<() => void>} */ -const file_waiters = []; - -/** - * Streams the file through the hasher so build memory stays bounded by chunk - * size instead of total asset size. - * @param {string} file - * @returns {Promise} - */ -async function hash_file(file) { - if (open_files === MAX_OPEN_FILES) { - await new Promise((resolve) => { - file_waiters.push(() => resolve(undefined)); - }); - } - open_files++; - try { - const hasher = new Bun.CryptoHasher('blake2b256'); - for await (const chunk of Bun.file(file).stream()) { - hasher.update(chunk); - } - return hasher.digest('hex').slice(0, 16); - } finally { - open_files--; - file_waiters.shift()?.(); - } -} - /** * The build-time validator for conditional requests: Bun only generates ETags for - * in-memory static routes, not file-backed responses, so the adapter ships its own. + * in-memory static routes, not file-backed responses, so the adapter ships kit's content hash. * @param {string} file - * @param {boolean} [precompress] - * @returns {Promise<{ hash: string, mtime: number, br?: boolean, gz?: boolean }>} + * @param {string | undefined} hash + * @param {boolean} [compressed] whether `builder.compress` wrote `.br` and `.gz` variants + * @returns {{ hash: string, mtime: number, br?: boolean, gz?: boolean }} */ -async function asset_meta(file, precompress = false) { - const hash = await hash_file(file); +function asset_meta(file, hash, compressed = false) { + if (hash === undefined) throw new Error(`Could not find a content hash for ${file}`); /** @type {{ hash: string, mtime: number, br?: boolean, gz?: boolean }} */ const meta = { hash, mtime: Bun.file(file).lastModified }; - if (precompress) { - if (fs.existsSync(`${file}.br`)) meta.br = true; - if (fs.existsSync(`${file}.gz`)) meta.gz = true; + if (compressed) { + meta.br = true; + meta.gz = true; } return meta; } +/** + * Content hashes of every client and prerendered file kit produced, keyed by the + * file's path relative to its output directory + * @param {Builder} builder + */ +function content_hashes(builder) { + const { pages, assets, redirects } = builder.prerendered; + return { + client: new Map(builder.clientFiles.map(({ file, hash }) => [file, hash])), + prerendered: new Map( + [...pages.values(), ...assets.values(), ...redirects.values()].map(({ file, hash }) => [ + file, + hash + ]) + ) + }; +} + /** @param {string[]} files */ function validate_file_paths(files) { for (const file of files) { @@ -279,9 +267,9 @@ export default function (opts = {}) { * @param {object} options * @param {Builder} options.builder * @param {string[]} options.server_assets - * @returns {Promise<{imports: string[], entries: string[], server_assets: string[]}>} + * @returns {{imports: string[], entries: string[], server_assets: string[]}} */ -async function get_embed_entries({ builder, server_assets }) { +function get_embed_entries({ builder, server_assets }) { const built_files = `${builder.config.outDir}/output`; const all_cl_files = read_files_recursive(`${built_files}/client`); @@ -300,30 +288,33 @@ async function get_embed_entries({ builder, server_assets }) { return `import asset_${i} from ${JSON.stringify(abs)} with { type: 'file' };`; }); + const hashes = content_hashes(builder); + /** * @param {{ abs: string, rel: string }} file * @param {string} helper + * @param {Map} hashes * @param {string} [url] */ - const entry = async (file, helper, url = file.rel) => - `...${helper}(${JSON.stringify(url)}, asset_${asset_index.get(file)}, ${JSON.stringify(await asset_meta(file.abs))})`; + const entry = (file, helper, hashes, url = file.rel) => + `...${helper}(${JSON.stringify(url)}, asset_${asset_index.get(file)}, ${JSON.stringify(asset_meta(file.abs, hashes.get(file.rel)))})`; const page_files = new Map(pr_pages.map((file) => [file.rel, file])); const page_rels = new Set([...builder.prerendered.pages].map(([_, { file }]) => file)); - const entries = await Promise.all([ - ...cl_files.map((file) => entry(file, 'client_asset')), + const entries = [ + ...cl_files.map((file) => entry(file, 'client_asset', hashes.client)), ...[...builder.prerendered.pages].map(([path, { file }]) => { const page = page_files.get(file); if (page === undefined) throw new Error(`Could not find prerendered page ${file} for route ${path}`); - return entry(page, 'prerendered_page', path); + return entry(page, 'prerendered_page', hashes.prerendered, path); }), ...pr_pages .filter(({ rel }) => !page_rels.has(rel)) - .map((file) => entry(file, 'prerendered_asset')), - ...[...pr_deps, ...pr_data].map((file) => entry(file, 'prerendered_asset')) - ]); + .map((file) => entry(file, 'prerendered_asset', hashes.prerendered)), + ...[...pr_deps, ...pr_data].map((file) => entry(file, 'prerendered_asset', hashes.prerendered)) + ]; const index_by_rel = new Map( assets.map(({ rel }, i) => /** @type {[string, number]} */ ([rel, i])).reverse() @@ -353,29 +344,43 @@ async function get_no_embed_entries({ builder, server_assets, out, precompress } const prerendered_files = builder.writePrerendered(`${out}/prerendered`); validate_file_paths([...client_files, ...prerendered_files]); + const hashes = content_hashes(builder); + + /** @type {Record>} */ + const compressed = { client: new Set(), prerendered: new Set() }; if (precompress) { - await Promise.all([builder.compress(`${out}/client`), builder.compress(`${out}/prerendered`)]); + for (const dir of /** @type {const} */ (['client', 'prerendered'])) { + const files = await builder.compress(`${out}/${dir}`); + compressed[dir] = new Set(files.map(({ file }) => file)); + } } /** * @param {string} helper * @param {string} url - * @param {string} dir + * @param {keyof typeof hashes} dir * @param {string} [filename] */ - const entry = async (helper, url, dir, filename) => - `...${helper}(${JSON.stringify(url)}, ${JSON.stringify(filename)}, ${JSON.stringify(await asset_meta(`${out}/${dir}/${filename ?? url}`, precompress))})`; + const entry = (helper, url, dir, filename) => { + const file = filename ?? url; + const meta = asset_meta( + `${out}/${dir}/${file}`, + hashes[dir].get(file), + compressed[dir].has(file) + ); + return `...${helper}(${JSON.stringify(url)}, ${JSON.stringify(filename)}, ${JSON.stringify(meta)})`; + }; const pages = [...builder.prerendered.pages]; const page_files = new Set(pages.map(([_, { file }]) => file)); - const entries = await Promise.all([ + const entries = [ ...client_files.map((file) => entry('client_asset', file, 'client')), ...pages.map(([path, { file }]) => entry('prerendered_page', path, 'prerendered', file)), ...prerendered_files .filter((file) => !page_files.has(file)) .map((file) => entry('prerendered_asset', file, 'prerendered')) - ]); + ]; return { imports: [], @@ -409,7 +414,7 @@ async function create_routes({ builder, out, embed, precompress }) { entries, server_assets: resolved_server_assets } = embed - ? await get_embed_entries({ builder, server_assets }) + ? get_embed_entries({ builder, server_assets }) : await get_no_embed_entries({ builder, out, server_assets, precompress }); const redirects = [...builder.prerendered.redirects].map(([src, { status, location }]) => { diff --git a/packages/adapter-bun/test/adapter.spec.ts b/packages/adapter-bun/test/adapter.spec.ts index f5fa75f984c5..95b8b7c23d07 100644 --- a/packages/adapter-bun/test/adapter.spec.ts +++ b/packages/adapter-bun/test/adapter.spec.ts @@ -17,8 +17,8 @@ let read_dir: Mock; let exists: Mock; let read_file: Mock; -// the real Bun.build would bundle and the real hashers would read assets off -// disk, so the build APIs stay test doubles even under Bun +// the real Bun.build would bundle and Bun.file would stat assets on disk, +// so the build APIs stay test doubles even under Bun beforeEach(() => { bun_build = spyOn(Bun, 'build').mockImplementation((async (_options: any): Promise => ({ success: true, @@ -30,14 +30,6 @@ beforeEach(() => { stream: () => new Blob([]).stream(), lastModified: 0 })) as never); - spyOn(Bun, 'CryptoHasher').mockImplementation(function () { - return { - update() {}, - digest() { - return 'abc'; - } - }; - } as never); spyOn(Bun, 'hash').mockImplementation(((input: string) => { let hash = 0n; for (const char of input) hash = hash * 31n + BigInt(char.charCodeAt(0)); @@ -357,6 +349,13 @@ describe('generated routes', () => { await adapter({ buildOptions: { compile: true } }).adapt( create_builder({ + client_files: ['data.json', '.well-known/asset.txt', '_app/read.txt'], + prerendered_files: [ + 'page/index.html', + 'favicon.ico', + 'dependency.json', + 'page/__data.json' + ], prerendered_pages: [['/page/', { file: 'page/index.html' }]], server_assets: ['_app/read.txt'] }) @@ -391,7 +390,7 @@ describe('generated routes', () => { }); test('precompresses assets and marks the variants in the generated routes', async () => { - const builder = create_builder({ client_files: ['app.js'] }); + const builder = create_builder({ client_files: ['app.js'], compressed: ['app.js'] }); await adapter({ precompress: true }).adapt(builder); @@ -455,7 +454,9 @@ describe('generated routes', () => { test('excludes dotfiles from embedded assets', async () => { mock_files({ client: ['.secret', 'public.txt'] }); - await adapter({ buildOptions: { compile: true } }).adapt(create_builder()); + await adapter({ buildOptions: { compile: true } }).adapt( + create_builder({ client_files: ['.secret', 'public.txt'] }) + ); const source = bun_build.mock.calls[0][0].files[routes_file]; expect(source).not.toContain('.secret'); @@ -473,7 +474,11 @@ describe('generated routes', () => { mock_files({ client: ['page.html'], pages: ['page.html'] }); await adapter({ buildOptions: { compile: true } }).adapt( - create_builder({ prerendered_pages: [['/page/', { file: 'page.html' }]] }) + create_builder({ + client_files: ['page.html'], + prerendered_files: ['page.html'], + prerendered_pages: [['/page/', { file: 'page.html' }]] + }) ); const source = bun_build.mock.calls[0][0].files[routes_file]; @@ -565,7 +570,8 @@ function create_builder({ server_assets = [], base = '', origin, - instrumentation = false + instrumentation = false, + compressed = [] }: { client_files?: string[]; prerendered_files?: string[]; @@ -576,14 +582,30 @@ function create_builder({ base?: string; origin?: string; instrumentation?: boolean; + compressed?: string[]; } = {}) { + // kit records a size and content hash for every file it writes + const measure = (file: string) => ({ file, size: 0, hash: 'abc' }); + const page_files = new Set(prerendered_pages.map(([, { file }]) => file)); + return { config: { outDir: '.svelte-kit', paths: { base, origin }, appDir: '_app' }, routes, prerendered: { - pages: new Map(prerendered_pages), - redirects: new Map(prerendered_redirects) + pages: new Map(prerendered_pages.map(([path, { file }]) => [path, measure(file)])), + assets: new Map( + prerendered_files + .filter((file) => !page_files.has(file)) + .map((file) => [`/${file}`, { type: '', ...measure(file) }]) + ), + redirects: new Map( + prerendered_redirects.map(([path, redirect]) => [ + path, + { ...redirect, ...measure(`${path.slice(1)}.html`) } + ]) + ) }, + clientFiles: client_files.map(measure), log: { minor: mock((_message: string) => {}), error: mock((_message: string) => {}), @@ -596,7 +618,9 @@ function create_builder({ getServerDirectory: () => '.svelte-kit/output/server', writeClient: mock(() => client_files), writePrerendered: mock(() => prerendered_files), - compress: mock(async (_directory: string) => {}), + compress: mock(async (_directory: string) => + compressed.map((file) => ({ file, gz: 1, br: 1 })) + ), findServerAssets: mock(() => server_assets), generateManifest: mock(() => '{"appDir":"_app"}'), hasServerInstrumentationFile: () => instrumentation, diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 63e63050737a..e7e704cca1ff 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -1,6 +1,4 @@ -import { createHash } from 'node:crypto'; import fs from 'node:fs'; -import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { rolldown } from 'rolldown'; @@ -30,22 +28,18 @@ export default function (opts = {}) { const prerendered_dir = `${out}/prerendered${base}`; builder.log.minor('Copying assets'); - const client_files = builder.writeClient(client_dir); - const prerendered_files = builder.writePrerendered(prerendered_dir); + builder.writeClient(client_dir); + builder.writePrerendered(prerendered_dir); - builder.log.minor(precompress ? 'Compressing and hashing assets' : 'Hashing assets'); + if (precompress) builder.log.minor('Compressing assets'); const [client_compressed, prerendered_compressed] = precompress ? await Promise.all([builder.compress(client_dir), builder.compress(prerendered_dir)]) : [[], []]; - const assets = create_asset_table( - base, - measure_files(client_dir, client_files, client_compressed) - ); + const assets = create_asset_table(base, builder.clientFiles, client_compressed); const prerendered_assets = create_prerendered_table( - base, - measure_files(prerendered_dir, prerendered_files, prerendered_compressed), - builder.prerendered.paths + builder.prerendered, + prerendered_compressed ); builder.log.minor('Building server'); @@ -193,57 +187,47 @@ function is_hidden(file) { } /** - * Size and content hash of every servable file, plus the sizes of the - * compressed variants where `builder.compress` wrote them. - * Files are read one at a time, so large outputs neither exhaust file descriptors nor pile up in memory - * @param {string} root - * @param {string[]} files - * @param {string[]} compressed - * @returns {AssetEntry[]} + * @param {{ file: string, size: number, hash: string }} measured + * @param {{ gz: number, br: number } | undefined} variants sizes of the `.gz` and `.br` files `builder.compress` wrote, if any + * @returns {AssetEntry} */ -function measure_files(root, files, compressed) { - const variants = new Set(compressed); - - /** @type {AssetEntry[]} */ - const entries = []; - - for (const file of files) { - if (is_hidden(file)) continue; - - const abs = join(root, file); - const contents = fs.readFileSync(abs); +function to_entry({ file, size, hash }, variants) { + /** @type {AssetEntry} */ + const entry = { file, size, etag: hash }; - /** @type {AssetEntry} */ - const entry = { - file, - size: contents.length, - etag: createHash('sha256').update(contents).digest('base64url') - }; - - // `builder.compress` writes a `.gz` and a `.br` variant of every file it returns - if (variants.has(file)) { - entry.gz = fs.statSync(`${abs}.gz`).size; - entry.br = fs.statSync(`${abs}.br`).size; - } - - entries.push(entry); + if (variants) { + entry.gz = variants.gz; + entry.br = variants.br; } - return entries; + return entry; } /** - * Keys the measured files by URL: the exact pathname, plus the `/foo` and + * @param {Array<{ file: string, gz: number, br: number }>} compressed + */ +function by_file(compressed) { + return new Map(compressed.map((entry) => [entry.file, entry])); +} + +/** + * Keys the client files by URL: the exact pathname, plus the `/foo` and * `/foo/` forms of `foo.html`/`foo/index.html` files * @param {string} base - * @param {AssetEntry[]} measured + * @param {Array<{ file: string, size: number, hash: string }>} files + * @param {Array<{ file: string, gz: number, br: number }>} compressed * @returns {AssetTable} */ -function create_asset_table(base, measured) { - const entries = measured.map((entry) => /** @type {[string, AssetEntry]} */ ([ - `${base}/${entry.file}`, - entry - ])); +function create_asset_table(base, files, compressed) { + const variants = by_file(compressed); + + /** @type {Array<[string, AssetEntry]>} */ + const entries = []; + + for (const measured of files) { + if (is_hidden(measured.file)) continue; + entries.push([`${base}/${measured.file}`, to_entry(measured, variants.get(measured.file))]); + } entries.sort(([a], [b]) => (a < b ? -1 : 1)); @@ -279,26 +263,23 @@ function create_asset_table(base, measured) { } /** - * Keys the measured files by the exact paths kit prerendered, so a lookup - * hit is precisely a prerendered page, asset or redirect and every other + * Keys the prerendered pages, assets and redirect stubs by the exact paths kit + * prerendered, so a lookup hit is precisely one of those and every other * pathname (including the non-canonical trailing-slash form) misses - * @param {string} base - * @param {AssetEntry[]} measured - * @param {string[]} paths + * @param {import('@sveltejs/kit').Builder['prerendered']} prerendered + * @param {Array<{ file: string, gz: number, br: number }>} compressed * @returns {AssetTable} */ -function create_prerendered_table(base, measured, paths) { - const by_file = new Map(measured.map((entry) => [entry.file, entry])); +function create_prerendered_table(prerendered, compressed) { + const variants = by_file(compressed); /** @type {Array<[string, AssetEntry]>} */ const entries = []; - for (const path of paths) { - // invert `output_filename` in kit's prerenderer - const file = path.slice(base.length + 1) || 'index.html'; - const entry = - by_file.get(file) ?? by_file.get(file + (file.endsWith('/') ? 'index.html' : '.html')); - if (entry) entries.push([path, entry]); + for (const map of [prerendered.pages, prerendered.assets, prerendered.redirects]) { + for (const [path, measured] of map) { + entries.push([path, to_entry(measured, variants.get(measured.file))]); + } } entries.sort(([a], [b]) => (a < b ? -1 : 1)); diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index 296ea71895ad..a14b5ad778a1 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -5,6 +5,7 @@ /** @import { RouteData, ValidatedConfig, BuildData, ServerMetadata, ServerMetadataRoute, Prerendered, PrerenderMap, Logger, RemoteChunk } from 'types' */ import { loadEnv } from 'vite'; import * as devalue from 'devalue'; +import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { promisify } from 'node:util'; @@ -106,6 +107,9 @@ export function create_builder({ app_manifest.assets.push({ path: build_data.service_worker }); } + /** @type {Array<{ file: string, size: number, hash: string }> | undefined} */ + let client_files; + return { log, rimraf: (dir) => fs.rmSync(dir, { force: true, recursive: true }), @@ -116,6 +120,9 @@ export function create_builder({ prerendered, routes, manifest: app_manifest, + get clientFiles() { + return (client_files ??= measure_files(`${config.outDir}/output/client`)); + }, get mimeTypes() { // TODO - make the `generate_manifest` function return data instead of a string, and retrieve mime types from there const mime_types = get_mime_lookup(build_data.manifest_data); @@ -149,16 +156,23 @@ export function create_builder({ const files = [...walk(directory)].filter((file) => extensions.includes(path.extname(file))); + /** @type {Array<{ file: string, gz: number, br: number }>} */ + const compressed = []; + // zlib work is serialised on the threadpool and each brotli encoder is allocated up front, // so a handful of files in flight is as fast as all of them and keeps memory flat let i = 0; await Promise.all( Array.from({ length: 16 }, async () => { - while (i < files.length) await compress_file(path.resolve(directory, files[i++])); + while (i < files.length) { + const index = i++; + const file = files[index]; + compressed[index] = { file, ...(await compress_file(path.resolve(directory, file))) }; + } }) ); - return files; + return compressed; }, findServerAssets(route_data) { @@ -367,7 +381,29 @@ export function create_builder({ } /** - * Writes gzip and brotli variants next to `file` + * Size and content hash of every file under `directory`, skipping Vite's own metadata + * @param {string} directory + */ +function measure_files(directory) { + /** @type {Array<{ file: string, size: number, hash: string }>} */ + const files = []; + + for (const file of walk(directory)) { + if (file.startsWith('.vite/') || file.includes('/.vite/')) continue; + + const contents = fs.readFileSync(path.join(directory, file)); + files.push({ + file, + size: contents.length, + hash: createHash('sha256').update(contents).digest('base64url') + }); + } + + return files; +} + +/** + * Writes gzip and brotli variants next to `file` and returns their sizes * @param {string} file */ async function compress_file(file) { @@ -388,6 +424,8 @@ async function compress_file(file) { fs.promises.writeFile(`${file}.gz`, gz), fs.promises.writeFile(`${file}.br`, br) ]); + + return { gz: gz.length, br: br.length }; } /** diff --git a/packages/kit/src/core/adapt/builder.spec.js b/packages/kit/src/core/adapt/builder.spec.js index 9b0f97144bae..ed1169196615 100644 --- a/packages/kit/src/core/adapt/builder.spec.js +++ b/packages/kit/src/core/adapt/builder.spec.js @@ -95,7 +95,31 @@ test('compress files', async () => { assert.ok(existsSync(target + '.br')); assert.ok(existsSync(target + '.gz')); } - assert.deepEqual(compressed.sort(), ['foo.css', 'foo.md', 'foo.mdx']); + assert.deepEqual( + compressed.map(({ file, gz, br }) => [file, gz > 0, br > 0]), + [ + ['foo.css', true, true], + ['foo.md', true, true], + ['foo.mdx', true, true] + ] + ); +}); + +test('clientFiles measures the client output once', () => { + const builder = create_builder({ + // @ts-expect-error - we don't need the whole config for this test + config: { outDir: fileURLToPath(new URL('./fixtures/client-files', import.meta.url)) }, + // @ts-expect-error - we don't need the whole config for this test + build_data: {}, + route_data: [] + }); + + const files = builder.clientFiles; + assert.deepEqual(files, [ + { file: 'a.txt', size: 6, hash: 'WJG1tSLV3whtD_CxEPvZ0hu0_HFjrzTQgoai6Eb2vgM' }, + { file: 'sub/b.txt', size: 2, hash: 'AmOCmYm2_ZVPcrqvL8ZLwuLwHWktTecphuqAj26ZgT8' } + ]); + assert.equal(builder.clientFiles, files); }); test('compress returns an empty array for a directory that does not exist', async () => { diff --git a/packages/kit/src/core/adapt/fixtures/client-files/output/client/.vite/manifest.json b/packages/kit/src/core/adapt/fixtures/client-files/output/client/.vite/manifest.json new file mode 100644 index 000000000000..0967ef424bce --- /dev/null +++ b/packages/kit/src/core/adapt/fixtures/client-files/output/client/.vite/manifest.json @@ -0,0 +1 @@ +{} diff --git a/packages/kit/src/core/adapt/fixtures/client-files/output/client/a.txt b/packages/kit/src/core/adapt/fixtures/client-files/output/client/a.txt new file mode 100644 index 000000000000..ce013625030b --- /dev/null +++ b/packages/kit/src/core/adapt/fixtures/client-files/output/client/a.txt @@ -0,0 +1 @@ +hello diff --git a/packages/kit/src/core/adapt/fixtures/client-files/output/client/sub/b.txt b/packages/kit/src/core/adapt/fixtures/client-files/output/client/sub/b.txt new file mode 100644 index 000000000000..61780798228d --- /dev/null +++ b/packages/kit/src/core/adapt/fixtures/client-files/output/client/sub/b.txt @@ -0,0 +1 @@ +b diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index 2d07b4df5e17..7aaa6c3e147a 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -1,4 +1,5 @@ import process from 'node:process'; +import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -18,6 +19,15 @@ import generate_fallback from './fallback.js'; import { stringify_remote_arg } from '../../runtime/shared.js'; import { matches_content_type } from '../../utils/http.js'; +/** + * Size and content hash of a file that has just been written, so adapters can serve it without reading it back + * @param {string | Uint8Array} contents + */ +function measure(contents) { + const bytes = typeof contents === 'string' ? Buffer.from(contents) : contents; + return { size: bytes.byteLength, hash: createHash('sha256').update(bytes).digest('base64url') }; +} + export default forked(import.meta.url, prerender); // https://html.spec.whatwg.org/multipage/browsing-the-web.html#scrolling-to-a-fragment @@ -170,7 +180,7 @@ async function prerender({ mkdirSync(dirname(dest), { recursive: true }); writeFileSync(dest, fallback); - prerendered.pages.set('/', { file }); + prerendered.pages.set('/', { file, ...measure(fallback) }); return { prerendered, prerender_map }; } @@ -561,22 +571,23 @@ async function prerender({ if (!headers['x-sveltekit-normalize']) { mkdirSync(dirname(dest), { recursive: true }); - writeFileSync( - dest, - `` - ); + const stub = ``; + + writeFileSync(dest, stub); written.add(file); if (!prerendered.redirects.has(decoded)) { prerendered.redirects.set(decoded, { status: response.status, - location: resolved + location: resolved, + file, + ...measure(stub) }); prerendered.paths.push(decoded); @@ -610,14 +621,12 @@ async function prerender({ writeFileSync(dest, body); written.add(file); + const measured = measure(body); + if (is_html) { - prerendered.pages.set(decoded, { - file - }); + prerendered.pages.set(decoded, { file, ...measured }); } else { - prerendered.assets.set(decoded, { - type - }); + prerendered.assets.set(decoded, { type, file, ...measured }); } prerendered.paths.push(decoded); diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 1f1e441f109a..ba323bea3f40 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -177,6 +177,17 @@ export interface Builder { * @since 3.0.0 */ mimeTypes: Record; + /** + * The size and a content hash of every file in the client output, i.e. the Vite build and the contents of the `static` directory. + * `file` is relative to the client directory, matching the paths returned by `writeClient`. Read from disk once, on first access. + * @since 3.0.0 + */ + clientFiles: Array<{ + file: string; + size: number; + /** suitable for use as an ETag */ + hash: string; + }>; /** * Create separate functions that map to one or more routes of your app. @@ -339,9 +350,9 @@ export interface Builder { /** * Compress files in `directory` with gzip and brotli, where appropriate. Generates `.gz` and `.br` files alongside the originals. * @param {string} directory The directory containing the files to be compressed - * @returns an array of the files in `directory` that were compressed + * @returns the files in `directory` that were compressed, with the sizes of their `.gz` and `.br` variants */ - compress: (directory: string) => Promise; + compress: (directory: string) => Promise>; } export interface Cookies { diff --git a/packages/kit/src/types/private.d.ts b/packages/kit/src/types/private.d.ts index 3de811a390ce..09e2617a715e 100644 --- a/packages/kit/src/types/private.d.ts +++ b/packages/kit/src/types/private.d.ts @@ -184,26 +184,42 @@ export interface Prerendered { { /** The location of the .html file relative to the output directory */ file: string; + /** The size of the file in bytes */ + size: number; + /** A hash of the file's contents, suitable for use as an ETag */ + hash: string; } >; /** - * A map of `path` to `{ type }` objects. + * A map of `path` to `{ type, file, size, hash }` objects. */ assets: Map< string, { /** The MIME type of the asset */ type: string; + /** The location of the file relative to the output directory */ + file: string; + /** The size of the file in bytes */ + size: number; + /** A hash of the file's contents, suitable for use as an ETag */ + hash: string; } >; /** - * A map of redirects encountered during prerendering. + * A map of redirects encountered during prerendering. Each one is also written as an HTML file that redirects on load. */ redirects: Map< string, { status: number; location: string; + /** The location of the .html file relative to the output directory */ + file: string; + /** The size of the file in bytes */ + size: number; + /** A hash of the file's contents, suitable for use as an ETag */ + hash: string; } >; /** An array of prerendered paths (without trailing slashes, regardless of the trailingSlash config) */ diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index ad8502f932c4..d14098177a54 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -164,6 +164,17 @@ declare module '@sveltejs/kit' { * @since 3.0.0 */ mimeTypes: Record; + /** + * The size and a content hash of every file in the client output, i.e. the Vite build and the contents of the `static` directory. + * `file` is relative to the client directory, matching the paths returned by `writeClient`. Read from disk once, on first access. + * @since 3.0.0 + */ + clientFiles: Array<{ + file: string; + size: number; + /** suitable for use as an ETag */ + hash: string; + }>; /** * Create separate functions that map to one or more routes of your app. @@ -323,9 +334,9 @@ declare module '@sveltejs/kit' { /** * Compress files in `directory` with gzip and brotli, where appropriate. Generates `.gz` and `.br` files alongside the originals. * @param directory The directory containing the files to be compressed - * @returns an array of the files in `directory` that were compressed + * @returns the files in `directory` that were compressed, with the sizes of their `.gz` and `.br` variants */ - compress: (directory: string) => Promise; + compress: (directory: string) => Promise>; } export interface Cookies { @@ -923,26 +934,42 @@ declare module '@sveltejs/kit' { { /** The location of the .html file relative to the output directory */ file: string; + /** The size of the file in bytes */ + size: number; + /** A hash of the file's contents, suitable for use as an ETag */ + hash: string; } >; /** - * A map of `path` to `{ type }` objects. + * A map of `path` to `{ type, file, size, hash }` objects. */ assets: Map< string, { /** The MIME type of the asset */ type: string; + /** The location of the file relative to the output directory */ + file: string; + /** The size of the file in bytes */ + size: number; + /** A hash of the file's contents, suitable for use as an ETag */ + hash: string; } >; /** - * A map of redirects encountered during prerendering. + * A map of redirects encountered during prerendering. Each one is also written as an HTML file that redirects on load. */ redirects: Map< string, { status: number; location: string; + /** The location of the .html file relative to the output directory */ + file: string; + /** The size of the file in bytes */ + size: number; + /** A hash of the file's contents, suitable for use as an ETag */ + hash: string; } >; /** An array of prerendered paths (without trailing slashes, regardless of the trailingSlash config) */ From d2836833accb70acea5c3bc9b5d420c0861b83fb Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:29:48 -0400 Subject: [PATCH 2/3] measure prerendered files lazily too, so adapters that never ask pay nothing --- .changeset/quiet-hashes-arrive.md | 2 +- packages/adapter-bun/index.js | 13 +++------- packages/adapter-bun/test/adapter.spec.ts | 20 +++++--------- packages/adapter-node/index.js | 17 +++++++----- packages/kit/src/core/adapt/builder.js | 9 +++++++ packages/kit/src/core/adapt/builder.spec.js | 26 +++++++++++++++++-- .../output/prerendered/dependencies/data.json | 1 + .../output/prerendered/pages/page.html | 1 + packages/kit/src/core/postbuild/prerender.js | 21 +++------------ packages/kit/src/exports/public.d.ts | 11 ++++++++ packages/kit/src/types/private.d.ts | 14 +--------- packages/kit/types/index.d.ts | 25 +++++++++--------- 12 files changed, 84 insertions(+), 76 deletions(-) create mode 100644 packages/kit/src/core/adapt/fixtures/client-files/output/prerendered/dependencies/data.json create mode 100644 packages/kit/src/core/adapt/fixtures/client-files/output/prerendered/pages/page.html diff --git a/.changeset/quiet-hashes-arrive.md b/.changeset/quiet-hashes-arrive.md index e9a3a6cf967b..fce20af9bd2e 100644 --- a/.changeset/quiet-hashes-arrive.md +++ b/.changeset/quiet-hashes-arrive.md @@ -2,4 +2,4 @@ '@sveltejs/kit': minor --- -feat: record the size and content hash of every client and prerendered file for adapters, as `builder.clientFiles` and on `builder.prerendered` +feat: expose the size and content hash of every client and prerendered file to adapters as `builder.clientFiles` and `builder.prerenderedFiles` diff --git a/packages/adapter-bun/index.js b/packages/adapter-bun/index.js index 04f1e01e34fb..47b448b8ab79 100644 --- a/packages/adapter-bun/index.js +++ b/packages/adapter-bun/index.js @@ -58,16 +58,9 @@ function asset_meta(file, hash, compressed = false) { * @param {Builder} builder */ function content_hashes(builder) { - const { pages, assets, redirects } = builder.prerendered; - return { - client: new Map(builder.clientFiles.map(({ file, hash }) => [file, hash])), - prerendered: new Map( - [...pages.values(), ...assets.values(), ...redirects.values()].map(({ file, hash }) => [ - file, - hash - ]) - ) - }; + /** @param {Array<{ file: string, hash: string }>} files */ + const index = (files) => new Map(files.map(({ file, hash }) => [file, hash])); + return { client: index(builder.clientFiles), prerendered: index(builder.prerenderedFiles) }; } /** @param {string[]} files */ diff --git a/packages/adapter-bun/test/adapter.spec.ts b/packages/adapter-bun/test/adapter.spec.ts index 95b8b7c23d07..398b3bd91790 100644 --- a/packages/adapter-bun/test/adapter.spec.ts +++ b/packages/adapter-bun/test/adapter.spec.ts @@ -584,28 +584,20 @@ function create_builder({ instrumentation?: boolean; compressed?: string[]; } = {}) { - // kit records a size and content hash for every file it writes + // kit measures every file in its output on first access const measure = (file: string) => ({ file, size: 0, hash: 'abc' }); - const page_files = new Set(prerendered_pages.map(([, { file }]) => file)); return { config: { outDir: '.svelte-kit', paths: { base, origin }, appDir: '_app' }, routes, prerendered: { - pages: new Map(prerendered_pages.map(([path, { file }]) => [path, measure(file)])), - assets: new Map( - prerendered_files - .filter((file) => !page_files.has(file)) - .map((file) => [`/${file}`, { type: '', ...measure(file) }]) - ), - redirects: new Map( - prerendered_redirects.map(([path, redirect]) => [ - path, - { ...redirect, ...measure(`${path.slice(1)}.html`) } - ]) - ) + pages: new Map(prerendered_pages), + redirects: new Map(prerendered_redirects) }, clientFiles: client_files.map(measure), + prerenderedFiles: [...prerendered_files, ...prerendered_pages.map(([, { file }]) => file)].map( + measure + ), log: { minor: mock((_message: string) => {}), error: mock((_message: string) => {}), diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index e7e704cca1ff..664d4ad85fd4 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -39,6 +39,7 @@ export default function (opts = {}) { const assets = create_asset_table(base, builder.clientFiles, client_compressed); const prerendered_assets = create_prerendered_table( builder.prerendered, + builder.prerenderedFiles, prerendered_compressed ); @@ -204,10 +205,11 @@ function to_entry({ file, size, hash }, variants) { } /** - * @param {Array<{ file: string, gz: number, br: number }>} compressed + * @template {{ file: string }} T + * @param {T[]} entries */ -function by_file(compressed) { - return new Map(compressed.map((entry) => [entry.file, entry])); +function by_file(entries) { + return new Map(entries.map((entry) => [entry.file, entry])); } /** @@ -267,18 +269,21 @@ function create_asset_table(base, files, compressed) { * prerendered, so a lookup hit is precisely one of those and every other * pathname (including the non-canonical trailing-slash form) misses * @param {import('@sveltejs/kit').Builder['prerendered']} prerendered + * @param {import('@sveltejs/kit').Builder['prerenderedFiles']} files * @param {Array<{ file: string, gz: number, br: number }>} compressed * @returns {AssetTable} */ -function create_prerendered_table(prerendered, compressed) { +function create_prerendered_table(prerendered, files, compressed) { + const measured = by_file(files); const variants = by_file(compressed); /** @type {Array<[string, AssetEntry]>} */ const entries = []; for (const map of [prerendered.pages, prerendered.assets, prerendered.redirects]) { - for (const [path, measured] of map) { - entries.push([path, to_entry(measured, variants.get(measured.file))]); + for (const [path, { file }] of map) { + const entry = measured.get(file); + if (entry) entries.push([path, to_entry(entry, variants.get(file))]); } } diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index a14b5ad778a1..18f17cce2082 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -109,6 +109,8 @@ export function create_builder({ /** @type {Array<{ file: string, size: number, hash: string }> | undefined} */ let client_files; + /** @type {Array<{ file: string, size: number, hash: string }> | undefined} */ + let prerendered_files; return { log, @@ -123,6 +125,11 @@ export function create_builder({ get clientFiles() { return (client_files ??= measure_files(`${config.outDir}/output/client`)); }, + get prerenderedFiles() { + return (prerendered_files ??= ['pages', 'dependencies', 'data'].flatMap((category) => + measure_files(`${config.outDir}/output/prerendered/${category}`) + )); + }, get mimeTypes() { // TODO - make the `generate_manifest` function return data instead of a string, and retrieve mime types from there const mime_types = get_mime_lookup(build_data.manifest_data); @@ -388,6 +395,8 @@ function measure_files(directory) { /** @type {Array<{ file: string, size: number, hash: string }>} */ const files = []; + if (!fs.existsSync(directory)) return files; + for (const file of walk(directory)) { if (file.startsWith('.vite/') || file.includes('/.vite/')) continue; diff --git a/packages/kit/src/core/adapt/builder.spec.js b/packages/kit/src/core/adapt/builder.spec.js index ed1169196615..16ae2c2b9650 100644 --- a/packages/kit/src/core/adapt/builder.spec.js +++ b/packages/kit/src/core/adapt/builder.spec.js @@ -1,7 +1,14 @@ -import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import fs, { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { assert, expect, test } from 'vitest'; +import { assert, expect, test, vi } from 'vitest'; import { create_builder } from './builder.js'; import { walk } from '../../utils/filesystem.js'; @@ -114,12 +121,27 @@ test('clientFiles measures the client output once', () => { route_data: [] }); + const read = vi.spyOn(fs, 'readFileSync'); + + assert.equal(read.mock.calls.length, 0); + const files = builder.clientFiles; assert.deepEqual(files, [ { file: 'a.txt', size: 6, hash: 'WJG1tSLV3whtD_CxEPvZ0hu0_HFjrzTQgoai6Eb2vgM' }, { file: 'sub/b.txt', size: 2, hash: 'AmOCmYm2_ZVPcrqvL8ZLwuLwHWktTecphuqAj26ZgT8' } ]); + assert.equal(read.mock.calls.length, 2); + assert.equal(builder.clientFiles, files); + assert.equal(read.mock.calls.length, 2); + + assert.deepEqual(builder.prerenderedFiles, [ + { file: 'page.html', size: 14, hash: 'sGk9yS924IvxSFs92bUUouMd_W85QiprYO23ImcdyY8' }, + { file: 'data.json', size: 3, hash: 'yj0WO6sFU4GCciYUBWjzvvfqrBh869doeOC2Pp5EI1Y' } + ]); + assert.equal(read.mock.calls.length, 4); + + read.mockRestore(); }); test('compress returns an empty array for a directory that does not exist', async () => { diff --git a/packages/kit/src/core/adapt/fixtures/client-files/output/prerendered/dependencies/data.json b/packages/kit/src/core/adapt/fixtures/client-files/output/prerendered/dependencies/data.json new file mode 100644 index 000000000000..0967ef424bce --- /dev/null +++ b/packages/kit/src/core/adapt/fixtures/client-files/output/prerendered/dependencies/data.json @@ -0,0 +1 @@ +{} diff --git a/packages/kit/src/core/adapt/fixtures/client-files/output/prerendered/pages/page.html b/packages/kit/src/core/adapt/fixtures/client-files/output/prerendered/pages/page.html new file mode 100644 index 000000000000..18ecdcb795c3 --- /dev/null +++ b/packages/kit/src/core/adapt/fixtures/client-files/output/prerendered/pages/page.html @@ -0,0 +1 @@ + diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index 7aaa6c3e147a..529177824676 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -1,5 +1,4 @@ import process from 'node:process'; -import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -19,15 +18,6 @@ import generate_fallback from './fallback.js'; import { stringify_remote_arg } from '../../runtime/shared.js'; import { matches_content_type } from '../../utils/http.js'; -/** - * Size and content hash of a file that has just been written, so adapters can serve it without reading it back - * @param {string | Uint8Array} contents - */ -function measure(contents) { - const bytes = typeof contents === 'string' ? Buffer.from(contents) : contents; - return { size: bytes.byteLength, hash: createHash('sha256').update(bytes).digest('base64url') }; -} - export default forked(import.meta.url, prerender); // https://html.spec.whatwg.org/multipage/browsing-the-web.html#scrolling-to-a-fragment @@ -180,7 +170,7 @@ async function prerender({ mkdirSync(dirname(dest), { recursive: true }); writeFileSync(dest, fallback); - prerendered.pages.set('/', { file, ...measure(fallback) }); + prerendered.pages.set('/', { file }); return { prerendered, prerender_map }; } @@ -586,8 +576,7 @@ async function prerender({ prerendered.redirects.set(decoded, { status: response.status, location: resolved, - file, - ...measure(stub) + file }); prerendered.paths.push(decoded); @@ -621,12 +610,10 @@ async function prerender({ writeFileSync(dest, body); written.add(file); - const measured = measure(body); - if (is_html) { - prerendered.pages.set(decoded, { file, ...measured }); + prerendered.pages.set(decoded, { file }); } else { - prerendered.assets.set(decoded, { type, file, ...measured }); + prerendered.assets.set(decoded, { type, file }); } prerendered.paths.push(decoded); diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index ba323bea3f40..04e35a758c46 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -188,6 +188,17 @@ export interface Builder { /** suitable for use as an ETag */ hash: string; }>; + /** + * The size and a content hash of every prerendered page, asset and redirect. + * `file` is relative to the prerendered directory, matching the paths returned by `writePrerendered`. Read from disk once, on first access. + * @since 3.0.0 + */ + prerenderedFiles: Array<{ + file: string; + size: number; + /** suitable for use as an ETag */ + hash: string; + }>; /** * Create separate functions that map to one or more routes of your app. diff --git a/packages/kit/src/types/private.d.ts b/packages/kit/src/types/private.d.ts index 09e2617a715e..fadfeb65622d 100644 --- a/packages/kit/src/types/private.d.ts +++ b/packages/kit/src/types/private.d.ts @@ -184,14 +184,10 @@ export interface Prerendered { { /** The location of the .html file relative to the output directory */ file: string; - /** The size of the file in bytes */ - size: number; - /** A hash of the file's contents, suitable for use as an ETag */ - hash: string; } >; /** - * A map of `path` to `{ type, file, size, hash }` objects. + * A map of `path` to `{ type, file }` objects. */ assets: Map< string, @@ -200,10 +196,6 @@ export interface Prerendered { type: string; /** The location of the file relative to the output directory */ file: string; - /** The size of the file in bytes */ - size: number; - /** A hash of the file's contents, suitable for use as an ETag */ - hash: string; } >; /** @@ -216,10 +208,6 @@ export interface Prerendered { location: string; /** The location of the .html file relative to the output directory */ file: string; - /** The size of the file in bytes */ - size: number; - /** A hash of the file's contents, suitable for use as an ETag */ - hash: string; } >; /** An array of prerendered paths (without trailing slashes, regardless of the trailingSlash config) */ diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index d14098177a54..74aff9c197fc 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -175,6 +175,17 @@ declare module '@sveltejs/kit' { /** suitable for use as an ETag */ hash: string; }>; + /** + * The size and a content hash of every prerendered page, asset and redirect. + * `file` is relative to the prerendered directory, matching the paths returned by `writePrerendered`. Read from disk once, on first access. + * @since 3.0.0 + */ + prerenderedFiles: Array<{ + file: string; + size: number; + /** suitable for use as an ETag */ + hash: string; + }>; /** * Create separate functions that map to one or more routes of your app. @@ -934,14 +945,10 @@ declare module '@sveltejs/kit' { { /** The location of the .html file relative to the output directory */ file: string; - /** The size of the file in bytes */ - size: number; - /** A hash of the file's contents, suitable for use as an ETag */ - hash: string; } >; /** - * A map of `path` to `{ type, file, size, hash }` objects. + * A map of `path` to `{ type, file }` objects. */ assets: Map< string, @@ -950,10 +957,6 @@ declare module '@sveltejs/kit' { type: string; /** The location of the file relative to the output directory */ file: string; - /** The size of the file in bytes */ - size: number; - /** A hash of the file's contents, suitable for use as an ETag */ - hash: string; } >; /** @@ -966,10 +969,6 @@ declare module '@sveltejs/kit' { location: string; /** The location of the .html file relative to the output directory */ file: string; - /** The size of the file in bytes */ - size: number; - /** A hash of the file's contents, suitable for use as an ETag */ - hash: string; } >; /** An array of prerendered paths (without trailing slashes, regardless of the trailingSlash config) */ From 5e6167ebc0ecef5aa484876d7dff80897096b7ca Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:13:49 -0400 Subject: [PATCH 3/3] name the prerendered directories once, type the measured file once --- packages/kit/src/core/adapt/builder.js | 36 +++++++++++++------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index 18f17cce2082..89d29100dd4b 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -22,8 +22,11 @@ import { handle_issues, validate } from '../../exports/internal/env.js'; import { get_mime_lookup } from '../utils.js'; import { lookup as mime_lookup } from '../../utils/mime.js'; +/** @typedef {{ file: string, size: number, hash: string }} MeasuredFile */ + const gzip = promisify(zlib.gzip); const brotli = promisify(zlib.brotliCompress); +const prerendered_dirs = ['pages', 'dependencies', 'data']; const extensions = [ '.html', '.js', @@ -107,9 +110,9 @@ export function create_builder({ app_manifest.assets.push({ path: build_data.service_worker }); } - /** @type {Array<{ file: string, size: number, hash: string }> | undefined} */ + /** @type {MeasuredFile[] | undefined} */ let client_files; - /** @type {Array<{ file: string, size: number, hash: string }> | undefined} */ + /** @type {MeasuredFile[] | undefined} */ let prerendered_files; return { @@ -126,8 +129,8 @@ export function create_builder({ return (client_files ??= measure_files(`${config.outDir}/output/client`)); }, get prerenderedFiles() { - return (prerendered_files ??= ['pages', 'dependencies', 'data'].flatMap((category) => - measure_files(`${config.outDir}/output/prerendered/${category}`) + return (prerendered_files ??= prerendered_dirs.flatMap((dir) => + measure_files(`${config.outDir}/output/prerendered/${dir}`) )); }, get mimeTypes() { @@ -173,8 +176,7 @@ export function create_builder({ Array.from({ length: 16 }, async () => { while (i < files.length) { const index = i++; - const file = files[index]; - compressed[index] = { file, ...(await compress_file(path.resolve(directory, file))) }; + compressed[index] = await compress_file(directory, files[index]); } }) ); @@ -292,11 +294,7 @@ export function create_builder({ writePrerendered(dest) { const source = `${config.outDir}/output/prerendered`; - return [ - ...copy(`${source}/pages`, dest), - ...copy(`${source}/dependencies`, dest), - ...copy(`${source}/data`, dest) - ]; + return prerendered_dirs.flatMap((dir) => copy(`${source}/${dir}`, dest)); }, writeServer(dest) { @@ -392,13 +390,13 @@ export function create_builder({ * @param {string} directory */ function measure_files(directory) { - /** @type {Array<{ file: string, size: number, hash: string }>} */ + /** @type {MeasuredFile[]} */ const files = []; if (!fs.existsSync(directory)) return files; for (const file of walk(directory)) { - if (file.startsWith('.vite/') || file.includes('/.vite/')) continue; + if (file.startsWith('.vite/')) continue; const contents = fs.readFileSync(path.join(directory, file)); files.push({ @@ -413,10 +411,12 @@ function measure_files(directory) { /** * Writes gzip and brotli variants next to `file` and returns their sizes + * @param {string} directory * @param {string} file */ -async function compress_file(file) { - const contents = await fs.promises.readFile(file); +async function compress_file(directory, file) { + const abs = path.resolve(directory, file); + const contents = await fs.promises.readFile(abs); const [gz, br] = await Promise.all([ gzip(contents, { level: zlib.constants.Z_BEST_COMPRESSION }), @@ -430,11 +430,11 @@ async function compress_file(file) { ]); await Promise.all([ - fs.promises.writeFile(`${file}.gz`, gz), - fs.promises.writeFile(`${file}.br`, br) + fs.promises.writeFile(`${abs}.gz`, gz), + fs.promises.writeFile(`${abs}.br`, br) ]); - return { gz: gz.length, br: br.length }; + return { file, gz: gz.length, br: br.length }; } /**