diff --git a/eslint.config.js b/eslint.config.js index 6b868a9944c0..958a228f69ad 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -77,6 +77,7 @@ export default [ 'packages/kit/test/apps/**/*', 'packages/kit/test/build-errors/**/*', 'packages/kit/test/prerendering/**/*', + 'packages/kit/test/version-chunk-rotation/**/*', 'packages/test-redirect-importer/index.js', 'packages/adapter-netlify/test/preview.js' ] diff --git a/packages/kit/package.json b/packages/kit/package.json index 2b2bf749dbbc..c3c792f3a079 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -99,6 +99,10 @@ "browser": "./src/runtime/app/env/public/client.js", "default": "./src/runtime/app/env/public/server.js" }, + "#app/env/version": { + "browser": "./src/runtime/app/env/version/client.js", + "default": "./src/runtime/app/env/version/server.js" + }, "#internal": { "browser": "./src/exports/internal/client.js", "default": "./src/exports/internal/server/index.js" diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index 5f0ccf0519c2..5415ed9c6f8e 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -1,10 +1,10 @@ import path from 'node:path'; import { styleText } from 'node:util'; -import { hash } from '../../utils/hash.js'; import { resolve_entry } from '../../utils/filesystem.js'; import { posixify } from '../../utils/os.js'; import { s } from '../../utils/misc.js'; import { load_error_page, load_template } from '../config/index.js'; +import { payload_hash } from '../utils.js'; import { write_if_changed } from './utils.js'; import { escape_html } from '../../utils/escape.js'; @@ -56,7 +56,7 @@ export const options = { )}, error }, - version_hash: ${s(hash(config.kit.version.name))} + version_hash: ${s(payload_hash(config.kit))} }; export async function get_hooks() { diff --git a/packages/kit/src/core/utils.js b/packages/kit/src/core/utils.js index 39f99ea39452..b320cb33ddd5 100644 --- a/packages/kit/src/core/utils.js +++ b/packages/kit/src/core/utils.js @@ -4,8 +4,44 @@ import { fileURLToPath } from 'node:url'; import { styleText } from 'node:util'; import { to_fs } from '../utils/vite.js'; import { noop } from '../utils/functions.js'; +import { hash } from '../utils/hash.js'; import { posixify } from '../utils/os.js'; +/** + * Returns a deterministic identifier for the `globalThis.__sveltekit_${payload_hash}` + * payload global. + * + * It must not be derived from `kit.version.name`: the name is inlined into + * content-hashed client chunks in `__SVELTEKIT_PAYLOAD__` via the + * `$env/dynamic/public` virtual module, so a version-derived identifier rotates the + * hashed filename of every chunk referencing it on each deploy (#12260). + * + * It must still distinguish SvelteKit apps from different projects embedded in the + * same document (#9576), so it is derived from the project's `package.json` name + * plus `paths.base` and `appDir` — all deterministic across repeated config loads + * within a build and across machines. + * + * @param {import('types').ValidatedKitConfig} kit + * @returns {string} + */ +export function payload_hash(kit) { + if (kit.embedded) return hash(kit.version.name); + + const root = path.dirname(kit.outDir); + + let name = ''; + + try { + name = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8')).name ?? ''; + } catch { + // TODO log error or similiar + } + + if (!name) name = path.basename(root); + + return hash(`${name}\n${kit.paths.base}\n${kit.appDir}`); +} + /** * Resolved path of the `runtime` directory posix-ified * diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 0e000666ae71..6423e92b176c 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -24,7 +24,7 @@ import { import * as sync from '../../core/sync/sync.js'; import { create_assets } from '../../core/sync/create_manifest_data/index.js'; import { load_and_validate_params } from '../../utils/params.js'; -import { runtime_directory, logger } from '../../core/utils.js'; +import { runtime_directory, logger, payload_hash } from '../../core/utils.js'; import { generate_manifest } from '../../core/generate_manifest/index.js'; import { build_server_nodes } from './build/build_server.js'; import { find_deps, resolve_symlinks } from './build/utils.js'; @@ -217,6 +217,12 @@ function resolve_root(vite_config) { return posixify(vite_config.root ? path.resolve(vite_config.root) : process.cwd()); } +/** + * config loads twice so we cache the value of the first load so client and server bundles read the same value + * @type {string | undefined} + */ +let resolved_version_name; + /** * @return {Plugin} */ @@ -341,7 +347,20 @@ function kit({ svelte_config }) { out_dir = posixify(kit.outDir); out = `${out_dir}/output`; - version_hash = hash(kit.version.name); + if (is_build) { + if (resolved_version_name === undefined) { + resolved_version_name = kit.version.name; + } else if (kit.version.name !== resolved_version_name) { + console.warn( + `svelte.config.js computed a different kit.version.name on each load ` + + `("${resolved_version_name}" vs "${kit.version.name}"). Using the first value ` + + `for the whole build — make version.name deterministic to silence this warning.` + ); + } + kit.version.name = resolved_version_name; + } + + version_hash = payload_hash(kit); kit_global = is_build ? `globalThis.__sveltekit_${version_hash}` diff --git a/packages/kit/src/runtime/app/env/internal.js b/packages/kit/src/runtime/app/env/internal.js index 1d438b38e870..4346d11cfd2c 100644 --- a/packages/kit/src/runtime/app/env/internal.js +++ b/packages/kit/src/runtime/app/env/internal.js @@ -1,4 +1,4 @@ -export const version = __SVELTEKIT_APP_VERSION__; +export { version } from '#app/env/version'; export let building = false; export let prerendering = false; diff --git a/packages/kit/src/runtime/app/env/version/client.js b/packages/kit/src/runtime/app/env/version/client.js new file mode 100644 index 000000000000..331d50116840 --- /dev/null +++ b/packages/kit/src/runtime/app/env/version/client.js @@ -0,0 +1 @@ +export const version = /** @type {string} */ (__SVELTEKIT_PAYLOAD__?.version); diff --git a/packages/kit/src/runtime/app/env/version/server.js b/packages/kit/src/runtime/app/env/version/server.js new file mode 100644 index 000000000000..6c0d694c02c0 --- /dev/null +++ b/packages/kit/src/runtime/app/env/version/server.js @@ -0,0 +1 @@ +export const version = __SVELTEKIT_APP_VERSION__; diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 583d4584324f..e70b3d3c3862 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -412,7 +412,10 @@ export async function render_response({ const blocks = []; - const properties = [`base: ${base_expression}`]; + const properties = [ + `base: ${base_expression}`, + `version: ${devalue.uneval(__SVELTEKIT_APP_VERSION__)}` + ]; if (paths.assets) { properties.push(`assets: ${s(paths.assets)}`); diff --git a/packages/kit/src/types/global-private.d.ts b/packages/kit/src/types/global-private.d.ts index 7c7bed787169..8e6d8b3c6e22 100644 --- a/packages/kit/src/types/global-private.d.ts +++ b/packages/kit/src/types/global-private.d.ts @@ -37,6 +37,8 @@ declare global { const __SVELTEKIT_PAYLOAD__: { /** The basepath, usually relative to the current page */ base: string; + /** determinsitically computed from package name + config.appDir + config.base */ + version?: string; /** Path to externally-hosted assets */ assets?: string; /** Public environment variables */ diff --git a/packages/kit/test/version-chunk-rotation/app/package.json b/packages/kit/test/version-chunk-rotation/app/package.json new file mode 100644 index 000000000000..a61bae71f858 --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/app/package.json @@ -0,0 +1,22 @@ +{ + "name": "test-version-chunk-rotation-app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "svelte-kit sync && vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync", + "check": "svelte-kit sync && tsc && svelte-check" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "workspace:^", + "@sveltejs/kit": "workspace:^", + "@sveltejs/vite-plugin-svelte": "catalog:", + "svelte": "catalog:", + "svelte-check": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/packages/kit/test/version-chunk-rotation/app/src/app.d.ts b/packages/kit/test/version-chunk-rotation/app/src/app.d.ts new file mode 100644 index 000000000000..6285566846a0 --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/app/src/app.d.ts @@ -0,0 +1,5 @@ +declare global { + namespace App {} +} + +export {}; diff --git a/packages/kit/test/version-chunk-rotation/app/src/app.html b/packages/kit/test/version-chunk-rotation/app/src/app.html new file mode 100644 index 000000000000..d533c5e31716 --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/app/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/packages/kit/test/version-chunk-rotation/app/src/lib/shared.js b/packages/kit/test/version-chunk-rotation/app/src/lib/shared.js new file mode 100644 index 000000000000..15f94603b63a --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/app/src/lib/shared.js @@ -0,0 +1,6 @@ +export const sharedMessage = 'shared between routes'; + +/** @param {number} n */ +export function double(n) { + return n * 2; +} diff --git a/packages/kit/test/version-chunk-rotation/app/src/routes/+page.svelte b/packages/kit/test/version-chunk-rotation/app/src/routes/+page.svelte new file mode 100644 index 000000000000..731197e95f21 --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/app/src/routes/+page.svelte @@ -0,0 +1,7 @@ + + +

home

+

{sharedMessage}: {double(21)}

+about diff --git a/packages/kit/test/version-chunk-rotation/app/src/routes/about/+page.svelte b/packages/kit/test/version-chunk-rotation/app/src/routes/about/+page.svelte new file mode 100644 index 000000000000..c4ca72613c2f --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/app/src/routes/about/+page.svelte @@ -0,0 +1,7 @@ + + +

about

+

{sharedMessage}: {double(50)}

+home diff --git a/packages/kit/test/version-chunk-rotation/app/src/routes/prerendered/+page.js b/packages/kit/test/version-chunk-rotation/app/src/routes/prerendered/+page.js new file mode 100644 index 000000000000..189f71e2e1b3 --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/app/src/routes/prerendered/+page.js @@ -0,0 +1 @@ +export const prerender = true; diff --git a/packages/kit/test/version-chunk-rotation/app/src/routes/prerendered/+page.svelte b/packages/kit/test/version-chunk-rotation/app/src/routes/prerendered/+page.svelte new file mode 100644 index 000000000000..5839f7d6d262 --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/app/src/routes/prerendered/+page.svelte @@ -0,0 +1,6 @@ + + +

prerendered

+

{sharedMessage}

diff --git a/packages/kit/test/version-chunk-rotation/app/tsconfig.json b/packages/kit/test/version-chunk-rotation/app/tsconfig.json new file mode 100644 index 000000000000..b1096bf168cd --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/app/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true + }, + "extends": "./.svelte-kit/tsconfig.json" +} diff --git a/packages/kit/test/version-chunk-rotation/app/vite.config.js b/packages/kit/test/version-chunk-rotation/app/vite.config.js new file mode 100644 index 000000000000..0322e21e7f0d --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/app/vite.config.js @@ -0,0 +1,32 @@ +import * as path from 'node:path'; +import process from 'node:process'; +import { sveltekit } from '@sveltejs/kit/vite'; +import adapter from '../../../../adapter-auto/index.js'; + +/** @type {import('vite').UserConfig} */ +const config = { + build: { + minify: false + }, + + clearScreen: false, + + logLevel: 'silent', + + plugins: [ + sveltekit({ + adapter: adapter(), + version: { + name: process.env.SK_VERSION || Date.now().toString() + } + }) + ], + + server: { + fs: { + allow: [path.resolve('../../../src')] + } + } +}; + +export default config; diff --git a/packages/kit/test/version-chunk-rotation/classify.js b/packages/kit/test/version-chunk-rotation/classify.js new file mode 100644 index 000000000000..7a6b8386da09 --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/classify.js @@ -0,0 +1,88 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * @typedef {object} Chunk + * @property {string} bytes_hash + * @property {boolean} contains_version + */ + +const VERSION_DERIVED_PAYLOAD_KEY = /__sveltekit_(?!dev\b)[A-Za-z0-9]+/; + +/** @param {string} code */ +const bytes_hash = (code) => createHash('sha256').update(code).digest('hex').slice(0, 12); + +/** + * @param {string} dir + * @param {string} [base] + * @param {string[]} [found] + * @returns {string[]} + */ +function js_files(dir, base = dir, found = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) js_files(abs, base, found); + else if (entry.name.endsWith('.js')) found.push(path.relative(base, abs)); + } + return found.sort(); +} + +/** + * @param {string} dir + * @param {string} version + * @returns {Map} + */ +export function snapshot(dir, version) { + /** @type {Map} */ + const chunks = new Map(); + + for (const filename of js_files(dir)) { + const code = fs.readFileSync(path.join(dir, filename), 'utf-8'); + + chunks.set(filename, { + bytes_hash: bytes_hash(code), + contains_version: code.includes(version) || VERSION_DERIVED_PAYLOAD_KEY.test(code) + }); + } + + return chunks; +} + +/** + * @param {Map} before + * @param {Map} after + */ +export function compare(before, after) { + /** @type {Array<{ name: string, contains_version: boolean }>} */ + const rotated = []; + /** @type {Array<{ name: string, contains_version: boolean }>} */ + const mutable = []; + + for (const [name, chunk] of before) { + const replacement = after.get(name); + if (!replacement) { + rotated.push({ name, contains_version: chunk.contains_version }); + } else if (replacement.bytes_hash !== chunk.bytes_hash) { + mutable.push({ + name, + contains_version: chunk.contains_version || replacement.contains_version + }); + } + } + + return { total: before.size, rotated, mutable }; +} + +/** @param {ReturnType} result */ +export function format_report(result) { + /** @param {{ name: string, contains_version: boolean }} finding */ + const line = ({ name, contains_version }) => + `${name}${contains_version ? ' ← contains the version' : ''}`; + + return [ + `${result.total} chunks total · ${result.rotated.length} rotated · ${result.mutable.length} mutable (same name, new bytes)`, + ...result.mutable.map((finding) => ` MUTABLE ${line(finding)}`), + ...result.rotated.map((finding) => ` ROTATED ${line(finding)}`) + ].join('\n'); +} diff --git a/packages/kit/test/version-chunk-rotation/classify.spec.js b/packages/kit/test/version-chunk-rotation/classify.spec.js new file mode 100644 index 000000000000..272a454e26fa --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/classify.spec.js @@ -0,0 +1,72 @@ +import { assert, test } from 'vitest'; +import { compare } from './classify.js'; + +/** + * @param {object} [options] + * @param {string} [options.bytes] + * @param {boolean} [options.contains_version] + * @returns {import('./classify.js').Chunk} + */ +function chunk({ bytes = 'same-bytes', contains_version = false } = {}) { + return { bytes_hash: bytes, contains_version }; +} + +/** @param {Record} chunks */ +const build = (chunks) => new Map(Object.entries(chunks)); + +test('identical builds produce no findings', () => { + const output = build({ + 'chunks/AAAAAAAA.js': chunk(), + 'entry/app.BBBBBBBB.js': chunk({ contains_version: true }) + }); + + const result = compare(output, output); + + assert.deepEqual(result.rotated, []); + assert.deepEqual(result.mutable, []); +}); + +test('a chunk whose bytes change under a stable filename is mutable', () => { + const before = build({ 'chunks/AAAAAAAA.js': chunk({ bytes: 'old', contains_version: true }) }); + const after = build({ 'chunks/AAAAAAAA.js': chunk({ bytes: 'new', contains_version: true }) }); + + const result = compare(before, after); + + assert.deepEqual(result.rotated, []); + assert.deepEqual(result.mutable, [{ name: 'chunks/AAAAAAAA.js', contains_version: true }]); +}); + +test('a rotated chunk is reported with its version flag', () => { + const before = build({ + 'chunks/AAAAAAAA.js': chunk({ contains_version: true }), + 'nodes/0.BBBBBBBB.js': chunk({ bytes: 'imports-AAAAAAAA' }) + }); + const after = build({ + 'chunks/CCCCCCCC.js': chunk({ contains_version: true }), + 'nodes/0.DDDDDDDD.js': chunk({ bytes: 'imports-CCCCCCCC' }) + }); + + const result = compare(before, after); + + assert.deepEqual(result.rotated, [ + { name: 'chunks/AAAAAAAA.js', contains_version: true }, + { name: 'nodes/0.BBBBBBBB.js', contains_version: false } + ]); + assert.deepEqual(result.mutable, []); +}); + +test('an unchanged chunk is not reported even when siblings rotate', () => { + const before = build({ + 'chunks/AAAAAAAA.js': chunk(), + 'chunks/BBBBBBBB.js': chunk({ contains_version: true }) + }); + const after = build({ + 'chunks/AAAAAAAA.js': chunk(), + 'chunks/CCCCCCCC.js': chunk({ contains_version: true }) + }); + + const result = compare(before, after); + + assert.deepEqual(result.rotated, [{ name: 'chunks/BBBBBBBB.js', contains_version: true }]); + assert.deepEqual(result.mutable, []); +}); diff --git a/packages/kit/test/version-chunk-rotation/consistency.spec.js b/packages/kit/test/version-chunk-rotation/consistency.spec.js new file mode 100644 index 000000000000..75b0aa4ff850 --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/consistency.spec.js @@ -0,0 +1,56 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { assert, test } from 'vitest'; + +const timeout = 180_000; + +const app_dir = fileURLToPath(new URL('./app', import.meta.url)); + +/** @param {string} [version] omit to build with the fixture's per-config-load default */ +function build(version) { + const env = { ...process.env }; + if (version === undefined) delete env.SK_VERSION; + else env.SK_VERSION = version; + + fs.rmSync(path.join(app_dir, '.svelte-kit/output'), { recursive: true, force: true }); + execSync('pnpm build', { cwd: app_dir, stdio: 'pipe', timeout, env }); + + return { + prerendered_page: fs.readFileSync( + path.join(app_dir, '.svelte-kit/output/prerendered/pages/prerendered.html'), + 'utf-8' + ), + version_json: /** @type {{ version: string }} */ ( + JSON.parse( + fs.readFileSync(path.join(app_dir, '.svelte-kit/output/client/_app/version.json'), 'utf-8') + ) + ) + }; +} + +test('a version containing cannot terminate the payload script', { timeout }, () => { + const dangerous_version = ''; + + const { prerendered_page, version_json } = build(dangerous_version); + + assert.notInclude(prerendered_page, dangerous_version); + assert.include(prerendered_page, '\\u003C/script>', 'the version is escaped, not dropped'); + assert.equal(version_json.version, dangerous_version); +}); + +test( + 'a version computed per config load resolves to one value for the whole build (#14166)', + { timeout }, + () => { + const { prerendered_page, version_json } = build(); + + assert.include( + prerendered_page, + `version: "${version_json.version}"`, + 'the payload version and _app/version.json must agree' + ); + } +); diff --git a/packages/kit/test/version-chunk-rotation/package.json b/packages/kit/test/version-chunk-rotation/package.json new file mode 100644 index 000000000000..16fecb69ead9 --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/package.json @@ -0,0 +1,13 @@ +{ + "name": "test-version-chunk-rotation", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "test": "vitest run", + "test:cross-platform:build": "vitest run" + }, + "devDependencies": { + "vitest": "catalog:" + } +} diff --git a/packages/kit/test/version-chunk-rotation/payload-hash.spec.js b/packages/kit/test/version-chunk-rotation/payload-hash.spec.js new file mode 100644 index 000000000000..8655f258592d --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/payload-hash.spec.js @@ -0,0 +1,91 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { assert, test } from 'vitest'; +import { payload_hash } from '../../src/core/utils.js'; +import { hash } from '../../src/utils/hash.js'; + +/** @param {{ name?: string, package_json?: boolean }} [options] */ +function project({ name, package_json = true } = {}) { + const root = mkdtempSync(join(tmpdir(), 'kit-payload-hash-')); + if (package_json) writeFileSync(join(root, 'package.json'), JSON.stringify({ name })); + return root; +} + +/** + * @param {object} options + * @param {string} options.root + * @param {string} [options.base] + * @param {string} [options.app_dir] + * @param {string} [options.version] + * @param {boolean} [options.embedded] + * @returns {import('types').ValidatedKitConfig} + */ +function kit_config({ root, base = '', app_dir = '_app', version = 'v1', embedded = false }) { + return /** @type {import('types').ValidatedKitConfig} */ ( + /** @type {unknown} */ ({ + outDir: join(root, '.svelte-kit'), + appDir: app_dir, + paths: { base }, + version: { name: version }, + embedded + }) + ); +} + +test('is independent of version.name', () => { + const root = project({ name: 'my-app' }); + + assert.equal( + payload_hash(kit_config({ root, version: 'v-alpha' })), + payload_hash(kit_config({ root, version: 'v-bravo' })) + ); +}); + +test('differs between projects with different package names', () => { + assert.notEqual( + payload_hash(kit_config({ root: project({ name: 'app-one' }) })), + payload_hash(kit_config({ root: project({ name: 'app-two' }) })) + ); +}); + +test('differs when paths.base differs', () => { + const root = project({ name: 'my-app' }); + + assert.notEqual( + payload_hash(kit_config({ root, base: '' })), + payload_hash(kit_config({ root, base: '/docs' })) + ); +}); + +test('differs when appDir differs', () => { + const root = project({ name: 'my-app' }); + + assert.notEqual( + payload_hash(kit_config({ root, app_dir: '_app' })), + payload_hash(kit_config({ root, app_dir: '_custom' })) + ); +}); + +test('embedded apps keep the version-derived key for cross-deploy isolation', () => { + const root = project({ name: 'my-app' }); + + assert.equal( + payload_hash(kit_config({ root, embedded: true, version: 'v-alpha' })), + hash('v-alpha') + ); +}); + +test('projects without a package.json still get distinct keys', () => { + assert.notEqual( + payload_hash(kit_config({ root: project({ package_json: false }) })), + payload_hash(kit_config({ root: project({ package_json: false }) })) + ); +}); + +test('projects with a nameless package.json still get distinct keys', () => { + assert.notEqual( + payload_hash(kit_config({ root: project() })), + payload_hash(kit_config({ root: project() })) + ); +}); diff --git a/packages/kit/test/version-chunk-rotation/rotation.spec.js b/packages/kit/test/version-chunk-rotation/rotation.spec.js new file mode 100644 index 000000000000..f677e9801518 --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/rotation.spec.js @@ -0,0 +1,71 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { assert, beforeAll, test } from 'vitest'; +import { compare, format_report, snapshot } from './classify.js'; + +const timeout = 180_000; + +const app_dir = fileURLToPath(new URL('./app', import.meta.url)); +const immutable_dir = path.join(app_dir, '.svelte-kit/output/client/_app/immutable'); + +/** @param {string} version */ +function build(version) { + fs.rmSync(path.join(app_dir, '.svelte-kit/output'), { recursive: true, force: true }); + execSync('pnpm build', { + cwd: app_dir, + stdio: 'pipe', + timeout, + env: { ...process.env, SK_VERSION: version } + }); + + return { + version, + chunks: snapshot(immutable_dir, version), + prerendered_page: fs.readFileSync( + path.join(app_dir, '.svelte-kit/output/prerendered/pages/prerendered.html'), + 'utf-8' + ) + }; +} + +/** + * @param {ReturnType} result + * @param {string} when + */ +function assert_bundle_unchanged(result, when) { + assert.equal(result.mutable.length, 0, `chunks mutated ${when}:\n${format_report(result)}`); + assert.equal(result.rotated.length, 0, `chunks rotated ${when}:\n${format_report(result)}`); +} + +/** @type {ReturnType} */ +let control_a; +/** @type {ReturnType} */ +let control_b; +/** @type {ReturnType} */ +let bumped; + +beforeAll(() => { + control_a = build('version-alpha'); + control_b = build('version-alpha'); + bumped = build('version-bravo'); +}, timeout); + +test('the same version builds an identical client bundle', () => { + assert_bundle_unchanged(compare(control_a.chunks, control_b.chunks), 'at a fixed version'); +}); + +test('bumping kit.version.name rotates no client chunks', () => { + assert_bundle_unchanged( + compare(control_a.chunks, bumped.chunks), + 'after a version bump with no app-code change' + ); +}); + +test('the server-rendered payload carries the version of its own build', () => { + for (const { version, prerendered_page } of [control_a, bumped]) { + assert.include(prerendered_page, `version: "${version}"`); + } +}); diff --git a/packages/kit/test/version-chunk-rotation/vitest.config.js b/packages/kit/test/version-chunk-rotation/vitest.config.js new file mode 100644 index 000000000000..91bbdfedda78 --- /dev/null +++ b/packages/kit/test/version-chunk-rotation/vitest.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // rotation.spec.js and consistency.spec.js both build ./app in place — + // concurrent builds in the same directory would corrupt each other's output + fileParallelism: false + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1caf22bd2bc..606048de79da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1392,6 +1392,36 @@ importers: specifier: 'catalog:' version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.19.19)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.4.2)(yaml@2.9.0)) + packages/kit/test/version-chunk-rotation: + devDependencies: + vitest: + specifier: 'catalog:' + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.19.19)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.4.2)(yaml@2.9.0)) + + packages/kit/test/version-chunk-rotation/app: + devDependencies: + '@sveltejs/adapter-auto': + specifier: workspace:^ + version: link:../../../../adapter-auto + '@sveltejs/kit': + specifier: workspace:^ + version: link:../../.. + '@sveltejs/vite-plugin-svelte': + specifier: 'catalog:' + version: 7.0.0(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.4.2)(yaml@2.9.0)) + svelte: + specifier: 'catalog:' + version: 5.56.3(@typescript-eslint/types@8.61.1) + svelte-check: + specifier: 'catalog:' + version: 4.7.0(picomatch@4.0.4)(svelte@5.56.3(@typescript-eslint/types@8.61.1))(typescript@6.0.3) + typescript: + specifier: 'catalog:' + version: 6.0.3 + vite: + specifier: 'catalog:' + version: 8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.4.2)(yaml@2.9.0) + packages/package: dependencies: chokidar: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d8bde5880d59..00c894c21efe 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -27,6 +27,8 @@ packages: - packages/kit/test/prerendering/* - packages/kit/test/build-errors/** - packages/kit/test/build-errors/apps/* + - packages/kit/test/version-chunk-rotation + - packages/kit/test/version-chunk-rotation/app - '!.test-tmp/**' - playgrounds/* allowBuilds: