diff --git a/.changeset/free-bars-clap.md b/.changeset/free-bars-clap.md new file mode 100644 index 000000000000..88d90a67e794 --- /dev/null +++ b/.changeset/free-bars-clap.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: validate that the `version.name` config option is deterministic diff --git a/eslint.config.js b/eslint.config.js index b0e8e3968706..1674d72a50f5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,6 +13,7 @@ export default [ '**/.svelte-kit', '**/test-results', '**/build', + '**/dist', '**/.custom-out-dir', 'packages/adapter-*/files', 'packages/kit/src/core/config/fixtures/multiple', // dir contains svelte config with multiple extensions tripping eslint diff --git a/packages/kit/src/core/config/fixtures/non-deterministic-version/svelte.config.js b/packages/kit/src/core/config/fixtures/non-deterministic-version/svelte.config.js new file mode 100644 index 000000000000..4068147a5a40 --- /dev/null +++ b/packages/kit/src/core/config/fixtures/non-deterministic-version/svelte.config.js @@ -0,0 +1,7 @@ +export default { + kit: { + version: { + name: Date.now().toString() + } + } +}; diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js index 7fc38bc483dd..e8828afe14a5 100644 --- a/packages/kit/src/core/config/index.js +++ b/packages/kit/src/core/config/index.js @@ -70,7 +70,7 @@ export async function load_config({ cwd = process.cwd() } = {}) { console.log( `No Svelte config file found in ${cwd} - using SvelteKit's default configuration without an adapter.` ); - return process_config({}, { cwd }); + return await process_config(() => Promise.resolve({}), { cwd }); } const config_file = config_files[0]; if (config_files.length > 1) { @@ -78,10 +78,15 @@ export async function load_config({ cwd = process.cwd() } = {}) { `Found multiple Svelte config files in ${cwd}: ${config_files.map((f) => path.basename(f)).join(', ')}. Using ${path.basename(config_file)}` ); } - const config = await import(`${url.pathToFileURL(config_file).href}?ts=${Date.now()}`); + + const get_config = async () => { + /** @type {{ default: import('@sveltejs/kit').Config }} */ + const config = await import(`${url.pathToFileURL(config_file).href}?ts=${Date.now()}`); + return config.default; + }; try { - return process_config(config.default, { cwd }); + return await process_config(get_config, { cwd }); } catch (e) { const error = /** @type {Error} */ (e); @@ -92,11 +97,11 @@ export async function load_config({ cwd = process.cwd() } = {}) { } /** - * @param {import('@sveltejs/kit').Config} config - * @returns {import('types').ValidatedConfig} + * @param {() => Promise} get_config + * @returns {Promise} */ -function process_config(config, { cwd = process.cwd() } = {}) { - const validated = validate_config(config); +async function process_config(get_config, { cwd = process.cwd() } = {}) { + const validated = await validate_config(get_config); validated.kit.outDir = path.resolve(cwd, validated.kit.outDir); @@ -115,16 +120,27 @@ function process_config(config, { cwd = process.cwd() } = {}) { } /** - * @param {import('@sveltejs/kit').Config} config - * @returns {import('types').ValidatedConfig} + * @param {() => Promise} get_config + * @returns {Promise} */ -export function validate_config(config) { +export async function validate_config(get_config) { + const config = await get_config(); + if (typeof config !== 'object') { throw new Error( 'The Svelte config file must have a configuration object as its default export. See https://svelte.dev/docs/kit/configuration' ); } + if (config.kit?.version?.name) { + const same_config = await get_config(); + if (config.kit.version.name !== same_config.kit?.version?.name) { + throw new Error( + 'The `version.name` option must be deterministic (e.g. a commit ref rather than` Math.random()` or `Date.now().toString()`). See https://svelte.dev/docs/kit/configuration#version' + ); + } + } + const validated = options(config, 'config'); if (validated.kit.router.resolution === 'server') { diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 90da427483d7..5c1fe1b55cf0 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -123,8 +123,8 @@ const get_defaults = (prefix = '') => ({ } }); -test('fills in defaults', () => { - const validated = validate_config({}); +test('fills in defaults', async () => { + const validated = await validate_config(() => Promise.resolve({})); assert.equal(validated.kit.serviceWorker.files(''), true); @@ -137,56 +137,70 @@ test('fills in defaults', () => { }); test('errors on invalid values', () => { - assert.throws(() => { - validate_config({ - kit: { - // @ts-expect-error - given value expected to throw - appDir: 42 - } - }); - }, /^config\.kit\.appDir should be a string, if specified$/); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + validate_config(() => + // @ts-expect-error - given value expected to throw + Promise.resolve({ + kit: { + appDir: 42 + } + }) + ) + ).rejects.toThrow(/^config\.kit\.appDir should be a string, if specified$/); }); test('errors on invalid nested values', () => { - assert.throws(() => { - validate_config({ - kit: { - files: { - // @ts-expect-error - given value expected to throw - potato: 'blah' + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + validate_config(() => + // @ts-expect-error - given value expected to throw + Promise.resolve({ + kit: { + files: { + potato: 42 + } } - } - }); - }, /^Unexpected option config\.kit\.files\.potato$/); + }) + ) + ).rejects.toThrow(/^Unexpected option config\.kit\.files\.potato$/); }); test('does not error on invalid top-level values', () => { - assert.doesNotThrow(() => { - validate_config({ - onwarn: () => {} - }); - }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + validate_config(() => + Promise.resolve({ + onwarn: () => {} + }) + ) + ).resolves.not.toThrow(); }); test('errors on extension without leading .', () => { - assert.throws(() => { - validate_config({ - extensions: ['blah'] - }); - }, /Each member of config\.extensions must start with '\.' — saw 'blah'/); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + validate_config(() => + Promise.resolve({ + extensions: ['blah'] + }) + ) + ).rejects.toThrow(/Each member of config\.extensions must start with '\.' — saw 'blah'/); }); -test('fills in partial blanks', () => { - const validated = validate_config({ - kit: { - files: { - assets: 'public' - }, - version: { - name: '0' +test('fills in partial blanks', async () => { + const validated = await validate_config(() => + Promise.resolve({ + kit: { + files: { + assets: 'public' + }, + version: { + name: '0' + } } - } - }); + }) + ); assert.equal(validated.kit.serviceWorker.files(''), true); @@ -200,106 +214,149 @@ test('fills in partial blanks', () => { }); test('fails if kit.appDir is blank', () => { - assert.throws(() => { - validate_config({ - kit: { - appDir: '' - } - }); - }, /^config\.kit\.appDir cannot be empty$/); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + validate_config(() => + Promise.resolve({ + kit: { + appDir: '' + } + }) + ) + ).rejects.toThrow(/^config\.kit\.appDir cannot be empty$/); }); test('fails if kit.appDir is only slash', () => { - assert.throws(() => { - validate_config({ - kit: { - appDir: '/' - } - }); - }, /^config\.kit\.appDir cannot start or end with '\/'. See https:\/\/svelte\.dev\/docs\/kit\/configuration$/); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + validate_config(() => + Promise.resolve({ + kit: { + appDir: '/' + } + }) + ) + ).rejects.toThrow( + /^config\.kit\.appDir cannot start or end with '\/'. See https:\/\/svelte\.dev\/docs\/kit\/configuration$/ + ); }); test('fails if kit.appDir starts with slash', () => { - assert.throws(() => { - validate_config({ - kit: { - appDir: '/_app' - } - }); - }, /^config\.kit\.appDir cannot start or end with '\/'. See https:\/\/svelte\.dev\/docs\/kit\/configuration$/); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + validate_config(() => + Promise.resolve({ + kit: { + appDir: '/_app' + } + }) + ) + ).rejects.toThrow( + /^config\.kit\.appDir cannot start or end with '\/'. See https:\/\/svelte\.dev\/docs\/kit\/configuration$/ + ); }); test('fails if kit.appDir ends with slash', () => { - assert.throws(() => { - validate_config({ - kit: { - appDir: '_app/' - } - }); - }, /^config\.kit\.appDir cannot start or end with '\/'. See https:\/\/svelte\.dev\/docs\/kit\/configuration$/); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + validate_config(() => + Promise.resolve({ + kit: { + appDir: '_app/' + } + }) + ) + ).rejects.toThrow( + /^config\.kit\.appDir cannot start or end with '\/'. See https:\/\/svelte\.dev\/docs\/kit\/configuration$/ + ); }); test('fails if paths.base is not root-relative', () => { - assert.throws(() => { - validate_config({ - kit: { - paths: { - // @ts-expect-error - base: 'https://example.com/somewhere/else' + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + validate_config(() => + // @ts-expect-error + Promise.resolve({ + kit: { + paths: { + base: 'https://example.com/somewhere/else' + } } - } - }); - }, /^config\.kit\.paths\.base option must either be the empty string or a root-relative path that starts but doesn't end with '\/'. See https:\/\/svelte\.dev\/docs\/kit\/configuration#paths$/); + }) + ) + ).rejects.toThrow( + /^config\.kit\.paths\.base option must either be the empty string or a root-relative path that starts but doesn't end with '\/'. See https:\/\/svelte\.dev\/docs\/kit\/configuration#paths$/ + ); }); test("fails if paths.base ends with '/'", () => { - assert.throws(() => { - validate_config({ - kit: { - paths: { - base: '/github-pages/' + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + validate_config(() => + Promise.resolve({ + kit: { + paths: { + base: '/github-pages/' + } } - } - }); - }, /^config\.kit\.paths\.base option must either be the empty string or a root-relative path that starts but doesn't end with '\/'. See https:\/\/svelte\.dev\/docs\/kit\/configuration#paths$/); + }) + ) + ).rejects.toThrow( + /^config\.kit\.paths\.base option must either be the empty string or a root-relative path that starts but doesn't end with '\/'. See https:\/\/svelte\.dev\/docs\/kit\/configuration#paths$/ + ); }); test('fails if paths.assets is relative', () => { - assert.throws(() => { - validate_config({ - kit: { - paths: { - // @ts-expect-error - assets: 'foo' + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + validate_config(() => + // @ts-expect-error + Promise.resolve({ + kit: { + paths: { + assets: 'foo' + } } - } - }); - }, /^config\.kit\.paths\.assets option must be an absolute path, if specified. See https:\/\/svelte\.dev\/docs\/kit\/configuration#paths$/); + }) + ) + ).rejects.toThrow( + /^config\.kit\.paths\.assets option must be an absolute path, if specified. See https:\/\/svelte\.dev\/docs\/kit\/configuration#paths$/ + ); }); test('fails if paths.assets has trailing slash', () => { - assert.throws(() => { - validate_config({ - kit: { - paths: { - assets: 'https://cdn.example.com/stuff/' + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + validate_config(() => + Promise.resolve({ + kit: { + paths: { + assets: 'https://cdn.example.com/stuff/' + } } - } - }); - }, /^config\.kit\.paths\.assets option must not end with '\/'. See https:\/\/svelte\.dev\/docs\/kit\/configuration#paths$/); + }) + ) + ).rejects.toThrow( + /^config\.kit\.paths\.assets option must not end with '\/'. See https:\/\/svelte\.dev\/docs\/kit\/configuration#paths$/ + ); }); test('fails if prerender.entries are invalid', () => { - assert.throws(() => { - validate_config({ - kit: { - prerender: { - // @ts-expect-error - given value expected to throw - entries: ['foo'] + // eslint-disable-next-line @typescript-eslint/no-floating-promises + expect( + validate_config(() => + // @ts-expect-error - given value expected to throw + Promise.resolve({ + kit: { + prerender: { + entries: ['foo'] + } } - } - }); - }, /^Each member of config\.kit.prerender.entries must be either '\*' or an absolute path beginning with '\/' — saw 'foo'$/); + }) + ) + ).rejects.toThrow( + /^Each member of config\.kit.prerender.entries must be either '\*' or an absolute path beginning with '\/' — saw 'foo'$/ + ); }); /** @@ -308,13 +365,17 @@ test('fails if prerender.entries are invalid', () => { * @param {import('@sveltejs/kit').KitConfig['paths']} output */ function validate_paths(name, input, output) { - test(name, () => { + test(name, async () => { expect( - validate_config({ - kit: { - paths: input - } - }).kit.paths + ( + await validate_config(() => + Promise.resolve({ + kit: { + paths: input + } + }) + ) + ).kit.paths ).toEqual(output); }); } @@ -407,3 +468,19 @@ test('errors on loading config with incorrect default export', async () => { 'The Svelte config file must have a configuration object as its default export. See https://svelte.dev/docs/kit/configuration' ); }); + +test('errors on non-deterministic version name', async () => { + let message = null; + + try { + const cwd = join(__dirname, 'fixtures', 'non-deterministic-version'); + await load_config({ cwd }); + } catch (/** @type {any} */ e) { + message = e.message; + } + + assert.equal( + message, + 'The `version.name` option must be deterministic (e.g. a commit ref rather than` Math.random()` or `Date.now().toString()`). See https://svelte.dev/docs/kit/configuration#version' + ); +}); diff --git a/packages/kit/src/core/sync/write_tsconfig.spec.js b/packages/kit/src/core/sync/write_tsconfig.spec.js index fbd780d74bdd..a437e33ddbdf 100644 --- a/packages/kit/src/core/sync/write_tsconfig.spec.js +++ b/packages/kit/src/core/sync/write_tsconfig.spec.js @@ -2,18 +2,20 @@ import { assert, expect, test } from 'vitest'; import { validate_config } from '../config/index.js'; import { get_tsconfig } from './write_tsconfig.js'; -test('Creates tsconfig path aliases from kit.alias', () => { - const { kit } = validate_config({ - kit: { - alias: { - simpleKey: 'simple/value', - key: 'value', - 'key/*': 'some/other/value/*', - keyToFile: 'path/to/file.ts', - $routes: '.svelte-kit/types/src/routes' +test('Creates tsconfig path aliases from kit.alias', async () => { + const { kit } = await validate_config(() => + Promise.resolve({ + kit: { + alias: { + simpleKey: 'simple/value', + key: 'value', + 'key/*': 'some/other/value/*', + keyToFile: 'path/to/file.ts', + $routes: '.svelte-kit/types/src/routes' + } } - } - }); + }) + ); const { compilerOptions } = get_tsconfig(kit); @@ -31,16 +33,18 @@ test('Creates tsconfig path aliases from kit.alias', () => { }); }); -test('Allows generated tsconfig to be mutated', () => { - const { kit } = validate_config({ - kit: { - typescript: { - config: (config) => { - config.extends = 'some/other/tsconfig.json'; +test('Allows generated tsconfig to be mutated', async () => { + const { kit } = await validate_config(() => + Promise.resolve({ + kit: { + typescript: { + config: (config) => { + config.extends = 'some/other/tsconfig.json'; + } } } - } - }); + }) + ); const config = get_tsconfig(kit); @@ -48,17 +52,19 @@ test('Allows generated tsconfig to be mutated', () => { assert.equal(config.extends, 'some/other/tsconfig.json'); }); -test('Allows generated tsconfig to be replaced', () => { - const { kit } = validate_config({ - kit: { - typescript: { - config: (config) => ({ - ...config, - extends: 'some/other/tsconfig.json' - }) +test('Allows generated tsconfig to be replaced', async () => { + const { kit } = await validate_config(() => + Promise.resolve({ + kit: { + typescript: { + config: (config) => ({ + ...config, + extends: 'some/other/tsconfig.json' + }) + } } - } - }); + }) + ); const config = get_tsconfig(kit); @@ -66,14 +72,16 @@ test('Allows generated tsconfig to be replaced', () => { assert.equal(config.extends, 'some/other/tsconfig.json'); }); -test('Creates tsconfig include from kit.files', () => { - const { kit } = validate_config({ - kit: { - files: { - lib: 'app' +test('Creates tsconfig include from kit.files', async () => { + const { kit } = await validate_config(() => + Promise.resolve({ + kit: { + files: { + lib: 'app' + } } - } - }); + }) + ); const { include } = get_tsconfig(kit); diff --git a/packages/kit/src/exports/vite/utils.spec.js b/packages/kit/src/exports/vite/utils.spec.js index 45569b13f5c7..1fbc97f42d35 100644 --- a/packages/kit/src/exports/vite/utils.spec.js +++ b/packages/kit/src/exports/vite/utils.spec.js @@ -4,18 +4,20 @@ import { validate_config } from '../../core/config/index.js'; import { posixify } from '../../utils/filesystem.js'; import { get_config_aliases } from './utils.js'; -test('transform kit.alias to resolve.alias', () => { - const config = validate_config({ - kit: { - alias: { - simpleKey: 'simple/value', - key: 'value', - 'key/*': 'value/*', - $regexChar: 'windows\\path', - '$regexChar/*': 'windows\\path\\*' +test('transform kit.alias to resolve.alias', async () => { + const config = await validate_config(() => + Promise.resolve({ + kit: { + alias: { + simpleKey: 'simple/value', + key: 'value', + 'key/*': 'value/*', + $regexChar: 'windows\\path', + '$regexChar/*': 'windows\\path\\*' + } } - } - }); + }) + ); const aliases = get_config_aliases(config.kit);