Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
217b76a
serve static assets from the build manifest
Nic-Polumeyv Sep 14, 2026
6e7f407
destroy the response when the file stream errors
Nic-Polumeyv Aug 22, 2026
dbef9f1
negotiate encodings and preconditions properly, derive variant etags …
Nic-Polumeyv Sep 14, 2026
8f28f3d
hash files one at a time through a single buffer, simplify aliasing
Nic-Polumeyv Sep 14, 2026
6042918
close the file when a download is aborted
Nic-Polumeyv Sep 14, 2026
04312b5
docs: describe how static assets are served
Nic-Polumeyv Sep 13, 2026
03a0a3a
load the asset tables through JSON.parse
Nic-Polumeyv Sep 14, 2026
64961cd
resolve both asset tables into one lookup at boot
Nic-Polumeyv Sep 14, 2026
4796ebf
test static.js on its own, one test per behaviour
Nic-Polumeyv Sep 14, 2026
3b68018
docs: say what happens to files replaced after the build
Nic-Polumeyv Sep 15, 2026
00f04f1
bundle the adapter's server source directly instead of prebuilding it…
Nic-Polumeyv Sep 14, 2026
bf4a3c2
drop the root build script, adapter-node was the only package with one
Nic-Polumeyv Sep 14, 2026
56644f1
drop the exclusions for a smoke spec deleted in #16907
Nic-Polumeyv Sep 14, 2026
e2aa055
record the size and content hash of client and prerendered files for …
Nic-Polumeyv Sep 2, 2026
bd8cbe7
drop the identifier guard, nothing rewrites chunks any more
Nic-Polumeyv Sep 14, 2026
c05bb5e
measure prerendered files lazily too, so adapters that never ask pay …
Nic-Polumeyv Sep 2, 2026
e39f45e
keep platform tests green
Rich-Harris Sep 15, 2026
19fb3af
name the prerendered directories once, type the measured file once
Nic-Polumeyv Sep 2, 2026
30a7ef0
Merge remote-tracking branch 'origin/version-3' into merge/16908
Nic-Polumeyv Sep 19, 2026
ba6ab38
Merge branch 'merge/16908' into merge/17109
Nic-Polumeyv Sep 19, 2026
c02fc35
Merge branch 'adapter-node-drop-prebuild' into kit-asset-table
Nic-Polumeyv Sep 19, 2026
41f8dce
Merge remote-tracking branch 'origin/version-3' into kit-asset-table
Nic-Polumeyv Sep 23, 2026
92d2b7c
Merge branch 'version-3' into kit-asset-table
teemingc Sep 24, 2026
f9f67b5
Merge branch 'version-3' into kit-asset-table
teemingc Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-hashes-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: expose the size and content hash of every client and prerendered file to adapters as `builder.clientFiles` and `builder.prerenderedFiles`
7 changes: 7 additions & 0 deletions .changeset/sizes-from-compress.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,7 @@ For adapter authors, there are some additional changes:
- adapters can augment the Vite config with additional plugins
- `builder.config.kit` no longer exists — the configuration now lives at the top level
- `builder.createEntries` has been removed — use `builder.writeClient`, `builder.writeServer` and `builder.writePrerendered` directly
- `builder.compress` returns a list of compressed files
- `builder.compress` returns the files it compressed, with the sizes of their `.gz` and `.br` variants
- `builder.mkdirp` and `builder.rimraf` are deprecated in favour of `node:fs` methods
- `builder.generateManifest` has been removed — use `builder.generateServerInstance` to replace it, and `builder.manifest` to access the manifest
- the `Server` class exported from the server output is deprecated — use the `server` object written by `builder.generateServerInstance`
Expand Down
81 changes: 34 additions & 47 deletions packages/adapter-bun/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,57 +18,37 @@ function is_dotfile(file) {
.some((segment, i) => segment.startsWith('.') && !(i === 0 && segment === '.well-known'));
}

// bounds open file handles while every asset hashes concurrently
const MAX_OPEN_FILES = 64;
let open_files = 0;
/** @type {Array<() => void>} */
const file_waiters = [];

/**
* Streams the file through the hasher so build memory stays bounded by chunk
* size instead of total asset size.
* @param {string} file
* @returns {Promise<string>}
*/
async function hash_file(file) {
if (open_files === MAX_OPEN_FILES) {
await new Promise((resolve) => {
file_waiters.push(() => resolve(undefined));
});
}
open_files++;
try {
const hasher = new Bun.CryptoHasher('blake2b256');
for await (const chunk of Bun.file(file).stream()) {
hasher.update(chunk);
}
return hasher.digest('hex').slice(0, 16);
} finally {
open_files--;
file_waiters.shift()?.();
}
}

