diff --git a/.changeset/quiet-hashes-arrive.md b/.changeset/quiet-hashes-arrive.md new file mode 100644 index 000000000000..fce20af9bd2e --- /dev/null +++ b/.changeset/quiet-hashes-arrive.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: expose the size and content hash of every client and prerendered file to adapters as `builder.clientFiles` and `builder.prerenderedFiles` 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 51e63738b2c1..d848cd78b8af 100644 --- a/documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md +++ b/documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md @@ -521,7 +521,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 - the `Server` class exported from the server output is deprecated — use the `server` object written by `builder.generateServerInstance` diff --git a/packages/adapter-bun/index.js b/packages/adapter-bun/index.js index 490939e90d13..1f6e59846792 100644 --- a/packages/adapter-bun/index.js +++ b/packages/adapter-bun/index.js @@ -18,57 +18,37 @@ 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. * @param {string} file - * @param {boolean} [precompress] - * @returns {Promise<{ hash: string, mtime: number, br?: boolean, gz?: boolean }>} + * @param {string | undefined} hash + * @param {boolean} compressed */ -async function asset_meta(file, precompress = false) { - const hash = await hash_file(file); +function asset_meta(file, hash, compressed) { + 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) { + /** @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 */ function validate_file_paths(files) { for (const file of files) { @@ -255,11 +235,17 @@ async function create_routes({ builder, out, embed, precompress }) { ...builder.prerendered.redirects.keys() ]); + const hashes = content_hashes(builder); + + /** @type {Record>} */ + const compressed = { client: new Set(), prerendered: new Set() }; if (precompress) { - await Promise.all([ - builder.compress(`${dest}/client`), - builder.compress(`${dest}/prerendered`) - ]); + await Promise.all( + /** @type {const} */ (['client', 'prerendered']).map(async (dir) => { + const files = await builder.compress(`${dest}/${dir}`); + compressed[dir] = new Set(files.map(({ file }) => file)); + }) + ); } /** @type {Map} */ @@ -268,25 +254,26 @@ async function create_routes({ builder, out, embed, precompress }) { /** * @param {string} helper * @param {string} url - * @param {string} dir + * @param {keyof typeof hashes} dir * @param {string} [filename] */ - const entry = async (helper, url, dir, filename = url) => { + const entry = (helper, url, dir, filename = url) => { const file = `${dest}/${dir}/${filename}`; if (embed) embedded.set(file, `asset_${embedded.size}`); - return `[${JSON.stringify(helper)}, ${JSON.stringify(url)}, ${embedded.get(file) ?? JSON.stringify(filename)}, ${JSON.stringify(await asset_meta(file, precompress))}]`; + const meta = asset_meta(file, hashes[dir].get(filename), compressed[dir].has(filename)); + return `[${JSON.stringify(helper)}, ${JSON.stringify(url)}, ${embedded.get(file) ?? JSON.stringify(filename)}, ${JSON.stringify(meta)}]`; }; const pages = [...builder.prerendered.pages]; const page_files = new Set(pages.map(([_, { file }]) => file)); - const assets = await Promise.all([ + const assets = [ ...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')) - ]); + ]; const server_assets = builder .findServerAssets(builder.routes.filter((route) => route.prerender !== true)) diff --git a/packages/adapter-bun/test/adapter.spec.ts b/packages/adapter-bun/test/adapter.spec.ts index 0b1fa486f506..fecd2382c5c1 100644 --- a/packages/adapter-bun/test/adapter.spec.ts +++ b/packages/adapter-bun/test/adapter.spec.ts @@ -13,8 +13,8 @@ let bun_build: Mock<(options: any) => Promise>; let read_file: Mock; let write_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, @@ -25,16 +25,7 @@ beforeEach(() => { stream: () => new Blob([]).stream(), lastModified: 0 })) as never); - spyOn(Bun, 'CryptoHasher').mockImplementation(function () { - return { - update() {}, - digest() { - return 'abc'; - } - }; - } as never); - spyOn(fs, 'existsSync').mockReturnValue(true); spyOn(fs, 'rmSync').mockImplementation(() => {}); read_file = spyOn(fs, 'readFileSync').mockImplementation((() => undefined) as any) as any; write_file = spyOn(fs, 'writeFileSync').mockImplementation(() => {}); @@ -349,7 +340,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); @@ -417,7 +408,8 @@ function create_builder({ server_assets = [], base = '', origin, - instrumentation = false + instrumentation = false, + compressed = [] }: { client_files?: string[]; prerendered_files?: string[]; @@ -428,7 +420,11 @@ function create_builder({ base?: string; origin?: string; instrumentation?: boolean; + compressed?: string[]; } = {}) { + // kit measures every file in its output on first access + const measure = (file: string) => ({ file, size: 0, hash: 'abc' }); + return { config: { outDir: '.svelte-kit', paths: { base, origin }, appDir: '_app' }, routes, @@ -436,6 +432,8 @@ function create_builder({ pages: new Map(prerendered_pages), redirects: new Map(prerendered_redirects) }, + clientFiles: client_files.map(measure), + prerenderedFiles: prerendered_files.map(measure), log: { minor: mock((_message: string) => {}), error: mock((_message: string) => {}), @@ -449,7 +447,9 @@ function create_builder({ writeClient: mock(() => client_files), writePrerendered: mock(() => prerendered_files), copy: mock(() => []), - compress: mock(async (_directory: string) => {}), + compress: mock(async (_directory: string) => + compressed.map((file) => ({ file, gz: 1, br: 1 })) + ), findServerAssets: mock(() => server_assets), hasServerInstrumentationFile: () => instrumentation, createInstrumentationInitializer: mock(() => `${server_dir}/__sveltekit_env_init.js`), diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 741d6f55c14f..cd77bb192de4 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -1,6 +1,4 @@ -import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; -import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; // posix so it matches the module ids Vite reports on every platform @@ -21,22 +19,19 @@ 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, + builder.prerenderedFiles, + prerendered_compressed ); const server = builder.getServerDirectory(); @@ -141,76 +136,48 @@ function is_hidden(file) { } /** - * Size and content hash from one pass over the file, a buffer at a time - * @param {string} file - * @param {Buffer} buffer + * @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(file, buffer) { - const fd = fs.openSync(file, 'r'); - const hash = createHash('sha256'); - let size = 0; - - try { - let read; - while ((read = fs.readSync(fd, buffer)) > 0) { - hash.update(buffer.subarray(0, read)); - size += read; - } - } finally { - fs.closeSync(fd); +function to_entry({ file, size, hash }, variants) { + /** @type {AssetEntry} */ + const entry = { file, size, etag: hash }; + + if (variants) { + entry.gz = variants.gz; + entry.br = variants.br; } - return { size, etag: hash.digest('base64url') }; + return entry; } /** - * 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 through a single buffer, so large outputs - * neither exhaust file descriptors nor pile up in memory - * @param {string} root - * @param {string[]} files - * @param {string[]} compressed - * @returns {AssetEntry[]} + * @template {{ file: string }} T + * @param {T[]} entries */ -function measure_files(root, files, compressed) { - const variants = new Set(compressed); - const buffer = Buffer.allocUnsafe(64 * 1024); - - /** @type {AssetEntry[]} */ - const entries = []; - - for (const file of files) { - if (is_hidden(file)) continue; - - const abs = join(root, file); - - /** @type {AssetEntry} */ - const entry = { file, ...measure(abs, buffer) }; - - // `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); - } - - return entries; +function by_file(entries) { + return new Map(entries.map((entry) => [entry.file, entry])); } /** - * Keys the measured files by URL: the exact pathname, plus the `/foo` and + * 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)); @@ -246,26 +213,26 @@ 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 {import('@sveltejs/kit').Builder['prerenderedFiles']} files + * @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, files, compressed) { + const measured = by_file(files); + 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, { file }] of map) { + const entry = measured.get(file); + if (entry) entries.push([path, to_entry(entry, variants.get(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 2d7409b828bc..ab3c9a04669d 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'; @@ -21,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', @@ -106,6 +110,11 @@ export function create_builder({ app_manifest.assets.push({ path: build_data.service_worker }); } + /** @type {MeasuredFile[] | undefined} */ + let client_files; + /** @type {MeasuredFile[] | undefined} */ + let prerendered_files; + return { log, rimraf: (dir) => fs.rmSync(dir, { force: true, recursive: true }), @@ -116,6 +125,14 @@ export function create_builder({ prerendered, routes, manifest: app_manifest, + get clientFiles() { + return (client_files ??= measure_files(`${config.outDir}/output/client`)); + }, + get prerenderedFiles() { + return (prerendered_files ??= prerendered_dirs.flatMap((dir) => + measure_files(`${config.outDir}/output/prerendered/${dir}`) + )); + }, 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); @@ -145,16 +162,22 @@ 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++; + compressed[index] = await compress_file(directory, files[index]); + } }) ); - return files; + return compressed; }, findServerAssets(route_data) { @@ -267,11 +290,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) { @@ -363,11 +382,37 @@ 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 {MeasuredFile[]} */ + const files = []; + + if (!fs.existsSync(directory)) return files; + + for (const file of walk(directory)) { + if (file.startsWith('.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} 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 }), @@ -375,9 +420,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 { file, 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..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'; @@ -95,7 +102,46 @@ 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 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/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/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 decb9fd8b192..be293394993f 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -555,22 +555,22 @@ 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 }); prerendered.paths.push(decoded); @@ -605,13 +605,9 @@ async function prerender({ written.add(file); if (is_html) { - prerendered.pages.set(decoded, { - file - }); + prerendered.pages.set(decoded, { file }); } else { - prerendered.assets.set(decoded, { - type - }); + 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 1f1e441f109a..04e35a758c46 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -177,6 +177,28 @@ 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; + }>; + /** + * 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. @@ -339,9 +361,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..fadfeb65622d 100644 --- a/packages/kit/src/types/private.d.ts +++ b/packages/kit/src/types/private.d.ts @@ -187,23 +187,27 @@ export interface Prerendered { } >; /** - * A map of `path` to `{ type }` objects. + * A map of `path` to `{ type, file }` objects. */ assets: Map< string, { /** The MIME type of the asset */ type: string; + /** The location of the file relative to the output directory */ + file: 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; } >; /** 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 1f6b3f169cd2..c83b4864b04a 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -164,6 +164,28 @@ 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; + }>; + /** + * 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. @@ -323,9 +345,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 { @@ -926,23 +948,27 @@ declare module '@sveltejs/kit' { } >; /** - * A map of `path` to `{ type }` objects. + * A map of `path` to `{ type, file }` objects. */ assets: Map< string, { /** The MIME type of the asset */ type: string; + /** The location of the file relative to the output directory */ + file: 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; } >; /** An array of prerendered paths (without trailing slashes, regardless of the trailingSlash config) */