From 217b76ada62fb81b07fb50371dd84be767af7e54 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:49:20 -0400 Subject: [PATCH 01/18] serve static assets from the build manifest --- .changeset/eight-frogs-drum.md | 5 + .changeset/spotty-moons-sit.md | 5 + .changeset/static-assets-build-time.md | 5 + .changeset/static-assets-etag.md | 5 + .changeset/static-assets-methods.md | 5 + packages/adapter-node/index.js | 187 +++++++++++++++--- packages/adapter-node/internal.d.ts | 23 ++- packages/adapter-node/package.json | 2 - packages/adapter-node/src/handler.js | 100 ++-------- packages/adapter-node/src/static.js | 175 ++++++++++++++++ .../src/routes/prerendered-page/+page.js | 1 + .../src/routes/prerendered-page/+page.svelte | 1 + .../test/apps/basic/static/.hidden | 1 + .../apps/basic/static/.well-known/thing.txt | 1 + .../test/apps/basic/static/a+b.txt | 1 + .../test/apps/basic/static/range.txt | 1 + .../adapter-node/test/apps/basic/test/test.js | 112 +++++++++++ packages/kit/src/core/adapt/builder.js | 7 + pnpm-lock.yaml | 9 - 19 files changed, 519 insertions(+), 127 deletions(-) create mode 100644 .changeset/eight-frogs-drum.md create mode 100644 .changeset/spotty-moons-sit.md create mode 100644 .changeset/static-assets-build-time.md create mode 100644 .changeset/static-assets-etag.md create mode 100644 .changeset/static-assets-methods.md create mode 100644 packages/adapter-node/src/static.js create mode 100644 packages/adapter-node/test/apps/basic/src/routes/prerendered-page/+page.js create mode 100644 packages/adapter-node/test/apps/basic/src/routes/prerendered-page/+page.svelte create mode 100644 packages/adapter-node/test/apps/basic/static/.hidden create mode 100644 packages/adapter-node/test/apps/basic/static/.well-known/thing.txt create mode 100644 packages/adapter-node/test/apps/basic/static/a+b.txt create mode 100644 packages/adapter-node/test/apps/basic/static/range.txt diff --git a/.changeset/eight-frogs-drum.md b/.changeset/eight-frogs-drum.md new file mode 100644 index 000000000000..e83993678eac --- /dev/null +++ b/.changeset/eight-frogs-drum.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-node': minor +--- + +feat: serve static assets from tables recorded at adapt time, instead of using sirv diff --git a/.changeset/spotty-moons-sit.md b/.changeset/spotty-moons-sit.md new file mode 100644 index 000000000000..2d6e050b7a2a --- /dev/null +++ b/.changeset/spotty-moons-sit.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: record client output extensions in `builder.mimeTypes` diff --git a/.changeset/static-assets-build-time.md b/.changeset/static-assets-build-time.md new file mode 100644 index 000000000000..e4f16a491226 --- /dev/null +++ b/.changeset/static-assets-build-time.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-node': major +--- + +breaking: record the list of static assets at build time, files added to the output afterwards are not served diff --git a/.changeset/static-assets-etag.md b/.changeset/static-assets-etag.md new file mode 100644 index 000000000000..794b2f3b51be --- /dev/null +++ b/.changeset/static-assets-etag.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-node': major +--- + +breaking: validate static assets with content-hash `ETag`s and stop sending `Last-Modified` diff --git a/.changeset/static-assets-methods.md b/.changeset/static-assets-methods.md new file mode 100644 index 000000000000..eb0afc9057c9 --- /dev/null +++ b/.changeset/static-assets-methods.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-node': major +--- + +breaking: serve static assets only to `GET` and `HEAD` requests diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 4fdcd0a40925..bf95c9c49244 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -1,5 +1,6 @@ +import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; -import { extname } from 'node:path'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; // posix so it matches the module ids Vite reports on every platform @@ -15,30 +16,27 @@ export default function (opts = {}) { async adapt(builder) { fs.rmSync(out, { force: true, recursive: true }); + const base = builder.config.paths.base; + const client_dir = `${out}/client${base}`; + const prerendered_dir = `${out}/prerendered${base}`; + builder.log.minor('Copying assets'); - const written = [ - ...builder.writeClient(`${out}/client${builder.config.paths.base}`), - ...builder.writePrerendered(`${out}/prerendered${builder.config.paths.base}`) - ]; - - /** @type {string[]} */ - let compressed = []; - - if (precompress) { - builder.log.minor('Compressing assets'); - compressed = ( - await Promise.all([ - builder.compress(`${out}/client`), - builder.compress(`${out}/prerendered`) - ]) - ).flat(); - } + const client_files = builder.writeClient(client_dir); + const prerendered_files = builder.writePrerendered(prerendered_dir); - const compressed_extensions = new Set(compressed.map((file) => extname(file))); - // a pathname whose extension appears in neither set may be a route segment - // resolving to a compressed `index.html`, so it must keep its `Vary` header - const uncompressed_extensions = new Set( - written.map((file) => extname(file)).filter((ext) => ext && !compressed_extensions.has(ext)) + builder.log.minor(precompress ? 'Compressing and hashing assets' : 'Hashing assets'); + const [client_compressed, prerendered_compressed] = precompress + ? await Promise.all([builder.compress(client_dir), builder.compress(prerendered_dir)]) + : [[], []]; + + const assets = create_asset_table( + base, + await measure_files(client_dir, client_files, client_compressed) + ); + const prerendered_assets = create_prerendered_table( + base, + await measure_files(prerendered_dir, prerendered_files, prerendered_compressed), + builder.prerendered.paths ); const server = builder.getServerDirectory(); @@ -66,14 +64,13 @@ export default function (opts = {}) { `import { fileURLToPath } from 'node:url';`, `export { server } from './server/server.js';`, `export const dir = dirname(fileURLToPath(import.meta.url));`, - `export const base = ${JSON.stringify(builder.config.paths.base)};`, + `export const base = ${JSON.stringify(base)};`, `export const app_path = ${JSON.stringify(builder.getAppPath())};`, `export const origin = ${JSON.stringify(builder.config.paths.origin)};`, `export const env_prefix = ${JSON.stringify(envPrefix)};`, - `export const precompress = ${precompress};`, - `export const uncompressed_extensions = new Set(${JSON.stringify([...uncompressed_extensions])});`, - `export const prerendered = new Set(${JSON.stringify(builder.prerendered.paths)});`, - `export const mime_types = ${JSON.stringify(builder.mimeTypes)};` + `export const mime_types = ${JSON.stringify(builder.mimeTypes)};`, + `export const assets = ${JSON.stringify(assets)};`, + `export const prerendered_assets = ${JSON.stringify(prerendered_assets)};` ].join('\n') ); @@ -133,3 +130,137 @@ export default function (opts = {}) { } }; } + +/** + * Size and content hash, from a single pass over the file + * @param {string} file + * @returns {Promise<[number, string]>} + */ +async function measure(file) { + const hash = createHash('sha256'); + let size = 0; + + for await (const chunk of fs.createReadStream(file)) { + hash.update(chunk); + size += chunk.length; + } + + return [size, hash.digest('base64url')]; +} + +/** + * Dotfiles are not served, with the customary exception of `.well-known` + * @param {string} file + */ +function is_hidden(file) { + return file.split('/').some((segment) => segment[0] === '.') && !file.startsWith('.well-known/'); +} + +/** + * Sizes and content hashes for every servable file, plus its compressed + * variants where `builder.compress` wrote them + * @param {string} root + * @param {string[]} files + * @param {string[]} compressed + * @returns {Promise} + */ +function measure_files(root, files, compressed) { + const variants = new Set(compressed); + + return Promise.all( + files + .filter((file) => !is_hidden(file)) + .map(async (file) => { + const [size, etag] = await measure(join(root, file)); + + /** @type {AssetEntry} */ + const entry = { file, size, etag }; + + // `builder.compress` writes a `.gz` and a `.br` variant of every file it returns + if (variants.has(file)) { + entry.gz = await measure(join(root, `${file}.gz`)); + entry.br = await measure(join(root, `${file}.br`)); + } + + return entry; + }) + ); +} + +/** + * Keys the measured 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 + * @returns {AssetTable} + */ +function create_asset_table(base, measured) { + const entries = measured.map((entry) => /** @type {[string, AssetEntry]} */ ([ + `${base}/${entry.file}`, + entry + ])); + + entries.sort(([a], [b]) => (a < b ? -1 : 1)); + + const keys = new Set(entries.map(([key]) => key)); + + /** @type {Array<[string, string]>} */ + const aliases = []; + + /** + * @param {string} alias + * @param {string} key + */ + function alias(alias, key) { + if (!keys.has(alias)) { + keys.add(alias); + aliases.push([alias, key]); + } + } + + // `/foo` and `/foo/` resolve to `foo.html`, unless a `foo/index.html` exists, + // in which case `foo.html` wins (matching the resolution order sirv used) + for (const [key, entry] of entries) { + if (!entry.file.endsWith('.html')) continue; + if (entry.file === 'index.html' || entry.file.endsWith('/index.html')) continue; + alias(key.slice(0, -5), key); + alias(key.slice(0, -5) + '/', key); + } + + for (const [key, entry] of entries) { + if (entry.file !== 'index.html' && !entry.file.endsWith('/index.html')) continue; + const with_slash = key.slice(0, -'index.html'.length); + if (with_slash.length > 1) alias(with_slash.slice(0, -1), key); + alias(with_slash, key); + } + + return { entries, aliases }; +} + +/** + * 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 + * pathname (including the non-canonical trailing-slash form) misses + * @param {string} base + * @param {AssetEntry[]} measured + * @param {string[]} paths + * @returns {AssetTable} + */ +function create_prerendered_table(base, measured, paths) { + const by_file = new Map(measured.map((entry) => [entry.file, entry])); + + /** @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]); + } + + entries.sort(([a], [b]) => (a < b ? -1 : 1)); + + return { entries, aliases: [] }; +} diff --git a/packages/adapter-node/internal.d.ts b/packages/adapter-node/internal.d.ts index 89767e62bdd6..1f2d8eb88b0e 100644 --- a/packages/adapter-node/internal.d.ts +++ b/packages/adapter-node/internal.d.ts @@ -7,8 +7,25 @@ declare module '#@sveltejs/adapter-node' { export const app_path: string; export const origin: string | undefined; export const env_prefix: string; - export const precompress: boolean; - export const uncompressed_extensions: Set; - export const prerendered: Set; export const mime_types: Record; + export const assets: AssetTable; + export const prerendered_assets: AssetTable; +} + +interface AssetEntry { + /** path on disk, relative to the served directory */ + file: string; + size: number; + /** content hash */ + etag: string; + /** size and content hash of the gzip variant, if one was written */ + gz?: [number, string]; + /** size and content hash of the brotli variant, if one was written */ + br?: [number, string]; +} + +interface AssetTable { + entries: Array<[string, AssetEntry]>; + /** `[alias, key]` pairs, e.g. `['/about', '/about.html']` */ + aliases: Array<[string, string]>; } diff --git a/packages/adapter-node/package.json b/packages/adapter-node/package.json index 65bfd33d4bfd..3b1a4f5c7239 100644 --- a/packages/adapter-node/package.json +++ b/packages/adapter-node/package.json @@ -44,11 +44,9 @@ "prepublishOnly": "pnpm build" }, "devDependencies": { - "@polka/url": "catalog:", "@sveltejs/kit": "workspace:^", "@types/node": "catalog:", "rolldown": "catalog:", - "sirv": "^3.0.2", "typescript": "catalog:typescript-native", "vitest": "catalog:" }, diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index 841c300ef6da..2e2b1bd59b07 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -1,10 +1,5 @@ /** @import { IncomingMessage, ServerResponse, IncomingHttpHeaders } from 'node:http' */ -/** @import { RequestHandler } from 'sirv' */ -import fs from 'node:fs'; -import path, { extname } from 'node:path'; import process from 'node:process'; -import sirv from 'sirv'; -import { parse as polka_url_parser } from '@polka/url'; import { getRequest, setResponse, createReadableStream } from '@sveltejs/kit/node'; import { server, @@ -13,13 +8,13 @@ import { base, app_path, origin, - precompress, - uncompressed_extensions, - prerendered, - mime_types + mime_types, + assets, + prerendered_assets } from '#@sveltejs/adapter-node'; import { env } from './env.js'; import { parse_as_bytes } from './utils.js'; +import { serve_static } from './static.js'; /** @typedef {(req: IncomingMessage, res: ServerResponse, next: () => void | Promise) => void | Promise} Middleware */ @@ -44,78 +39,6 @@ await server.init({ read: (file) => createReadableStream(`${asset_dir}/${file}`) }); -/** - * @param {string} path - * @param {boolean} client - */ -function serve(path, client = false) { - return fs.existsSync(path) - ? sirv(path, { - etag: true, - gzip: precompress, - brotli: precompress, - setHeaders: (res, pathname) => { - // `sirv` sets `Vary` from its options rather than from the file it resolved - if (precompress && uncompressed_extensions.has(extname(pathname))) { - res.removeHeader('vary'); - } - - // `sirv` uses its own bundled `mrmime`, which the manifest's added types never reach - let type = mime_types[pathname.slice(pathname.lastIndexOf('.'))]; - if (type === 'text/html') type += ';charset=utf-8'; - if (type) res.setHeader('content-type', type); - - // only apply to build directory, not e.g. version.json - if (client && pathname.startsWith(`/${app_path}/immutable/`) && res.statusCode === 200) { - res.setHeader('cache-control', 'public,max-age=31536000,immutable'); - } - } - }) - : undefined; -} - -/** - * Relative reference from `from` to `to`, which must differ only by a trailing slash. - * Keep in sync with the copy in `packages/kit/src/utils/url.js` - * @param {string} from - * @param {string} to - * @returns {string} - */ -function relative_pathname(from, to) { - const segment = to.replace(/\/$/, '').split('/').at(-1); - - return from.endsWith('/') ? `../${segment}` : `${segment}/`; -} - -// required because the static file server ignores trailing slashes -/** @returns {Middleware} */ -function serve_prerendered() { - const handler = serve(path.join(dir, 'prerendered')); - - return (req, res, next) => { - let { pathname, search, query } = polka_url_parser(req); - - try { - pathname = decodeURIComponent(pathname); - } catch { - // ignore invalid URI - } - - if (prerendered.has(pathname)) { - return handler?.(req, res, next); - } - - // remove or add trailing slash as appropriate - const inverted = pathname.at(-1) === '/' ? pathname.slice(0, -1) : pathname + '/'; - if (prerendered.has(inverted)) { - const location = relative_pathname(pathname, inverted) + (query ? search : ''); - res.writeHead(308, { location }).end(); - } else { - void next(); - } - }; -} - /** @type {Middleware} */ const ssr = async (req, res) => { /** @type {Request} */ @@ -277,7 +200,14 @@ function get_origin(headers) { return port ? `${protocol}://${host}:${port}` : `${protocol}://${host}`; } -export const handler = sequence( - /** @type {(RequestHandler | Middleware)[]} */ - ([serve(path.join(dir, 'client'), true), serve_prerendered(), ssr].filter(Boolean)) -); +export const handler = sequence([ + serve_static(asset_dir, assets, { + mime_types, + immutable_prefix: `/${app_path}/immutable/` + }), + serve_static(`${dir}/prerendered${base}`, prerendered_assets, { + mime_types, + redirect_trailing_slash: true + }), + ssr +]); diff --git a/packages/adapter-node/src/static.js b/packages/adapter-node/src/static.js new file mode 100644 index 000000000000..c940d0f0878d --- /dev/null +++ b/packages/adapter-node/src/static.js @@ -0,0 +1,175 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** @typedef {AssetEntry & { type?: string }} Asset */ + +/** + * Splits `req.url` into a decoded pathname and the search string. + * An undecodable pathname is returned as-is, so it misses the asset + * tables and falls through to SvelteKit's 400 + * @param {import('node:http').IncomingMessage} req + */ +function split_url(req) { + let pathname = /** @type {string} */ (req.url); + let search = ''; + + const query_index = pathname.indexOf('?'); + if (query_index !== -1) { + search = pathname.slice(query_index); + pathname = pathname.slice(0, query_index); + } + + if (pathname.includes('%')) { + try { + pathname = decodeURIComponent(pathname); + } catch { + // invalid URI + } + } + + return { pathname, search }; +} + +/** + * Relative reference from `from` to `to`, which must differ only by a trailing slash. + * Keep in sync with the copy in `packages/kit/src/utils/url.js` + * @param {string} from + * @param {string} to + * @returns {string} + */ +function relative_pathname(from, to) { + const segment = to.replace(/\/$/, '').split('/').at(-1); + + return from.endsWith('/') ? `../${segment}` : `${segment}/`; +} + +/** + * Serves the closed set of files recorded in the manifest at adapt time. + * Everything about a response is precomputed: exact pathname keys, sizes, + * content-hash ETags, content types and which compressed variants exist, + * so requests are a map lookup and a stream. + * + * @param {string} dir + * @param {AssetTable} table + * @param {{ + * mime_types: Record, + * immutable_prefix?: string, + * redirect_trailing_slash?: boolean + * }} opts + * @returns {import('./handler.js').Middleware} + */ +export function serve_static( + dir, + table, + { mime_types, immutable_prefix, redirect_trailing_slash } +) { + /** @type {Map} */ + const files = new Map(); + + for (const [key, entry] of table.entries) { + let type = mime_types[entry.file.slice(entry.file.lastIndexOf('.'))]; + if (type === 'text/html') type += ';charset=utf-8'; + files.set(key, { ...entry, file: path.join(dir, entry.file), type }); + } + + for (const [alias, key] of table.aliases) { + files.set(alias, /** @type {Asset} */ (files.get(key))); + } + + return (req, res, next) => { + const { pathname, search } = split_url(req); + + const entry = files.get(pathname); + if (!entry) { + if (redirect_trailing_slash) { + // redirect to the canonical path when only the trailing slash differs + const inverted = pathname.at(-1) === '/' ? pathname.slice(0, -1) : pathname + '/'; + if (files.has(inverted)) { + const location = relative_pathname(pathname, inverted) + search; + res.writeHead(308, { location }).end(); + return; + } + } + return next(); + } + + let file = entry.file; + let size = entry.size; + let etag = entry.etag; + + /** @type {string | undefined} */ + let encoding; + + const accept_encoding = req.headers['accept-encoding'] ?? ''; + if (entry.br && /\bbr\b/.test(accept_encoding)) { + [size, etag] = entry.br; + file += '.br'; + encoding = 'br'; + } else if (entry.gz && /\bgzip\b/.test(accept_encoding)) { + [size, etag] = entry.gz; + file += '.gz'; + encoding = 'gzip'; + } + + if (req.headers['if-none-match'] === `"${etag}"`) { + res.writeHead(304).end(); + return; + } + + /** @type {Record} */ + const headers = { + 'content-length': size, + etag: `"${etag}"`, + 'accept-ranges': 'bytes' + }; + + if (entry.type) headers['content-type'] = entry.type; + if (encoding) headers['content-encoding'] = encoding; + if (entry.br || entry.gz) headers.vary = 'Accept-Encoding'; + + if (immutable_prefix && pathname.startsWith(immutable_prefix)) { + headers['cache-control'] = 'public,max-age=31536000,immutable'; + } + + /** @type {{ start?: number, end?: number }} */ + const range = {}; + let status = 200; + + if (req.headers.range) { + const match = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range); + + if (match && (match[1] || match[2])) { + let start = match[1] ? parseInt(match[1], 10) : NaN; + let end = match[2] ? parseInt(match[2], 10) : size - 1; + + if (isNaN(start)) { + // suffix range: the last `match[2]` bytes + start = Math.max(size - end, 0); + end = size - 1; + } else { + end = Math.min(end, size - 1); + } + + if (start >= size || start > end) { + res.writeHead(416, { 'content-range': `bytes */${size}` }).end(); + return; + } + + status = 206; + headers['content-range'] = `bytes ${start}-${end}/${size}`; + headers['content-length'] = end - start + 1; + range.start = start; + range.end = end; + } + } + + res.writeHead(status, headers); + + if (req.method === 'HEAD') { + res.end(); + return; + } + + fs.createReadStream(file, range).pipe(res); + }; +} diff --git a/packages/adapter-node/test/apps/basic/src/routes/prerendered-page/+page.js b/packages/adapter-node/test/apps/basic/src/routes/prerendered-page/+page.js new file mode 100644 index 000000000000..189f71e2e1b3 --- /dev/null +++ b/packages/adapter-node/test/apps/basic/src/routes/prerendered-page/+page.js @@ -0,0 +1 @@ +export const prerender = true; diff --git a/packages/adapter-node/test/apps/basic/src/routes/prerendered-page/+page.svelte b/packages/adapter-node/test/apps/basic/src/routes/prerendered-page/+page.svelte new file mode 100644 index 000000000000..e0c562081ba2 --- /dev/null +++ b/packages/adapter-node/test/apps/basic/src/routes/prerendered-page/+page.svelte @@ -0,0 +1 @@ +

