Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -517,7 +517,7 @@ For adapter authors, there are some additional changes:
- adapters can augment the Vite config with additional plugins
- `builder.config.kit` no longer exists — the configuration now lives at the top level
- `builder.createEntries` has been removed — use `builder.writeClient`, `builder.writeServer` and `builder.writePrerendered` directly
- `builder.compress` returns a list of compressed files
- `builder.compress` returns the files it compressed, with the sizes of their `.gz` and `.br` variants
- `builder.mkdirp` and `builder.rimraf` are deprecated in favour of `node:fs` methods
- `builder.generateManifest` has been removed — use `builder.generateServerInstance` to replace it, and `builder.manifest` to access the manifest

Expand Down
110 changes: 54 additions & 56 deletions packages/adapter-bun/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,57 +31,38 @@ 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.
* 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) {
/** @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 @@ -279,9 +260,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`);
Expand All @@ -300,30 +281,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<string, string>} 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()
Expand Down Expand Up @@ -353,29 +337,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<keyof typeof hashes, Set<string>>} */
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: [],
Expand Down Expand Up @@ -409,7 +407,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 }]) => {
Expand Down
46 changes: 31 additions & 15 deletions packages/adapter-bun/test/adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ let read_dir: Mock<typeof fs.readdirSync>;
let exists: Mock<typeof fs.existsSync>;
let read_file: Mock<typeof fs.readFileSync>;

// 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 @@ -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));
Expand Down Expand Up @@ -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']
})
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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');
Expand All @@ -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];
Expand Down Expand Up @@ -565,7 +570,8 @@ function create_builder({
server_assets = [],
base = '',
origin,
instrumentation = false
instrumentation = false,
compressed = []
}: {
client_files?: string[];
prerendered_files?: string[];
Expand All @@ -576,14 +582,22 @@ 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, ...prerendered_pages.map(([, { file }]) => file)].map(
measure
),
log: {
minor: mock((_message: string) => {}),
error: mock((_message: string) => {}),
Expand All @@ -596,7 +610,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,
Expand Down
Loading
Loading