/**
* The build-time validator for conditional requests: Bun only generates ETags for
* in-memory static routes, not file-backed responses, so the adapter ships its own.
* @param {string} file
* @param {boolean} [precompress]
* @returns {Promise<{ hash: string, mtime: number, br?: boolean, gz?: boolean }>}
* @param {string | undefined} hash
* @param {boolean} compressed
*/
async function asset_meta(file, precompress = false) {
const hash = await hash_file(file);
function asset_meta(file, hash, compressed) {
if (hash === undefined) throw new Error(`Could not find a content hash for ${file}`);

/** @type {{ hash: string, mtime: number, br?: boolean, gz?: boolean }} */
const meta = { hash, mtime: Bun.file(file).lastModified };
if (precompress) {
if (fs.existsSync(`${file}.br`)) meta.br = true;
if (fs.existsSync(`${file}.gz`)) meta.gz = true;
if (compressed) {
meta.br = true;
meta.gz = true;
}

return meta;
}

/**
* Content hashes of every client and prerendered file kit produced, keyed by the
* file's path relative to its output directory
* @param {Builder} builder
*/
function content_hashes(builder) {
/** @param {Array<{ file: string, hash: string }>} files */
const index = (files) => new Map(files.map(({ file, hash }) => [file, hash]));
return { client: index(builder.clientFiles), prerendered: index(builder.prerenderedFiles) };
}

/** @param {string[]} files */
function validate_file_paths(files) {
for (const file of files) {
Expand Down Expand Up @@ -255,11 +235,17 @@ async function create_routes({ builder, out, embed, precompress }) {
...builder.prerendered.redirects.keys()
]);

const hashes = content_hashes(builder);

/** @type {Record<keyof typeof hashes, Set<string>>} */
const compressed = { client: new Set(), prerendered: new Set() };
if (precompress) {
await Promise.all([
builder.compress(`${dest}/client`),
builder.compress(`${dest}/prerendered`)
]);
await Promise.all(
/** @type {const} */ (['client', 'prerendered']).map(async (dir) => {
const files = await builder.compress(`${dest}/${dir}`);
compressed[dir] = new Set(files.map(({ file }) => file));
})
);
}

/** @type {Map<string, string>} */
Expand All @@ -268,25 +254,26 @@ async function create_routes({ builder, out, embed, precompress }) {
/**
* @param {string} helper
* @param {string} url
* @param {string} dir
* @param {keyof typeof hashes} dir
* @param {string} [filename]
*/
const entry = async (helper, url, dir, filename = url) => {
const entry = (helper, url, dir, filename = url) => {
const file = `${dest}/${dir}/${filename}`;
if (embed) embedded.set(file, `asset_${embedded.size}`);
return `[${JSON.stringify(helper)}, ${JSON.stringify(url)}, ${embedded.get(file) ?? JSON.stringify(filename)}, ${JSON.stringify(await asset_meta(file, precompress))}]`;
const meta = asset_meta(file, hashes[dir].get(filename), compressed[dir].has(filename));
return `[${JSON.stringify(helper)}, ${JSON.stringify(url)}, ${embedded.get(file) ?? JSON.stringify(filename)}, ${JSON.stringify(meta)}]`;
};

const pages = [...builder.prerendered.pages];
const page_files = new Set(pages.map(([_, { file }]) => file));

const assets = await Promise.all([
const assets = [
...client_files.map((file) => entry('client_asset', file, 'client')),
...pages.map(([path, { file }]) => entry('prerendered_page', path, 'prerendered', file)),
...prerendered_files
.filter((file) => !page_files.has(file))
.map((file) => entry('prerendered_asset', file, 'prerendered'))
]);
];

const server_assets = builder
.findServerAssets(builder.routes.filter((route) => route.prerender !== true))
Expand Down
28 changes: 14 additions & 14 deletions packages/adapter-bun/test/adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ let bun_build: Mock<(options: any) => Promise<any>>;
let read_file: Mock<typeof fs.readFileSync>;
let write_file: Mock<typeof fs.writeFileSync>;

// 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<any> => ({
success: true,
Expand All @@ -25,16 +25,7 @@ beforeEach(() => {
stream: () => new Blob([]).stream(),
lastModified: 0
})) as never);
spyOn(Bun, 'CryptoHasher').mockImplementation(function () {
return {
update() {},
digest() {
return 'abc';
}
};
} as never);

spyOn(fs, 'existsSync').mockReturnValue(true);
spyOn(fs, 'rmSync').mockImplementation(() => {});
read_file = spyOn(fs, 'readFileSync').mockImplementation((() => undefined) as any) as any;
write_file = spyOn(fs, 'writeFileSync').mockImplementation(() => {});
Expand Down Expand Up @@ -349,7 +340,7 @@ describe('generated routes', () => {
});

test('precompresses assets and marks the variants in the generated routes', async () => {
const builder = create_builder({ client_files: ['app.js'] });
const builder = create_builder({ client_files: ['app.js'], compressed: ['app.js'] });

await adapter({ precompress: true }).adapt(builder);

Expand Down Expand Up @@ -417,7 +408,8 @@ function create_builder({
server_assets = [],
base = '',
origin,
instrumentation = false
instrumentation = false,
compressed = []
}: {
client_files?: string[];
prerendered_files?: string[];
Expand All @@ -428,14 +420,20 @@ function create_builder({
base?: string;
origin?: string;
instrumentation?: boolean;
compressed?: string[];
} = {}) {
// kit measures every file in its output on first access
const measure = (file: string) => ({ file, size: 0, hash: 'abc' });

return {
config: { outDir: '.svelte-kit', paths: { base, origin }, appDir: '_app' },
routes,
prerendered: {
pages: new Map(prerendered_pages),
redirects: new Map(prerendered_redirects)
},
clientFiles: client_files.map(measure),
prerenderedFiles: prerendered_files.map(measure),
log: {
minor: mock((_message: string) => {}),
error: mock((_message: string) => {}),
Expand All @@ -449,7 +447,9 @@ function create_builder({
writeClient: mock(() => client_files),
writePrerendered: mock(() => prerendered_files),
copy: mock(() => []),
compress: mock(async (_directory: string) => {}),
compress: mock(async (_directory: string) =>
compressed.map((file) => ({ file, gz: 1, br: 1 }))
),
findServerAssets: mock(() => server_assets),
hasServerInstrumentationFile: () => instrumentation,
createInstrumentationInitializer: mock(() => `${server_dir}/__sveltekit_env_init.js`),
Expand Down
Loading
Loading