prerendered

diff --git a/packages/adapter-node/test/apps/basic/static/.hidden b/packages/adapter-node/test/apps/basic/static/.hidden new file mode 100644 index 000000000000..f525718fdb02 --- /dev/null +++ b/packages/adapter-node/test/apps/basic/static/.hidden @@ -0,0 +1 @@ +shh \ No newline at end of file diff --git a/packages/adapter-node/test/apps/basic/static/.well-known/thing.txt b/packages/adapter-node/test/apps/basic/static/.well-known/thing.txt new file mode 100644 index 000000000000..68cfd84c3359 --- /dev/null +++ b/packages/adapter-node/test/apps/basic/static/.well-known/thing.txt @@ -0,0 +1 @@ +wk \ No newline at end of file diff --git a/packages/adapter-node/test/apps/basic/static/a+b.txt b/packages/adapter-node/test/apps/basic/static/a+b.txt new file mode 100644 index 000000000000..b070a6a41f99 --- /dev/null +++ b/packages/adapter-node/test/apps/basic/static/a+b.txt @@ -0,0 +1 @@ +plus \ No newline at end of file diff --git a/packages/adapter-node/test/apps/basic/static/range.txt b/packages/adapter-node/test/apps/basic/static/range.txt new file mode 100644 index 000000000000..ad471007bd7f --- /dev/null +++ b/packages/adapter-node/test/apps/basic/static/range.txt @@ -0,0 +1 @@ +0123456789 \ No newline at end of file diff --git a/packages/adapter-node/test/apps/basic/test/test.js b/packages/adapter-node/test/apps/basic/test/test.js index 68f22a449044..b6d0c910bf5a 100644 --- a/packages/adapter-node/test/apps/basic/test/test.js +++ b/packages/adapter-node/test/apps/basic/test/test.js @@ -97,3 +97,115 @@ test('does not replace adapter stubs in application chunks', async ({ request }) const response = await request.get('/stub'); expect(await response.text()).toBe('__SVELTEKIT_ADAPTER_NODE_MIMETYPES__'); }); + +test('serves files with a + in the name', async ({ request }) => { + // https://github.com/sveltejs/kit/issues/11766 + const response = await request.get('/a+b.txt'); + expect(response.status()).toBe(200); + expect(await response.text()).toBe('plus'); +}); + +test('does not serve dotfiles', async ({ request }) => { + const response = await request.get('/.hidden'); + expect(response.status()).toBe(404); +}); + +test('serves .well-known', async ({ request }) => { + const response = await request.get('/.well-known/thing.txt'); + expect(response.status()).toBe(200); + expect(await response.text()).toBe('wk'); +}); + +test('serves a content-hash ETag and honours if-none-match', async ({ request }) => { + const response = await request.get('/range.txt'); + const etag = response.headers()['etag']; + expect(etag).toBeTruthy(); + + const cached = await request.get('/range.txt', { headers: { 'if-none-match': etag } }); + expect(cached.status()).toBe(304); +}); + +test('responds to HEAD without a body', async ({ request }) => { + const response = await request.head('/range.txt', { + headers: { 'accept-encoding': 'identity' } + }); + expect(response.status()).toBe(200); + expect(response.headers()['content-length']).toBe('10'); + expect(await response.text()).toBe(''); +}); + +test('serves a single-byte range', async ({ request }) => { + // the probe HTML5 video and PDF.js use to detect range support + const response = await request.get('/range.txt', { + headers: { 'accept-encoding': 'identity', range: 'bytes=0-0' } + }); + expect(response.status()).toBe(206); + expect(response.headers()['content-range']).toBe('bytes 0-0/10'); + expect(await response.text()).toBe('0'); +}); + +test('serves a suffix range', async ({ request }) => { + const response = await request.get('/range.txt', { + headers: { 'accept-encoding': 'identity', range: 'bytes=-3' } + }); + expect(response.status()).toBe(206); + expect(response.headers()['content-range']).toBe('bytes 7-9/10'); + expect(await response.text()).toBe('789'); +}); + +test('serves an open-ended range', async ({ request }) => { + const response = await request.get('/range.txt', { + headers: { 'accept-encoding': 'identity', range: 'bytes=4-' } + }); + expect(response.status()).toBe(206); + expect(await response.text()).toBe('456789'); +}); + +test('rejects an unsatisfiable range', async ({ request }) => { + const response = await request.get('/range.txt', { + headers: { 'accept-encoding': 'identity', range: 'bytes=10-' } + }); + expect(response.status()).toBe(416); + expect(response.headers()['content-range']).toBe('bytes */10'); +}); + +test('serves a prerendered page', async ({ request }) => { + const response = await request.get('/prerendered-page'); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toBe('text/html;charset=utf-8'); + expect(await response.text()).toContain('prerendered'); +}); + +test('redirects to the canonical prerendered path', async ({ request }) => { + const response = await request.get('/prerendered-page/', { maxRedirects: 0 }); + expect(response.status()).toBe(308); + expect(response.headers()['location']).toBe('../prerendered-page'); +}); + +test('resolves an extensionless path to the matching .html file', async ({ request }) => { + const response = await request.get('/page'); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toBe('text/html;charset=utf-8'); +}); + +test('serves immutable assets with an immutable cache header', async ({ request }) => { + const html = await (await request.get('/')).text(); + const [asset] = /** @type {RegExpMatchArray} */ (html.match(/\/_app\/immutable\/[^"']+\.js/)); + + const response = await request.get(asset); + expect(response.status()).toBe(200); + expect(response.headers()['cache-control']).toBe('public,max-age=31536000,immutable'); +}); + +test('does not serve version.json with an immutable cache header', async ({ request }) => { + const response = await request.get('/_app/version.json'); + expect(response.status()).toBe(200); + expect(response.headers()['cache-control']).toBeUndefined(); +}); + +test('serves the gzip variant when brotli is not accepted', async ({ request }) => { + const response = await request.get('/range.txt', { headers: { 'accept-encoding': 'gzip' } }); + expect(response.status()).toBe(200); + expect(response.headers()['content-encoding']).toBe('gzip'); + expect(response.headers()['vary']).toBe('Accept-Encoding'); +}); diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index 89298891f535..eb61f50ccee2 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -134,6 +134,13 @@ export function create_builder({ const ext = path.extname(pathname); if (ext) mime_types[ext] ??= mime_lookup(ext) || ''; } + + // record extensions in the client output, so that adapters can serve + // client files without a mime database of their own + for (const file of walk(path.join(build_data.out_dir, 'client'))) { + const ext = path.extname(file); + if (ext) mime_types[ext] ??= mime_lookup(ext) || ''; + } return mime_types; }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27c586459676..45761cee8074 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,9 +128,6 @@ catalogs: '@playwright/test': specifier: ^1.62.1 version: 1.62.1 - '@polka/url': - specifier: ^1.0.0-next.29 - version: 1.0.0-next.29 '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 @@ -455,9 +452,6 @@ importers: packages/adapter-node: devDependencies: - '@polka/url': - specifier: 'catalog:' - version: 1.0.0-next.29 '@sveltejs/kit': specifier: workspace:^ version: link:../kit @@ -467,9 +461,6 @@ importers: rolldown: specifier: 'catalog:' version: 1.2.3 - sirv: - specifier: ^3.0.2 - version: 3.0.2 typescript: specifier: catalog:typescript-native version: 7.0.2 From 6e7f4070b6ebe7bcf450cb34cfa59881cb228ad7 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:57:30 -0400 Subject: [PATCH 02/18] destroy the response when the file stream errors An unhandled read stream error (a file deleted from the build output, EMFILE) crashed the process. Headers are already sent by then, so drop the connection. --- packages/adapter-node/src/static.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/adapter-node/src/static.js b/packages/adapter-node/src/static.js index c940d0f0878d..f60ce53170ae 100644 --- a/packages/adapter-node/src/static.js +++ b/packages/adapter-node/src/static.js @@ -170,6 +170,9 @@ export function serve_static( return; } - fs.createReadStream(file, range).pipe(res); + // headers are already sent, so all we can do is drop the connection + fs.createReadStream(file, range) + .on('error', () => res.destroy()) + .pipe(res); }; } From dbef9f16b19f381fd164959490ffcf3a69ca00c2 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:49:20 -0400 Subject: [PATCH 03/18] negotiate encodings and preconditions properly, derive variant etags from the original hash --- packages/adapter-node/index.js | 27 ++--- packages/adapter-node/internal.d.ts | 8 +- packages/adapter-node/src/static.js | 112 ++++++++++++------ .../adapter-node/test/apps/basic/test/test.js | 73 ++++++++++++ 4 files changed, 166 insertions(+), 54 deletions(-) diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index bf95c9c49244..22b41541e1f1 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -132,20 +132,15 @@ export default function (opts = {}) { } /** - * Size and content hash, from a single pass over the file + * Content hash of a file * @param {string} file - * @returns {Promise<[number, string]>} */ -async function measure(file) { - const hash = createHash('sha256'); - let size = 0; +async function hash(file) { + const sha = createHash('sha256'); - for await (const chunk of fs.createReadStream(file)) { - hash.update(chunk); - size += chunk.length; - } + for await (const chunk of fs.createReadStream(file)) sha.update(chunk); - return [size, hash.digest('base64url')]; + return sha.digest('base64url'); } /** @@ -157,8 +152,8 @@ function is_hidden(file) { } /** - * Sizes and content hashes for every servable file, plus its compressed - * variants where `builder.compress` wrote them + * Size and content hash of every servable file, plus the sizes of the + * compressed variants where `builder.compress` wrote them * @param {string} root * @param {string[]} files * @param {string[]} compressed @@ -171,15 +166,15 @@ function measure_files(root, files, compressed) { files .filter((file) => !is_hidden(file)) .map(async (file) => { - const [size, etag] = await measure(join(root, file)); + const abs = join(root, file); /** @type {AssetEntry} */ - const entry = { file, size, etag }; + const entry = { file, size: fs.statSync(abs).size, etag: await hash(abs) }; // `builder.compress` writes a `.gz` and a `.br` variant of every file it returns if (variants.has(file)) { - entry.gz = await measure(join(root, `${file}.gz`)); - entry.br = await measure(join(root, `${file}.br`)); + entry.gz = fs.statSync(`${abs}.gz`).size; + entry.br = fs.statSync(`${abs}.br`).size; } return entry; diff --git a/packages/adapter-node/internal.d.ts b/packages/adapter-node/internal.d.ts index 1f2d8eb88b0e..77426e780893 100644 --- a/packages/adapter-node/internal.d.ts +++ b/packages/adapter-node/internal.d.ts @@ -18,10 +18,10 @@ interface AssetEntry { size: number; /** content hash */ etag: string; - /** size and content hash of the gzip variant, if one was written */ - gz?: [number, string]; - /** size and content hash of the brotli variant, if one was written */ - br?: [number, string]; + /** size of the gzip variant, if one was written */ + gz?: number; + /** size of the brotli variant, if one was written */ + br?: number; } interface AssetTable { diff --git a/packages/adapter-node/src/static.js b/packages/adapter-node/src/static.js index f60ce53170ae..103f922cf9e4 100644 --- a/packages/adapter-node/src/static.js +++ b/packages/adapter-node/src/static.js @@ -5,8 +5,9 @@ import path from 'node:path'; /** * Splits `req.url` into a decoded pathname and the search string. - * An undecodable pathname is returned as-is, so it misses the asset - * tables and falls through to SvelteKit's 400 + * Decoding follows kit's router: reserved characters such as `%2F` stay + * encoded. An undecodable pathname is returned as-is, so it misses the + * asset table and falls through to SvelteKit's 400 * @param {import('node:http').IncomingMessage} req */ function split_url(req) { @@ -21,7 +22,7 @@ function split_url(req) { if (pathname.includes('%')) { try { - pathname = decodeURIComponent(pathname); + pathname = pathname.split('%25').map(decodeURI).join('%25'); } catch { // invalid URI } @@ -43,6 +44,52 @@ function relative_pathname(from, to) { return from.endsWith('/') ? `../${segment}` : `${segment}/`; } +/** + * Parses `Accept-Encoding` and picks the preferred variant that exists + * @param {string | undefined} header + * @param {Asset} asset + * @returns {'br' | 'gz' | undefined} + */ +function negotiate(header, asset) { + if (!header) return; + + /** @type {Map} */ + const weights = new Map(); + + for (const part of header.toLowerCase().split(',')) { + const [coding, ...params] = part.split(';'); + let weight = 1; + + for (const param of params) { + const [name, value] = param.split('='); + if (name.trim() === 'q') weight = parseFloat(value) || 0; + } + + weights.set(coding.trim(), weight); + } + + /** @param {string} coding */ + const weight = (coding) => weights.get(coding) ?? weights.get('*') ?? 0; + + const br = asset.br ? weight('br') : 0; + const gzip = asset.gz ? weight('gzip') : 0; + + if (gzip > br) return 'gz'; + if (br > 0) return 'br'; +} + +/** + * Whether an `If-None-Match` value matches `etag`, using weak comparison + * @param {string | undefined} header + * @param {string} etag + */ +function etag_matches(header, etag) { + if (!header) return false; + if (header.trim() === '*') return true; + + return header.split(',').some((tag) => tag.trim().replace(/^W\//, '') === etag); +} + /** * Serves the closed set of files recorded in the manifest at adapt time. * Everything about a response is precomputed: exact pathname keys, sizes, @@ -77,10 +124,12 @@ export function serve_static( } return (req, res, next) => { + if (req.method !== 'GET' && req.method !== 'HEAD') return next(); + const { pathname, search } = split_url(req); - const entry = files.get(pathname); - if (!entry) { + const asset = files.get(pathname); + if (!asset) { if (redirect_trailing_slash) { // redirect to the canonical path when only the trailing slash differs const inverted = pathname.at(-1) === '/' ? pathname.slice(0, -1) : pathname + '/'; @@ -93,49 +142,44 @@ export function serve_static( return next(); } - let file = entry.file; - let size = entry.size; - let etag = entry.etag; - - /** @type {string | undefined} */ - let encoding; - - const accept_encoding = req.headers['accept-encoding'] ?? ''; - if (entry.br && /\bbr\b/.test(accept_encoding)) { - [size, etag] = entry.br; - file += '.br'; - encoding = 'br'; - } else if (entry.gz && /\bgzip\b/.test(accept_encoding)) { - [size, etag] = entry.gz; - file += '.gz'; - encoding = 'gzip'; - } + let file = asset.file; + let size = asset.size; + let etag = `"${asset.etag}"`; - if (req.headers['if-none-match'] === `"${etag}"`) { - res.writeHead(304).end(); - return; + const variant = negotiate(req.headers['accept-encoding'], asset); + if (variant) { + size = /** @type {number} */ (asset[variant]); + file += `.${variant}`; + etag = `"${asset.etag}.${variant}"`; } /** @type {Record} */ - const headers = { - 'content-length': size, - etag: `"${etag}"`, - 'accept-ranges': 'bytes' - }; + const headers = { etag }; - if (entry.type) headers['content-type'] = entry.type; - if (encoding) headers['content-encoding'] = encoding; - if (entry.br || entry.gz) headers.vary = 'Accept-Encoding'; + if (asset.br || asset.gz) headers.vary = 'Accept-Encoding'; if (immutable_prefix && pathname.startsWith(immutable_prefix)) { headers['cache-control'] = 'public,max-age=31536000,immutable'; } + if (etag_matches(req.headers['if-none-match'], etag)) { + res.writeHead(304, headers).end(); + return; + } + + headers['content-length'] = size; + headers['accept-ranges'] = 'bytes'; + if (asset.type) headers['content-type'] = asset.type; + if (variant) headers['content-encoding'] = variant === 'gz' ? 'gzip' : 'br'; + /** @type {{ start?: number, end?: number }} */ const range = {}; let status = 200; - if (req.headers.range) { + // a stale `If-Range` validator means the client's partial copy is of an older + // representation, so it gets the whole current one + const if_range = req.headers['if-range']; + if (req.headers.range && (!if_range || if_range === etag)) { const match = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range); if (match && (match[1] || match[2])) { diff --git a/packages/adapter-node/test/apps/basic/test/test.js b/packages/adapter-node/test/apps/basic/test/test.js index b6d0c910bf5a..8d3e6ea50ece 100644 --- a/packages/adapter-node/test/apps/basic/test/test.js +++ b/packages/adapter-node/test/apps/basic/test/test.js @@ -209,3 +209,76 @@ test('serves the gzip variant when brotli is not accepted', async ({ request }) expect(response.headers()['content-encoding']).toBe('gzip'); expect(response.headers()['vary']).toBe('Accept-Encoding'); }); + +test('serves the variant the client prefers', async ({ request }) => { + const response = await request.get('/range.txt', { + headers: { 'accept-encoding': 'gzip;q=1, br;q=0.5' } + }); + expect(response.headers()['content-encoding']).toBe('gzip'); +}); + +test('does not serve an encoding the client rejects', async ({ request }) => { + const response = await request.get('/range.txt', { + headers: { 'accept-encoding': 'br;q=0, gzip;q=0' } + }); + expect(response.headers()['content-encoding']).toBeUndefined(); + expect(await response.text()).toBe('0123456789'); +}); + +test('treats content codings as case-insensitive', async ({ request }) => { + const response = await request.get('/range.txt', { headers: { 'accept-encoding': 'BR' } }); + expect(response.headers()['content-encoding']).toBe('br'); +}); + +test('gives each variant its own ETag', async ({ request }) => { + const identity = await request.get('/range.txt', { headers: { 'accept-encoding': 'identity' } }); + const br = await request.get('/range.txt', { headers: { 'accept-encoding': 'br' } }); + expect(br.headers()['etag']).not.toBe(identity.headers()['etag']); +}); + +test('only serves static files to GET and HEAD', async ({ request }) => { + const response = await request.post('/range.txt'); + expect(response.status()).not.toBe(200); + expect(response.headers()['etag']).toBeUndefined(); +}); + +test('sends the validator headers with a 304', async ({ request }) => { + const response = await request.get('/range.txt'); + const etag = response.headers()['etag']; + + const cached = await request.get('/range.txt', { headers: { 'if-none-match': etag } }); + expect(cached.status()).toBe(304); + expect(cached.headers()['etag']).toBe(etag); + expect(cached.headers()['vary']).toBe('Accept-Encoding'); +}); + +test('matches if-none-match lists, weak tags and wildcards', async ({ request }) => { + const etag = (await request.get('/range.txt')).headers()['etag']; + + for (const header of [`"stale", ${etag}`, `W/${etag}`, '*']) { + const cached = await request.get('/range.txt', { headers: { 'if-none-match': header } }); + expect(cached.status(), header).toBe(304); + } +}); + +test('serves the whole file when if-range does not match', async ({ request }) => { + const etag = ( + await request.get('/range.txt', { headers: { 'accept-encoding': 'identity' } }) + ).headers()['etag']; + + const fresh = await request.get('/range.txt', { + headers: { 'accept-encoding': 'identity', range: 'bytes=0-1', 'if-range': etag } + }); + expect(fresh.status()).toBe(206); + + const stale = await request.get('/range.txt', { + headers: { 'accept-encoding': 'identity', range: 'bytes=0-1', 'if-range': '"stale"' } + }); + expect(stale.status()).toBe(200); + expect(await stale.text()).toBe('0123456789'); +}); + +test('does not decode reserved characters in the pathname', async ({ request }) => { + const response = await request.get('/sub%2Findex.html'); + expect(response.status()).toBe(404); +}); From 8f28f3d1ac55149a43838ecdca52797eed93b874 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:49:21 -0400 Subject: [PATCH 04/18] hash files one at a time through a single buffer, simplify aliasing --- packages/adapter-node/index.js | 91 +++++++++++++++----------- packages/kit/src/core/adapt/builder.js | 12 +--- 2 files changed, 55 insertions(+), 48 deletions(-) diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 22b41541e1f1..14ce5c591af4 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -31,11 +31,11 @@ export default function (opts = {}) { const assets = create_asset_table( base, - await measure_files(client_dir, client_files, client_compressed) + measure_files(client_dir, client_files, client_compressed) ); const prerendered_assets = create_prerendered_table( base, - await measure_files(prerendered_dir, prerendered_files, prerendered_compressed), + measure_files(prerendered_dir, prerendered_files, prerendered_compressed), builder.prerendered.paths ); @@ -132,54 +132,71 @@ export default function (opts = {}) { } /** - * Content hash of a file + * Dotfiles are not served, with the customary exception of `.well-known` * @param {string} file */ -async function hash(file) { - const sha = createHash('sha256'); - - for await (const chunk of fs.createReadStream(file)) sha.update(chunk); - - return sha.digest('base64url'); +function is_hidden(file) { + return file.split('/').some((segment) => segment[0] === '.') && !file.startsWith('.well-known/'); } /** - * Dotfiles are not served, with the customary exception of `.well-known` + * Size and content hash from one pass over the file, a buffer at a time * @param {string} file + * @param {Buffer} buffer */ -function is_hidden(file) { - return file.split('/').some((segment) => segment[0] === '.') && !file.startsWith('.well-known/'); +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); + } + + return { size, etag: hash.digest('base64url') }; } /** * Size and content hash of every servable file, plus the sizes of the - * compressed variants where `builder.compress` wrote them + * 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 {Promise} + * @returns {AssetEntry[]} */ function measure_files(root, files, compressed) { const variants = new Set(compressed); + const buffer = Buffer.allocUnsafe(64 * 1024); - return Promise.all( - files - .filter((file) => !is_hidden(file)) - .map(async (file) => { - const abs = join(root, file); + /** @type {AssetEntry[]} */ + const entries = []; + + for (const file of files) { + if (is_hidden(file)) continue; + + const abs = join(root, file); - /** @type {AssetEntry} */ - const entry = { file, size: fs.statSync(abs).size, etag: await hash(abs) }; + /** @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; - } + // `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 entry; - }) - ); + return entries; } /** @@ -213,20 +230,16 @@ function create_asset_table(base, measured) { } } - // `/foo` and `/foo/` resolve to `foo.html`, unless a `foo/index.html` exists, - // in which case `foo.html` wins (matching the resolution order sirv used) + // `/foo` and `/foo/` resolve to `foo.html`, or to `foo/index.html` when only that exists. + // `foo.html` sorts first, so it claims the aliases (the resolution order sirv used) for (const [key, entry] of entries) { if (!entry.file.endsWith('.html')) continue; - if (entry.file === 'index.html' || entry.file.endsWith('/index.html')) continue; - alias(key.slice(0, -5), key); - alias(key.slice(0, -5) + '/', key); - } - for (const [key, entry] of entries) { - if (entry.file !== 'index.html' && !entry.file.endsWith('/index.html')) continue; - const with_slash = key.slice(0, -'index.html'.length); - if (with_slash.length > 1) alias(with_slash.slice(0, -1), key); + const is_index = entry.file === 'index.html' || entry.file.endsWith('/index.html'); + const with_slash = is_index ? key.slice(0, -'index.html'.length) : key.slice(0, -5) + '/'; + alias(with_slash, key); + if (with_slash.length > 1) alias(with_slash.slice(0, -1), key); } return { entries, aliases }; diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index eb61f50ccee2..c042eed8c0f3 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -129,15 +129,9 @@ export function create_builder({ mime_types[ext] ??= mime_lookup(ext) || ''; } - // record extensions that only exist in prerendered output, e.g. a prerendered favicon.ico - for (const pathname of prerendered.paths) { - const ext = path.extname(pathname); - if (ext) mime_types[ext] ??= mime_lookup(ext) || ''; - } - - // record extensions in the client output, so that adapters can serve - // client files without a mime database of their own - for (const file of walk(path.join(build_data.out_dir, 'client'))) { + // record extensions that only exist in prerendered or client output (e.g. a favicon.ico), + // so that adapters can serve those files without a mime database of their own + for (const file of [...prerendered.paths, ...walk(path.join(build_data.out_dir, 'client'))]) { const ext = path.extname(file); if (ext) mime_types[ext] ??= mime_lookup(ext) || ''; } From 6042918e310466e4c21a162643a2247823b092bd Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:49:21 -0400 Subject: [PATCH 05/18] close the file when a download is aborted --- packages/adapter-node/src/static.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/adapter-node/src/static.js b/packages/adapter-node/src/static.js index 103f922cf9e4..c3784122f33c 100644 --- a/packages/adapter-node/src/static.js +++ b/packages/adapter-node/src/static.js @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { pipeline } from 'node:stream'; /** @typedef {AssetEntry & { type?: string }} Asset */ @@ -214,9 +215,8 @@ export function serve_static( return; } - // headers are already sent, so all we can do is drop the connection - fs.createReadStream(file, range) - .on('error', () => res.destroy()) - .pipe(res); + // the headers are already sent, so a failed read can only drop the connection. + // `pipeline` also closes the file when the client goes away mid-transfer + pipeline(fs.createReadStream(file, range), res, () => {}); }; } From 04312b5d41eea3aa1a0bf8a3407e6dae4492ca32 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:42:17 -0400 Subject: [PATCH 06/18] docs: describe how static assets are served --- documentation/docs/25-build-and-deploy/40-adapter-node.md | 4 +++- documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/documentation/docs/25-build-and-deploy/40-adapter-node.md b/documentation/docs/25-build-and-deploy/40-adapter-node.md index c3d2b14fe855..f4fdfcc3c92f 100644 --- a/documentation/docs/25-build-and-deploy/40-adapter-node.md +++ b/documentation/docs/25-build-and-deploy/40-adapter-node.md @@ -36,6 +36,8 @@ node build Development dependencies will be bundled into your app using [Rolldown](https://rolldown.rs/). To control whether a given package is bundled or externalised, place it in `devDependencies` or `dependencies` respectively in your `package.json`. +Client assets and prerendered output are served from a list of files recorded during the build. Only `GET` and `HEAD` requests are served from it; other methods continue to SvelteKit. Every asset carries an ETag computed during the build, so conditional requests revalidate with an empty `304` response. Byte ranges are supported. Files below SvelteKit's `immutable` directory receive `Cache-Control: public,max-age=31536000,immutable`. + ### Compressing responses You will typically want to compress responses coming from the server. If you're already deploying your server behind a reverse proxy for SSL or load balancing, it typically results in better performance to also handle compression at that layer since Node.js is single-threaded. @@ -180,7 +182,7 @@ The directory to build the server to. It defaults to `build` — i.e. `node buil ### precompress -Enables precompressing using gzip and brotli for assets and prerendered pages. It defaults to `true`. +Generates `.br` and `.gz` variants of client and prerendered assets during the build. The server negotiates `Accept-Encoding` per request, preferring brotli over gzip, and each variant carries its own ETag. It defaults to `true`. ### envPrefix 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 f833cf0a8c9c..005cf0a5e633 100644 --- a/documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md +++ b/documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md @@ -499,6 +499,9 @@ await myCache.match(request); - bundling now happens with `rolldown` - the `ORIGIN` environment variable is removed (set `paths.origin` in your Vite config instead) +- static assets are served from a list recorded at build time; files added to the output directory afterwards are not served +- `ETag`s for static assets are content hashes, and `Last-Modified` is no longer sent +- only `GET` and `HEAD` requests are served static assets; other methods reach SvelteKit ### `adapter-netlify` From 03a0a3adda8c40c2d4da0126e0901bb6eb22acee Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:31:54 -0400 Subject: [PATCH 07/18] load the asset tables through JSON.parse --- packages/adapter-node/index.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 14ce5c591af4..e11798f22585 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -69,8 +69,9 @@ export default function (opts = {}) { `export const origin = ${JSON.stringify(builder.config.paths.origin)};`, `export const env_prefix = ${JSON.stringify(envPrefix)};`, `export const mime_types = ${JSON.stringify(builder.mimeTypes)};`, - `export const assets = ${JSON.stringify(assets)};`, - `export const prerendered_assets = ${JSON.stringify(prerendered_assets)};` + // JSON.parse of a string loads about twice as fast as an object literal of the same size + `export const assets = JSON.parse(${JSON.stringify(JSON.stringify(assets))});`, + `export const prerendered_assets = JSON.parse(${JSON.stringify(JSON.stringify(prerendered_assets))});` ].join('\n') ); From 64961cdd3c7543fd98df529dfeb14b23c42ff21c Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:37:17 -0400 Subject: [PATCH 08/18] resolve both asset tables into one lookup at boot --- packages/adapter-node/src/handler.js | 15 ++-- packages/adapter-node/src/static.js | 100 ++++++++++++++++++--------- 2 files changed, 71 insertions(+), 44 deletions(-) diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index 2e2b1bd59b07..16e1160eac06 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -1,4 +1,4 @@ -/** @import { IncomingMessage, ServerResponse, IncomingHttpHeaders } from 'node:http' */ +/** @import { IncomingHttpHeaders } from 'node:http' */ import process from 'node:process'; import { getRequest, setResponse, createReadableStream } from '@sveltejs/kit/node'; import { @@ -14,9 +14,9 @@ import { } from '#@sveltejs/adapter-node'; import { env } from './env.js'; import { parse_as_bytes } from './utils.js'; -import { serve_static } from './static.js'; +import { create_file_map, serve_static } from './static.js'; -/** @typedef {(req: IncomingMessage, res: ServerResponse, next: () => void | Promise) => void | Promise} Middleware */ +/** @import { Middleware } from './static.js' */ const xff_depth = parseInt(env('XFF_DEPTH', '1')); const address_header = env('ADDRESS_HEADER', '').toLowerCase(); @@ -201,13 +201,6 @@ function get_origin(headers) { } export const handler = sequence([ - serve_static(asset_dir, assets, { - mime_types, - immutable_prefix: `/${app_path}/immutable/` - }), - serve_static(`${dir}/prerendered${base}`, prerendered_assets, { - mime_types, - redirect_trailing_slash: true - }), + serve_static(create_file_map({ dir, base, app_path, mime_types, assets, prerendered_assets })), ssr ]); diff --git a/packages/adapter-node/src/static.js b/packages/adapter-node/src/static.js index c3784122f33c..c80b9962c6e9 100644 --- a/packages/adapter-node/src/static.js +++ b/packages/adapter-node/src/static.js @@ -1,8 +1,13 @@ +/** @import { IncomingMessage, ServerResponse } from 'node:http' */ import fs from 'node:fs'; import path from 'node:path'; import { pipeline } from 'node:stream'; -/** @typedef {AssetEntry & { type?: string }} Asset */ +/** + * @typedef {(req: IncomingMessage, res: ServerResponse, next: () => void | Promise) => void | Promise} Middleware + * @typedef {AssetEntry & { type?: string, cache_control?: string }} Asset + * @typedef {Asset | { location: string }} Entry + */ /** * Splits `req.url` into a decoded pathname and the search string. @@ -92,25 +97,13 @@ function etag_matches(header, etag) { } /** - * Serves the closed set of files recorded in the manifest at adapt time. - * Everything about a response is precomputed: exact pathname keys, sizes, - * content-hash ETags, content types and which compressed variants exist, - * so requests are a map lookup and a stream. - * + * Absolute file paths and content types for one table * @param {string} dir * @param {AssetTable} table - * @param {{ - * mime_types: Record, - * immutable_prefix?: string, - * redirect_trailing_slash?: boolean - * }} opts - * @returns {import('./handler.js').Middleware} + * @param {Record} mime_types + * @returns {Map} */ -export function serve_static( - dir, - table, - { mime_types, immutable_prefix, redirect_trailing_slash } -) { +function resolve(dir, table, mime_types) { /** @type {Map} */ const files = new Map(); @@ -124,23 +117,68 @@ export function serve_static( files.set(alias, /** @type {Asset} */ (files.get(key))); } + return files; +} + +/** + * One lookup for every request, decided at boot: client assets (immutable below + * `app_path`), prerendered pages, and a 308 from the non-canonical trailing-slash + * form of a prerendered path to the canonical one. Client assets win a collision + * @param {{ + * dir: string, + * base: string, + * app_path: string, + * mime_types: Record, + * assets: AssetTable, + * prerendered_assets: AssetTable + * }} opts + * @returns {Map} + */ +export function create_file_map({ dir, base, app_path, mime_types, assets, prerendered_assets }) { + /** @type {Map} */ + const files = resolve(`${dir}/client${base}`, assets, mime_types); + + const immutable = `/${app_path}/immutable/`; + for (const [key, asset] of files) { + if (key.startsWith(immutable)) { + /** @type {Asset} */ (asset).cache_control = 'public,max-age=31536000,immutable'; + } + } + + const prerendered = resolve(`${dir}/prerendered${base}`, prerendered_assets, mime_types); + for (const [key, asset] of prerendered) { + if (!files.has(key)) files.set(key, asset); + } + + for (const key of prerendered.keys()) { + const inverted = key.at(-1) === '/' ? key.slice(0, -1) : key + '/'; + if (inverted && !files.has(inverted)) { + files.set(inverted, { location: relative_pathname(inverted, key) }); + } + } + + return files; +} + +/** + * Serves the closed set of files recorded at adapt time. Everything about a + * response is decided before the first request, so a request is one map + * lookup, header negotiation and a stream + * @param {Map} files + * @returns {Middleware} + */ +export function serve_static(files) { return (req, res, next) => { if (req.method !== 'GET' && req.method !== 'HEAD') return next(); const { pathname, search } = split_url(req); const asset = files.get(pathname); - if (!asset) { - if (redirect_trailing_slash) { - // redirect to the canonical path when only the trailing slash differs - const inverted = pathname.at(-1) === '/' ? pathname.slice(0, -1) : pathname + '/'; - if (files.has(inverted)) { - const location = relative_pathname(pathname, inverted) + search; - res.writeHead(308, { location }).end(); - return; - } - } - return next(); + if (!asset) return next(); + + if ('location' in asset) { + res.writeHead(308, { location: asset.location + search }).end(); + return; } let file = asset.file; @@ -158,16 +196,12 @@ export function serve_static( const headers = { etag }; if (asset.br || asset.gz) headers.vary = 'Accept-Encoding'; - - if (immutable_prefix && pathname.startsWith(immutable_prefix)) { - headers['cache-control'] = 'public,max-age=31536000,immutable'; - } + if (asset.cache_control) headers['cache-control'] = asset.cache_control; if (etag_matches(req.headers['if-none-match'], etag)) { res.writeHead(304, headers).end(); return; } - headers['content-length'] = size; headers['accept-ranges'] = 'bytes'; if (asset.type) headers['content-type'] = asset.type; From 4796ebfe43a920448d6d1eab1d6c45287055a005 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:49:21 -0400 Subject: [PATCH 09/18] test static.js on its own, one test per behaviour --- packages/adapter-node/src/static.spec.ts | 241 +++++++++++++++++ .../adapter-node/test/apps/basic/test/test.js | 247 +++--------------- 2 files changed, 278 insertions(+), 210 deletions(-) create mode 100644 packages/adapter-node/src/static.spec.ts diff --git a/packages/adapter-node/src/static.spec.ts b/packages/adapter-node/src/static.spec.ts new file mode 100644 index 000000000000..a6ceaeba6c87 --- /dev/null +++ b/packages/adapter-node/src/static.spec.ts @@ -0,0 +1,241 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { brotliCompressSync, gzipSync } from 'node:zlib'; +import { afterAll, beforeAll, expect, test } from 'vitest'; +import { create_file_map, serve_static } from './static.js'; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-node-static-')); +const client = path.join(dir, 'client'); +const prerendered = path.join(dir, 'prerendered'); + +/** writes a file the way `builder.writeClient` + `builder.compress` would, and records it like `adapt` does */ +function write(root: string, file: string, content: string, compress = false): AssetEntry { + const abs = path.join(root, file); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + + const entry: AssetEntry = { + file, + size: Buffer.byteLength(content), + etag: createHash('sha256').update(content).digest('base64url') + }; + + if (compress) { + const gz = gzipSync(content); + const br = brotliCompressSync(content); + fs.writeFileSync(`${abs}.gz`, gz); + fs.writeFileSync(`${abs}.br`, br); + entry.gz = gz.length; + entry.br = br.length; + } + + return entry; +} + +const range = write(client, 'range.txt', '0123456789', true); +const plain = write(client, 'plain.txt', 'plain'); +const plus = write(client, 'a+b.txt', 'plus'); +const page = write(client, 'page.html', '

page

', true); +const sub = write(client, 'sub/index.html', '

sub

'); +const chunk = write(client, '_app/immutable/chunks/x.js', 'export {}'); +const version = write(client, '_app/version.json', '{}'); +const about = write(prerendered, 'about.html', '

about

'); + +const files = create_file_map({ + dir, + base: '', + app_path: '_app', + mime_types: { + '.txt': 'text/plain', + '.html': 'text/html', + '.js': 'text/javascript', + '.json': 'application/json' + }, + assets: { + entries: [ + ['/range.txt', range], + ['/plain.txt', plain], + ['/a+b.txt', plus], + ['/page.html', page], + ['/sub/index.html', sub], + ['/_app/immutable/chunks/x.js', chunk], + ['/_app/version.json', version] + ], + aliases: [ + ['/page', '/page.html'], + ['/sub/', '/sub/index.html'] + ] + }, + prerendered_assets: { entries: [['/about', about]], aliases: [] } +}); + +const middleware = serve_static(files); +const server = http.createServer((req, res) => + middleware(req, res, () => { + res.statusCode = 404; + res.end('next'); + }) +); + +let origin: string; + +beforeAll(async () => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address() as { port: number }; + origin = `http://127.0.0.1:${address.port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +function get(pathname: string, { method = 'GET', headers = {} as Record } = {}) { + return new Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }>( + (resolve, reject) => { + http + .request(origin + pathname, { method, headers }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString() + }) + ); + }) + .on('error', reject) + .end(); + } + ); +} + +test('serves a recorded file with its precomputed headers', async () => { + const response = await get('/plain.txt'); + expect(response.status).toBe(200); + expect(response.body).toBe('plain'); + expect(response.headers['content-type']).toBe('text/plain'); + expect(response.headers['content-length']).toBe('5'); + expect(response.headers['accept-ranges']).toBe('bytes'); + expect(response.headers['etag']).toBe(`"${plain.etag}"`); + expect(response.headers['vary']).toBeUndefined(); + + const head = await get('/plain.txt', { method: 'HEAD' }); + expect(head.headers['content-length']).toBe('5'); + expect(head.body).toBe(''); +}); + +test('passes unknown paths and non-GET methods to the next handler', async () => { + expect((await get('/missing.txt')).status).toBe(404); + + const post = await get('/plain.txt', { method: 'POST' }); + expect(post.status).toBe(404); + expect(post.headers['etag']).toBeUndefined(); +}); + +test('decodes percent-encoding but not reserved characters or +', async () => { + // https://github.com/sveltejs/kit/issues/11766 + expect((await get('/pl%61in.txt')).body).toBe('plain'); + expect((await get('/a+b.txt')).body).toBe('plus'); + expect((await get('/a%2Bb.txt')).status).toBe(404); + expect((await get('/sub%2Findex.html')).status).toBe(404); +}); + +test('resolves aliases to their html file', async () => { + const page = await get('/page'); + expect(page.body).toBe('

page

'); + expect(page.headers['content-type']).toBe('text/html;charset=utf-8'); + expect((await get('/sub/')).body).toBe('

sub

'); +}); + +test('marks assets below the immutable directory only', async () => { + const immutable = 'public,max-age=31536000,immutable'; + expect((await get('/_app/immutable/chunks/x.js')).headers['cache-control']).toBe(immutable); + expect((await get('/_app/version.json')).headers['cache-control']).toBeUndefined(); +}); + +test('serves prerendered pages and redirects their non-canonical trailing-slash form', async () => { + expect((await get('/about')).body).toBe('

about

'); + + const redirect = await get('/about/?x=1'); + expect(redirect.status).toBe(308); + expect(redirect.headers['location']).toBe('../about?x=1'); +}); + +test('answers a matching if-none-match with a 304 that carries the validators', async () => { + const etag = `"${range.etag}"`; + + for (const header of [etag, `"stale", ${etag}`, `W/${etag}`, '*']) { + const cached = await get('/range.txt', { headers: { 'if-none-match': header } }); + expect(cached.status, header).toBe(304); + expect(cached.body).toBe(''); + expect(cached.headers['etag']).toBe(etag); + expect(cached.headers['vary']).toBe('Accept-Encoding'); + } + + expect((await get('/range.txt', { headers: { 'if-none-match': '"stale"' } })).status).toBe(200); +}); + +test('serves byte ranges', async () => { + const single = await get('/range.txt', { headers: { range: 'bytes=0-0' } }); + expect(single.status).toBe(206); + expect(single.headers['content-range']).toBe('bytes 0-0/10'); + expect(single.headers['content-length']).toBe('1'); + expect(single.body).toBe('0'); + + const suffix = await get('/range.txt', { headers: { range: 'bytes=-3' } }); + expect(suffix.headers['content-range']).toBe('bytes 7-9/10'); + expect(suffix.body).toBe('789'); + + const open = await get('/range.txt', { headers: { range: 'bytes=4-' } }); + expect(open.body).toBe('456789'); + + const clamped = await get('/range.txt', { headers: { range: 'bytes=8-100' } }); + expect(clamped.headers['content-range']).toBe('bytes 8-9/10'); + + const unsatisfiable = await get('/range.txt', { headers: { range: 'bytes=10-' } }); + expect(unsatisfiable.status).toBe(416); + expect(unsatisfiable.headers['content-range']).toBe('bytes */10'); + + // a stale `If-Range` validator gets the whole current representation + const etag = `"${range.etag}"`; + const fresh = await get('/range.txt', { headers: { range: 'bytes=0-1', 'if-range': etag } }); + expect(fresh.status).toBe(206); + const stale = await get('/range.txt', { headers: { range: 'bytes=0-1', 'if-range': '"stale"' } }); + expect(stale.status).toBe(200); + expect(stale.body).toBe('0123456789'); +}); + +test('negotiates the compressed variant', async () => { + const gzip = await get('/range.txt', { headers: { 'accept-encoding': 'gzip' } }); + expect(gzip.headers['content-encoding']).toBe('gzip'); + expect(gzip.headers['content-length']).toBe(String(range.gz)); + expect(gzip.headers['vary']).toBe('Accept-Encoding'); + + const preferred = await get('/range.txt', { + headers: { 'accept-encoding': 'gzip;q=1, br;q=0.5' } + }); + expect(preferred.headers['content-encoding']).toBe('gzip'); + + const upper = await get('/range.txt', { headers: { 'accept-encoding': 'BR' } }); + expect(upper.headers['content-encoding']).toBe('br'); + + const wildcard = await get('/range.txt', { headers: { 'accept-encoding': '*' } }); + expect(wildcard.headers['content-encoding']).toBe('br'); + + const rejected = await get('/range.txt', { headers: { 'accept-encoding': 'br;q=0, gzip;q=0' } }); + expect(rejected.headers['content-encoding']).toBeUndefined(); + expect(rejected.body).toBe('0123456789'); + + // each representation has its own validator and its own range base + const br = await get('/range.txt', { headers: { 'accept-encoding': 'br' } }); + expect(br.headers['etag']).toBe(`"${range.etag}.br"`); + const partial = await get('/range.txt', { + headers: { 'accept-encoding': 'br', range: 'bytes=0-0' } + }); + expect(partial.headers['content-range']).toBe(`bytes 0-0/${range.br}`); +}); diff --git a/packages/adapter-node/test/apps/basic/test/test.js b/packages/adapter-node/test/apps/basic/test/test.js index 8d3e6ea50ece..275dcc266e8c 100644 --- a/packages/adapter-node/test/apps/basic/test/test.js +++ b/packages/adapter-node/test/apps/basic/test/test.js @@ -44,53 +44,41 @@ test('preserves similar user identifiers and imports', async ({ request }) => { }); }); -test('sets Vary on assets that were precompressed', async ({ request }) => { - const response = await request.get('/data.json'); - expect(response.status()).toBe(200); - expect(response.headers()['vary']).toBe('Accept-Encoding'); -}); - -test('does not set Vary on assets that were not precompressed', async ({ request }) => { - const response = await request.get('/test.ico'); - expect(response.status()).toBe(200); - expect(response.headers()['vary']).toBeUndefined(); +test('records which assets have compressed variants', async ({ request }) => { + expect((await request.get('/data.json')).headers()['vary']).toBe('Accept-Encoding'); + expect((await request.get('/test.ico')).headers()['vary']).toBeUndefined(); }); -// an extensionless pathname can still resolve to a precompressed `index.html` -test('sets Vary on assets reached without an extension', async ({ request }) => { - const response = await request.get('/sub/'); - expect(response.status()).toBe(200); - expect(response.headers()['content-type']).toBe('text/html;charset=utf-8'); - expect(response.headers()['vary']).toBe('Accept-Encoding'); -}); +test('records the size, content hash and compressed variants of each file', async ({ request }) => { + expect(await (await request.get('/a+b.txt')).text()).toBe('plus'); -// a dot in the final path segment looks like an extension but isn't one -test('sets Vary on assets reached via a dotted path segment', async ({ request }) => { - const response = await request.get('/v1.0/'); - expect(response.status()).toBe(200); - expect(response.headers()['content-type']).toBe('text/html;charset=utf-8'); - expect(response.headers()['vary']).toBe('Accept-Encoding'); -}); + const identity = await request.get('/range.txt', { headers: { 'accept-encoding': 'identity' } }); + expect(identity.headers()['content-length']).toBe('10'); + const etag = identity.headers()['etag']; + expect(etag).toBeTruthy(); + const cached = await request.get('/range.txt', { + headers: { 'accept-encoding': 'identity', 'if-none-match': etag } + }); + expect(cached.status()).toBe(304); -test('serves static files with the Content-Type from the manifest', async ({ request }) => { - // https://github.com/sveltejs/kit/issues/13753 - const response = await request.get('/test.ico'); - expect(response.status()).toBe(200); - expect(response.headers()['content-type']).toBe('image/x-icon'); + const gzip = await request.get('/range.txt', { headers: { 'accept-encoding': 'gzip' } }); + expect(gzip.headers()['content-encoding']).toBe('gzip'); + expect(await gzip.text()).toBe('0123456789'); + expect(gzip.headers()['etag']).not.toBe(etag); }); -test('serves prerendered endpoints with the Content-Type from the manifest', async ({ - request -}) => { - const response = await request.get('/prerendered.ico'); - expect(response.status()).toBe(200); - expect(response.headers()['content-type']).toBe('image/x-icon'); +test('records aliases for html files', async ({ request }) => { + for (const path of ['/page', '/sub/']) { + const response = await request.get(path); + expect(response.status(), path).toBe(200); + expect(response.headers()['content-type']).toBe('text/html;charset=utf-8'); + } }); -test('serves static HTML with a charset', async ({ request }) => { - const response = await request.get('/page.html'); - expect(response.status()).toBe(200); - expect(response.headers()['content-type']).toBe('text/html;charset=utf-8'); +test('uses the content types from the manifest', async ({ request }) => { + // https://github.com/sveltejs/kit/issues/13753 + expect((await request.get('/test.ico')).headers()['content-type']).toBe('image/x-icon'); + expect((await request.get('/prerendered.ico')).headers()['content-type']).toBe('image/x-icon'); }); test('does not replace adapter stubs in application chunks', async ({ request }) => { @@ -98,94 +86,19 @@ test('does not replace adapter stubs in application chunks', async ({ request }) expect(await response.text()).toBe('__SVELTEKIT_ADAPTER_NODE_MIMETYPES__'); }); -test('serves files with a + in the name', async ({ request }) => { - // https://github.com/sveltejs/kit/issues/11766 - const response = await request.get('/a+b.txt'); - expect(response.status()).toBe(200); - expect(await response.text()).toBe('plus'); +test('does not record dotfiles, except .well-known', async ({ request }) => { + expect((await request.get('/.hidden')).status()).toBe(404); + expect(await (await request.get('/.well-known/thing.txt')).text()).toBe('wk'); }); -test('does not serve dotfiles', async ({ request }) => { - const response = await request.get('/.hidden'); - expect(response.status()).toBe(404); -}); +test('serves prerendered pages and redirects to their canonical path', async ({ request }) => { + const page = await request.get('/prerendered-page'); + expect(page.headers()['content-type']).toBe('text/html;charset=utf-8'); + expect(await page.text()).toContain('prerendered'); -test('serves .well-known', async ({ request }) => { - const response = await request.get('/.well-known/thing.txt'); - expect(response.status()).toBe(200); - expect(await response.text()).toBe('wk'); -}); - -test('serves a content-hash ETag and honours if-none-match', async ({ request }) => { - const response = await request.get('/range.txt'); - const etag = response.headers()['etag']; - expect(etag).toBeTruthy(); - - const cached = await request.get('/range.txt', { headers: { 'if-none-match': etag } }); - expect(cached.status()).toBe(304); -}); - -test('responds to HEAD without a body', async ({ request }) => { - const response = await request.head('/range.txt', { - headers: { 'accept-encoding': 'identity' } - }); - expect(response.status()).toBe(200); - expect(response.headers()['content-length']).toBe('10'); - expect(await response.text()).toBe(''); -}); - -test('serves a single-byte range', async ({ request }) => { - // the probe HTML5 video and PDF.js use to detect range support - const response = await request.get('/range.txt', { - headers: { 'accept-encoding': 'identity', range: 'bytes=0-0' } - }); - expect(response.status()).toBe(206); - expect(response.headers()['content-range']).toBe('bytes 0-0/10'); - expect(await response.text()).toBe('0'); -}); - -test('serves a suffix range', async ({ request }) => { - const response = await request.get('/range.txt', { - headers: { 'accept-encoding': 'identity', range: 'bytes=-3' } - }); - expect(response.status()).toBe(206); - expect(response.headers()['content-range']).toBe('bytes 7-9/10'); - expect(await response.text()).toBe('789'); -}); - -test('serves an open-ended range', async ({ request }) => { - const response = await request.get('/range.txt', { - headers: { 'accept-encoding': 'identity', range: 'bytes=4-' } - }); - expect(response.status()).toBe(206); - expect(await response.text()).toBe('456789'); -}); - -test('rejects an unsatisfiable range', async ({ request }) => { - const response = await request.get('/range.txt', { - headers: { 'accept-encoding': 'identity', range: 'bytes=10-' } - }); - expect(response.status()).toBe(416); - expect(response.headers()['content-range']).toBe('bytes */10'); -}); - -test('serves a prerendered page', async ({ request }) => { - const response = await request.get('/prerendered-page'); - expect(response.status()).toBe(200); - expect(response.headers()['content-type']).toBe('text/html;charset=utf-8'); - expect(await response.text()).toContain('prerendered'); -}); - -test('redirects to the canonical prerendered path', async ({ request }) => { - const response = await request.get('/prerendered-page/', { maxRedirects: 0 }); - expect(response.status()).toBe(308); - expect(response.headers()['location']).toBe('../prerendered-page'); -}); - -test('resolves an extensionless path to the matching .html file', async ({ request }) => { - const response = await request.get('/page'); - expect(response.status()).toBe(200); - expect(response.headers()['content-type']).toBe('text/html;charset=utf-8'); + const redirect = await request.get('/prerendered-page/', { maxRedirects: 0 }); + expect(redirect.status()).toBe(308); + expect(redirect.headers()['location']).toBe('../prerendered-page'); }); test('serves immutable assets with an immutable cache header', async ({ request }) => { @@ -196,89 +109,3 @@ test('serves immutable assets with an immutable cache header', async ({ request expect(response.status()).toBe(200); expect(response.headers()['cache-control']).toBe('public,max-age=31536000,immutable'); }); - -test('does not serve version.json with an immutable cache header', async ({ request }) => { - const response = await request.get('/_app/version.json'); - expect(response.status()).toBe(200); - expect(response.headers()['cache-control']).toBeUndefined(); -}); - -test('serves the gzip variant when brotli is not accepted', async ({ request }) => { - const response = await request.get('/range.txt', { headers: { 'accept-encoding': 'gzip' } }); - expect(response.status()).toBe(200); - expect(response.headers()['content-encoding']).toBe('gzip'); - expect(response.headers()['vary']).toBe('Accept-Encoding'); -}); - -test('serves the variant the client prefers', async ({ request }) => { - const response = await request.get('/range.txt', { - headers: { 'accept-encoding': 'gzip;q=1, br;q=0.5' } - }); - expect(response.headers()['content-encoding']).toBe('gzip'); -}); - -test('does not serve an encoding the client rejects', async ({ request }) => { - const response = await request.get('/range.txt', { - headers: { 'accept-encoding': 'br;q=0, gzip;q=0' } - }); - expect(response.headers()['content-encoding']).toBeUndefined(); - expect(await response.text()).toBe('0123456789'); -}); - -test('treats content codings as case-insensitive', async ({ request }) => { - const response = await request.get('/range.txt', { headers: { 'accept-encoding': 'BR' } }); - expect(response.headers()['content-encoding']).toBe('br'); -}); - -test('gives each variant its own ETag', async ({ request }) => { - const identity = await request.get('/range.txt', { headers: { 'accept-encoding': 'identity' } }); - const br = await request.get('/range.txt', { headers: { 'accept-encoding': 'br' } }); - expect(br.headers()['etag']).not.toBe(identity.headers()['etag']); -}); - -test('only serves static files to GET and HEAD', async ({ request }) => { - const response = await request.post('/range.txt'); - expect(response.status()).not.toBe(200); - expect(response.headers()['etag']).toBeUndefined(); -}); - -test('sends the validator headers with a 304', async ({ request }) => { - const response = await request.get('/range.txt'); - const etag = response.headers()['etag']; - - const cached = await request.get('/range.txt', { headers: { 'if-none-match': etag } }); - expect(cached.status()).toBe(304); - expect(cached.headers()['etag']).toBe(etag); - expect(cached.headers()['vary']).toBe('Accept-Encoding'); -}); - -test('matches if-none-match lists, weak tags and wildcards', async ({ request }) => { - const etag = (await request.get('/range.txt')).headers()['etag']; - - for (const header of [`"stale", ${etag}`, `W/${etag}`, '*']) { - const cached = await request.get('/range.txt', { headers: { 'if-none-match': header } }); - expect(cached.status(), header).toBe(304); - } -}); - -test('serves the whole file when if-range does not match', async ({ request }) => { - const etag = ( - await request.get('/range.txt', { headers: { 'accept-encoding': 'identity' } }) - ).headers()['etag']; - - const fresh = await request.get('/range.txt', { - headers: { 'accept-encoding': 'identity', range: 'bytes=0-1', 'if-range': etag } - }); - expect(fresh.status()).toBe(206); - - const stale = await request.get('/range.txt', { - headers: { 'accept-encoding': 'identity', range: 'bytes=0-1', 'if-range': '"stale"' } - }); - expect(stale.status()).toBe(200); - expect(await stale.text()).toBe('0123456789'); -}); - -test('does not decode reserved characters in the pathname', async ({ request }) => { - const response = await request.get('/sub%2Findex.html'); - expect(response.status()).toBe(404); -}); From 3b68018106fb1c490aca797bb38761727779a5d9 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:57:36 -0400 Subject: [PATCH 10/18] docs: say what happens to files replaced after the build --- documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 005cf0a5e633..64f972110e02 100644 --- a/documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md +++ b/documentation/docs/60-appendix/35-migrating-to-sveltekit-3.md @@ -499,7 +499,7 @@ await myCache.match(request); - bundling now happens with `rolldown` - the `ORIGIN` environment variable is removed (set `paths.origin` in your Vite config instead) -- static assets are served from a list recorded at build time; files added to the output directory afterwards are not served +- static assets are served from a list recorded at build time; files added to the output directory afterwards are not served, and replaced ones keep their old size and `ETag` (use environment variables for runtime configuration) - `ETag`s for static assets are content hashes, and `Last-Modified` is no longer sent - only `GET` and `HEAD` requests are served static assets; other methods reach SvelteKit From 00f04f138e668b71bf964ef6398a4eaf3c681f60 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:20:09 -0400 Subject: [PATCH 11/18] bundle the adapter's server source directly instead of prebuilding it with rolldown --- .changeset/adapter-node-no-prebuild.md | 5 ++ .github/workflows/platform-tests-node.yml | 4 -- eslint.config.js | 1 - packages/adapter-node/.gitignore | 1 - packages/adapter-node/index.js | 10 ++-- packages/adapter-node/package.json | 10 ++-- packages/adapter-node/rolldown.config.js | 53 ------------------- .../apps/basic/src/routes/stub/+server.js | 5 -- .../adapter-node/test/apps/basic/test/test.js | 5 -- packages/adapter-node/tsconfig.json | 1 - pnpm-lock.yaml | 6 --- pnpm-workspace.yaml | 1 - 12 files changed, 13 insertions(+), 89 deletions(-) create mode 100644 .changeset/adapter-node-no-prebuild.md delete mode 100644 packages/adapter-node/rolldown.config.js delete mode 100644 packages/adapter-node/test/apps/basic/src/routes/stub/+server.js diff --git a/.changeset/adapter-node-no-prebuild.md b/.changeset/adapter-node-no-prebuild.md new file mode 100644 index 000000000000..8be5832560c4 --- /dev/null +++ b/.changeset/adapter-node-no-prebuild.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-node': patch +--- + +chore: bundle the adapter's server source directly instead of prebuilding it with rolldown diff --git a/.github/workflows/platform-tests-node.yml b/.github/workflows/platform-tests-node.yml index 253b4f0788b8..8f6183ddf95c 100644 --- a/.github/workflows/platform-tests-node.yml +++ b/.github/workflows/platform-tests-node.yml @@ -47,10 +47,6 @@ jobs: with: node-version: ${{ matrix.node-version }} - - name: Build adapter-node files - working-directory: packages/adapter-node - run: pnpm prepublishOnly - - uses: ./.github/actions/node-test with: test-app-dir: packages/adapter-node/test/apps/basic diff --git a/eslint.config.js b/eslint.config.js index 0e497dfe33c3..5b806eb92a2f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -93,7 +93,6 @@ export default [ '**/test-results', '**/dist', '**/.custom-out-dir', - 'packages/adapter-node/files', 'packages/kit/src/core/config/fixtures/multiple', // dir contains svelte config with multiple extensions tripping eslint 'packages/kit/src/core/sync/create_manifest_data/test/samples/**/*', 'packages/kit/src/core/sync/write_types/test/*/**/*', diff --git a/packages/adapter-node/.gitignore b/packages/adapter-node/.gitignore index 22f507db66b4..a96ff58a5388 100644 --- a/packages/adapter-node/.gitignore +++ b/packages/adapter-node/.gitignore @@ -1,4 +1,3 @@ .DS_Store node_modules -/files coverage/ diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index e11798f22585..2ecfc652829d 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; // posix so it matches the module ids Vite reports on every platform -const files = fileURLToPath(new URL('./files', import.meta.url).href).replaceAll('\\', '/'); +const src = fileURLToPath(new URL('./src', import.meta.url).href).replaceAll('\\', '/'); const handoff = '#@sveltejs/adapter-node'; /** @type {typeof import('./index.js').default} */ @@ -100,9 +100,9 @@ export default function (opts = {}) { rolldownOptions: { // bundled with the app's server code so shared modules aren't duplicated (#15755) input: { - 'adapter-index': `${files}/index.js`, - 'adapter-env': `${files}/adapter-env.js`, - handler: `${files}/handler.js` + 'adapter-index': `${src}/index.js`, + 'adapter-env': `${src}/env.js`, + handler: `${src}/handler.js` }, // only production dependencies (and their deep imports) stay external external: [ @@ -115,7 +115,7 @@ export default function (opts = {}) { paths: { [handoff]: '../adapter-node.js' }, // the hand-off path only holds at the output root, so adapter chunks may not nest chunkFileNames: (chunk) => - chunk.moduleIds.some((id) => id.startsWith(files)) + chunk.moduleIds.some((id) => id.startsWith(src)) ? 'adapter-node-[name].js' : 'chunks/[name].js' } diff --git a/packages/adapter-node/package.json b/packages/adapter-node/package.json index 3b1a4f5c7239..600eb2f9a093 100644 --- a/packages/adapter-node/package.json +++ b/packages/adapter-node/package.json @@ -27,26 +27,22 @@ }, "types": "index.d.ts", "files": [ - "files", + "src/*.js", "index.js", "index.d.ts", "ambient.d.ts" ], "scripts": { - "dev": "rolldown -cw", - "build": "rolldown -c", "test": "pnpm test:unit && pnpm test:build", "test:unit": "vitest run", - "test:build": "pnpm build && pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test:build", + "test:build": "pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test:build", "check": "tsc", "lint": "oxfmt --check .", - "format": "oxfmt --write .", - "prepublishOnly": "pnpm build" + "format": "oxfmt --write ." }, "devDependencies": { "@sveltejs/kit": "workspace:^", "@types/node": "catalog:", - "rolldown": "catalog:", "typescript": "catalog:typescript-native", "vitest": "catalog:" }, diff --git a/packages/adapter-node/rolldown.config.js b/packages/adapter-node/rolldown.config.js deleted file mode 100644 index 6d07443fe873..000000000000 --- a/packages/adapter-node/rolldown.config.js +++ /dev/null @@ -1,53 +0,0 @@ -/** @import { Plugin, RolldownOptions } from 'rolldown' */ -import { builtinModules } from 'node:module'; -import { rmSync } from 'node:fs'; - -/** - * @param {string} filepath - * @returns {Plugin} - */ -function clearOutput(filepath) { - return { - name: 'clear-output', - buildStart: { - order: 'pre', - sequential: true, - handler() { - rmSync(filepath, { recursive: true, force: true }); - } - } - }; -} - -/** - * @returns {Plugin} - */ -function prefixBuiltinModules() { - return { - name: 'prefix-built-in-modules', - resolveId(source) { - if (builtinModules.includes(source)) { - return { id: 'node:' + source, external: true }; - } - } - }; -} - -/** @type {RolldownOptions} */ -export default { - input: { - index: 'src/index.js', - handler: 'src/handler.js', - 'adapter-env': 'src/env.js' - }, - output: { - dir: 'files', - format: 'esm', - hoistTransitiveImports: false, - chunkFileNames: 'chunks/[name].js' - }, - plugins: [clearOutput('files'), prefixBuiltinModules()], - // resolved by the app's build; `@sveltejs/kit/node` stays external so it isn't duplicated (#15755) - external: ['#@sveltejs/adapter-node', '@sveltejs/kit/node'], - platform: 'node' -}; diff --git a/packages/adapter-node/test/apps/basic/src/routes/stub/+server.js b/packages/adapter-node/test/apps/basic/src/routes/stub/+server.js deleted file mode 100644 index cc73dcaa34aa..000000000000 --- a/packages/adapter-node/test/apps/basic/src/routes/stub/+server.js +++ /dev/null @@ -1,5 +0,0 @@ -const stub = '__SVELTEKIT_ADAPTER_NODE_MIMETYPES__'; - -export function GET() { - return new Response(stub); -} diff --git a/packages/adapter-node/test/apps/basic/test/test.js b/packages/adapter-node/test/apps/basic/test/test.js index 275dcc266e8c..05d0b88ffda8 100644 --- a/packages/adapter-node/test/apps/basic/test/test.js +++ b/packages/adapter-node/test/apps/basic/test/test.js @@ -81,11 +81,6 @@ test('uses the content types from the manifest', async ({ request }) => { expect((await request.get('/prerendered.ico')).headers()['content-type']).toBe('image/x-icon'); }); -test('does not replace adapter stubs in application chunks', async ({ request }) => { - const response = await request.get('/stub'); - expect(await response.text()).toBe('__SVELTEKIT_ADAPTER_NODE_MIMETYPES__'); -}); - test('does not record dotfiles, except .well-known', async ({ request }) => { expect((await request.get('/.hidden')).status()).toBe(404); expect(await (await request.get('/.well-known/thing.txt')).text()).toBe('wk'); diff --git a/packages/adapter-node/tsconfig.json b/packages/adapter-node/tsconfig.json index 8a885a923b9d..b5268c19ec06 100644 --- a/packages/adapter-node/tsconfig.json +++ b/packages/adapter-node/tsconfig.json @@ -13,7 +13,6 @@ }, "include": [ "index.js", - "rolldown.config.js", "vitest.config.js", "src/**/*.js", "src/**/*.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45761cee8074..18e2776a248e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,9 +167,6 @@ catalogs: prettier-plugin-svelte: specifier: ^4.1.1 version: 4.1.1 - rolldown: - specifier: ^1.2.3 - version: 1.2.3 sirv-cli: specifier: ^3.0.1 version: 3.0.1 @@ -458,9 +455,6 @@ importers: '@types/node': specifier: 'catalog:' version: 22.19.19 - rolldown: - specifier: 'catalog:' - version: 1.2.3 typescript: specifier: catalog:typescript-native version: 7.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ee7d79fd3eae..0bb75e530186 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -65,7 +65,6 @@ catalog: eslint: ^10.8.1 prettier: ^3.9.6 prettier-plugin-svelte: ^4.1.1 - rolldown: ^1.2.3 semver: ^7.5.4 sirv-cli: ^3.0.1 svelte: ^5.57.0 From bf4a3c2a106fd4d908a184bb6a7995cf42056e83 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:12:41 -0400 Subject: [PATCH 12/18] drop the root build script, adapter-node was the only package with one --- AGENTS.md | 3 --- package.json | 1 - 2 files changed, 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 22277391f579..44f67df61f7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,9 +12,6 @@ This guide is for AI coding agents working in the SvelteKit monorepo. # Initial setup (takes 3-4 minutes, set 10+ min timeout) pnpm install --frozen-lockfile -# Build all packages (~1-2 seconds) -pnpm build - # Format code (~15 seconds) pnpm run format diff --git a/package.json b/package.json index 1a75f5e79598..c676dfdf867f 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "precommit": "pnpm format && pnpm lint", "changeset:version": "changeset version && pnpm -r generate:version && git add --all", "changeset:release": "changeset publish", - "build": "pnpm --filter '@sveltejs/*' -r build", "sync-all": "node scripts/sync-all.js" }, "devDependencies": { From 56644f1ed2a8234399efa826c7a15a3dee2d8400 Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:14:24 -0400 Subject: [PATCH 13/18] drop the exclusions for a smoke spec deleted in #16907 --- eslint.config.js | 3 +-- packages/adapter-node/tsconfig.json | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 5b806eb92a2f..09556c422cc7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -105,8 +105,7 @@ export default [ 'packages/package/test/errors/**/*', 'packages/package/test/fixtures/**/*', 'packages/package/test/watch/expected/**/*', - 'packages/package/test/watch/package/**/*', - 'packages/adapter-node/smoke.spec_disabled.js' + 'packages/package/test/watch/package/**/*' ] }, { diff --git a/packages/adapter-node/tsconfig.json b/packages/adapter-node/tsconfig.json index b5268c19ec06..7d101719d1af 100644 --- a/packages/adapter-node/tsconfig.json +++ b/packages/adapter-node/tsconfig.json @@ -20,6 +20,5 @@ "tests/**/*.js", "tests/**/*.ts", "./internal.d.ts" - ], - "exclude": ["smoke.spec_disabled.js"] + ] } From e2aa05559c61527566e173e59fd9e521f2620d34 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 14/18] 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 | 125 ++++++------------ 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, 315 insertions(+), 185 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 64f972110e02..066c978df6a4 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 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 2ecfc652829d..8a8e26a6bec7 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,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 ); const server = builder.getServerDirectory(); @@ -141,77 +135,47 @@ 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[]} + * @param {Array<{ file: string, gz: number, br: number }>} compressed */ -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(compressed) { + return new Map(compressed.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)); @@ -247,26 +211,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 c042eed8c0f3..b97c2edc3064 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); @@ -145,16 +152,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) { @@ -363,7 +377,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) { @@ -378,6 +414,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 47874437d6d8..fae963267bea 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'; @@ -19,6 +20,15 @@ import { stringify_remote_arg } from '../../runtime/shared.js'; import { matches_content_type } from '../../utils/http.js'; import { fix_stack_trace } from '../../runtime/server/sourcemaps.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 @@ -171,7 +181,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 }; } @@ -562,22 +572,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); @@ -611,14 +622,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 cea97c90ac5b..7da7dd0fe26d 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 bd8cbe7f27b73d716036bc163e0cf32055e6c6cf Mon Sep 17 00:00:00 2001 From: Nic Polumeyv <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:37:08 -0400 Subject: [PATCH 15/18] drop the identifier guard, nothing rewrites chunks any more --- .../src/routes/adapter-identifiers/+server.js | 11 ----------- packages/adapter-node/test/apps/basic/test/test.js | 11 ----------- .../adapter-node/test/apps/basic/vite.config.js | 14 +------------- 3 files changed, 1 insertion(+), 35 deletions(-) delete mode 100644 packages/adapter-node/test/apps/basic/src/routes/adapter-identifiers/+server.js diff --git a/packages/adapter-node/test/apps/basic/src/routes/adapter-identifiers/+server.js b/packages/adapter-node/test/apps/basic/src/routes/adapter-identifiers/+server.js deleted file mode 100644 index de68a235327a..000000000000 --- a/packages/adapter-node/test/apps/basic/src/routes/adapter-identifiers/+server.js +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-expect-error resolved by the Vite plugin in the test fixture -import { value as SERVER } from 'SERVER'; - -const BASE_PATH = 'user-base-path'; -const APP_PATH = 'user-app-path'; -const ENV_PREFIX = 'user-env-prefix'; -const PRECOMPRESS = 'user-precompress'; - -export function GET() { - return Response.json({ BASE_PATH, APP_PATH, ENV_PREFIX, PRECOMPRESS, SERVER }); -} diff --git a/packages/adapter-node/test/apps/basic/test/test.js b/packages/adapter-node/test/apps/basic/test/test.js index 05d0b88ffda8..ab498a883b7e 100644 --- a/packages/adapter-node/test/apps/basic/test/test.js +++ b/packages/adapter-node/test/apps/basic/test/test.js @@ -33,17 +33,6 @@ test('initializes dynamic env before instrumentation', async ({ request }) => { expect(await response.json()).toEqual({ value: 'available' }); }); -test('preserves similar user identifiers and imports', async ({ request }) => { - const response = await request.get('/adapter-identifiers'); - expect(await response.json()).toEqual({ - BASE_PATH: 'user-base-path', - APP_PATH: 'user-app-path', - ENV_PREFIX: 'user-env-prefix', - PRECOMPRESS: 'user-precompress', - SERVER: 'user-server' - }); -}); - test('records which assets have compressed variants', async ({ request }) => { expect((await request.get('/data.json')).headers()['vary']).toBe('Accept-Encoding'); expect((await request.get('/test.ico')).headers()['vary']).toBeUndefined(); diff --git a/packages/adapter-node/test/apps/basic/vite.config.js b/packages/adapter-node/test/apps/basic/vite.config.js index 3c5c1848a5ac..71f440a54514 100644 --- a/packages/adapter-node/test/apps/basic/vite.config.js +++ b/packages/adapter-node/test/apps/basic/vite.config.js @@ -11,19 +11,7 @@ const config = { adapter: adapter({ envPrefix: 'MY_CUSTOM_' }) - }), - { - name: 'test-user-server-module', - enforce: 'post', - resolveId(id) { - if (id === 'SERVER') return '\0test-user-server-module'; - }, - load(id) { - if (id === '\0test-user-server-module') { - return `export const value = 'user-server'`; - } - } - } + }) ] }; From c05bb5e0b82115d540263215fb5a58bdb6a174c0 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 16/18] 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 8a8e26a6bec7..cd77bb192de4 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -30,6 +30,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 ); @@ -152,10 +153,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])); } /** @@ -215,18 +217,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 b97c2edc3064..6577215f2126 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); @@ -384,6 +391,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 fae963267bea..be56a529200c 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'; @@ -20,15 +19,6 @@ import { stringify_remote_arg } from '../../runtime/shared.js'; import { matches_content_type } from '../../utils/http.js'; import { fix_stack_trace } from '../../runtime/server/sourcemaps.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 @@ -181,7 +171,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 }; } @@ -587,8 +577,7 @@ async function prerender({ prerendered.redirects.set(decoded, { status: response.status, location: resolved, - file, - ...measure(stub) + file }); prerendered.paths.push(decoded); @@ -622,12 +611,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 7da7dd0fe26d..951d91d1e6e4 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 e39f45ea3e215b5b9494b78398f58bbabfea7d75 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 15 Sep 2026 10:25:05 -0400 Subject: [PATCH 17/18] keep platform tests green --- packages/adapter-node/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/adapter-node/package.json b/packages/adapter-node/package.json index 600eb2f9a093..38395ac9916b 100644 --- a/packages/adapter-node/package.json +++ b/packages/adapter-node/package.json @@ -33,6 +33,7 @@ "ambient.d.ts" ], "scripts": { + "prepublishOnly": "echo TODO: remove this script once .github/workflows/platform-tests-node.yml no longer invokes prepublishOnly, after version-3 is merged into main", "test": "pnpm test:unit && pnpm test:build", "test:unit": "vitest run", "test:build": "pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test:build", From 19fb3afb48eb9116ff0af2ca262c64fe8a8cdc35 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 18/18] 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 6577215f2126..b8c68932fb14 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() { @@ -169,8 +172,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]); } }) ); @@ -288,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) { @@ -388,13 +386,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({ @@ -409,10 +407,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 }), @@ -420,11 +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 { gz: gz.length, br: br.length }; + return { file, gz: gz.length, br: br.length }; } /**