From 57c2c328159336a781ac19757d91ddef747cbb85 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 7 Jul 2026 23:18:30 +0200 Subject: [PATCH 1/4] feat: warn when using a `.server.` file or file inside a `server` directory without importing a server-only module #12529 was opened a while ago, and since then things have changed. We do check _all_ files now, but we do not error on `.server.` files or files inside `server` directories if they're outside the cwd. We decided we want to keep that logic. Things outside it (or inside node_modules) still will fail if they e.g. import `$app/server` or `$app/env/private`. We decided that this is the desired behavior. This PR therefore only adds additional validation to `@sveltejs/package` to hint at this, and thereby closes #12529 We said we may investigate creating an npm package which you can also use and establish it as somewhat of a standard across the Vite ecosystem, but that will happen separately. --- .changeset/cute-buses-camp.md | 5 ++ packages/package/src/validate.js | 24 +++++++- packages/package/test/index.spec.js | 95 +++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 .changeset/cute-buses-camp.md diff --git a/.changeset/cute-buses-camp.md b/.changeset/cute-buses-camp.md new file mode 100644 index 000000000000..3ca0727cc833 --- /dev/null +++ b/.changeset/cute-buses-camp.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/package': minor +--- + +feat: warn when using a `.server.` file or file inside a `server` directory without importing a server-only module diff --git a/packages/package/src/validate.js b/packages/package/src/validate.js index 1fdc4eddba48..5966639f9321 100644 --- a/packages/package/src/validate.js +++ b/packages/package/src/validate.js @@ -51,6 +51,8 @@ export function _create_validator(options) { const imports = new Set(); let uses_import_meta = false; let has_svelte_files = false; + /** @type {Map} */ + const unprotected_server_only_files = new Map(); /** * Checks a file content for problematic imports and things like `import.meta` @@ -65,13 +67,20 @@ export function _create_validator(options) { const file_imports = [ ...content.matchAll(/from\s+('|")([^"';,]+?)\1/g), - ...content.matchAll(/import\s*\(\s*('|")([^"';,]+?)\1\s*\)/g) + ...content.matchAll(/import\s*\(\s*('|")([^"';,]+?)\1\s*\)/g), + ...content.matchAll(/import\s+('|")([^"';,]+?)\1/g) ]; + let has_guard_import = false; for (const [, , import_path] of file_imports) { if (import_path.startsWith('$app/')) { imports.add(import_path); } + if (import_path === '$app/server' || import_path === '$app/env/private') { + has_guard_import = true; + } } + const is_server_only = name.includes('.server.') || /(^|\/)server\//.test(name); + unprotected_server_only_files.set(name, is_server_only && !has_guard_import); } /** @@ -149,6 +158,19 @@ export function _create_validator(options) { ); } + const unprotected = [...unprotected_server_only_files] + .filter(([, is_unprotected]) => is_unprotected) + .map(([name]) => name); + if (unprotected.length > 0) { + const list = unprotected.map((name) => `- ${name}`).join('\n'); + warnings.push( + `The following server-only files do not import \`$app/server\` or \`$app/env/private\`:\n${list}\n` + + 'These files will not be blocked from being imported on the client. ' + + 'If you intend to use this package within a SvelteKit application and want to prevent this, add `import "$app/server"` or an import from `$app/env/private` to each file — both throw when imported on the client.\n' + + 'These files were deemed server-only because they either contain `.server.` in their filename or are located in a `server` directory, which have special meaning within SvelteKit apps.' + ); + } + return warnings; } diff --git a/packages/package/test/index.spec.js b/packages/package/test/index.spec.js index e66f13130e91..3db9d0f4713e 100644 --- a/packages/package/test/index.spec.js +++ b/packages/package/test/index.spec.js @@ -349,6 +349,101 @@ test('validates package (all ok 2)', () => { expect(warnings.length).toEqual(0); }); +test('warns about .server. files that do not import $app/server', () => { + const { analyse_code, validate } = _create_validator({ + config: {}, + cwd: '', + input: '', + output: '', + preserve_output: false, + types: true + }); + analyse_code('utils.server.js', 'export const x = 1;'); + const warnings = validate({ + exports: { '.': { svelte: './dist/index.js' } }, + peerDependencies: { svelte: '^3.55.0' } + }); + + has_warnings(warnings, [ + 'The following server-only files do not import `$app/server` or `$app/env/private`:\n- utils.server.js\n' + + 'These files will not be blocked from being imported on the client.' + ]); +}); + +test('warns about files in a server directory that do not import $app/server', () => { + const { analyse_code, validate } = _create_validator({ + config: {}, + cwd: '', + input: '', + output: '', + preserve_output: false, + types: true + }); + analyse_code('server/db.js', 'export const db = null;'); + const warnings = validate({ + exports: { '.': { svelte: './dist/index.js' } }, + peerDependencies: { svelte: '^3.55.0' } + }); + + has_warnings(warnings, [ + 'The following server-only files do not import `$app/server` or `$app/env/private`:\n- server/db.js\n' + + 'These files will not be blocked from being imported on the client.' + ]); +}); + +test('does not warn about server files that import $app/server', () => { + const { analyse_code, validate } = _create_validator({ + config: {}, + cwd: '', + input: '', + output: '', + preserve_output: false, + types: true + }); + analyse_code('utils.server.js', `import '$app/server';`); + const warnings = validate({ + exports: { '.': { svelte: './dist/index.js' } }, + peerDependencies: { svelte: '^3.55.0', '@sveltejs/kit': '^2.0.0' } + }); + + expect(warnings.length).toEqual(0); +}); + +test('does not warn about server files that import $app/env/private', () => { + const { analyse_code, validate } = _create_validator({ + config: {}, + cwd: '', + input: '', + output: '', + preserve_output: false, + types: true + }); + analyse_code('server/db.js', `import { env } from '$app/env/private';`); + const warnings = validate({ + exports: { '.': { svelte: './dist/index.js' } }, + peerDependencies: { svelte: '^3.55.0', '@sveltejs/kit': '^2.0.0' } + }); + + expect(warnings.length).toEqual(0); +}); + +test('does not warn about server files when there are none', () => { + const { validate } = _create_validator({ + config: {}, + cwd: '', + input: '', + output: '', + preserve_output: false, + types: true + }); + const warnings = validate({ + exports: { '.': { svelte: './dist/index.js' } }, + peerDependencies: { svelte: '^3.55.0' } + }); + + expect(warnings.length).toEqual(0); +}); + test('create package with preserved output', async () => { const output = join(import.meta.dirname, 'fixtures', 'preserve-output', 'dist'); rimraf(output); From b0601d423e97682f1a79de7c7505a58203275fa2 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 8 Jul 2026 23:51:17 +0200 Subject: [PATCH 2/4] transitive --- packages/package/src/validate.js | 72 ++++++++++++++++++++++--- packages/package/test/index.spec.js | 83 +++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 7 deletions(-) diff --git a/packages/package/src/validate.js b/packages/package/src/validate.js index 5966639f9321..197e223a3256 100644 --- a/packages/package/src/validate.js +++ b/packages/package/src/validate.js @@ -1,4 +1,5 @@ import { styleText } from 'node:util'; +import * as path from 'node:path'; import { load_pkg_json } from './config.js'; /** @@ -51,8 +52,8 @@ export function _create_validator(options) { const imports = new Set(); let uses_import_meta = false; let has_svelte_files = false; - /** @type {Map} */ - const unprotected_server_only_files = new Map(); + /** @type {Map} */ + const analysed_files = new Map(); /** * Checks a file content for problematic imports and things like `import.meta` @@ -71,6 +72,8 @@ export function _create_validator(options) { ...content.matchAll(/import\s+('|")([^"';,]+?)\1/g) ]; let has_guard_import = false; + /** @type {string[]} */ + const relative_imports = []; for (const [, , import_path] of file_imports) { if (import_path.startsWith('$app/')) { imports.add(import_path); @@ -78,9 +81,62 @@ export function _create_validator(options) { if (import_path === '$app/server' || import_path === '$app/env/private') { has_guard_import = true; } + if (import_path.startsWith('./') || import_path.startsWith('../')) { + relative_imports.push(import_path); + } + } + analysed_files.set(name, { has_guard_import, relative_imports }); + } + + /** + * Resolves a relative import path to a file name that was seen by `analyse_code`, + * trying common extension swaps (e.g. `.js` -> `.ts`) since transpiled content + * references output paths while files are keyed by their source name. + * @param {string} importer The file name of the importing file + * @param {string} import_path The relative import path + * @returns {string | undefined} + */ + function resolve_relative_import(importer, import_path) { + const resolved = path.posix.join(path.posix.dirname(importer), import_path); + if (analysed_files.has(resolved)) return resolved; + const ext = path.posix.extname(resolved); + if (ext === '.js' || ext === '.ts') { + const base = resolved.slice(0, -ext.length); + for (const candidate of ['.js', '.ts']) { + const candidate_path = base + candidate; + if (analysed_files.has(candidate_path)) return candidate_path; + } + } else { + return analysed_files.has(resolved) ? resolved : undefined; + } + } + + /** + * Walks the chain of relative imports starting from `name` to check whether any + * transitively reachable file imports `$app/server` or `$app/env/private`. + * @param {string} name + * @returns {boolean} + */ + function reaches_guard_import(name) { + /** @type {Set} */ + const visited = new Set(); + /** + * @param {string} current + * @returns {boolean} + */ + function visit(current) { + if (visited.has(current)) return false; + visited.add(current); + const file = analysed_files.get(current); + if (!file) return false; + if (file.has_guard_import) return true; + for (const import_path of file.relative_imports) { + const resolved = resolve_relative_import(current, import_path); + if (resolved && visit(resolved)) return true; + } + return false; } - const is_server_only = name.includes('.server.') || /(^|\/)server\//.test(name); - unprotected_server_only_files.set(name, is_server_only && !has_guard_import); + return visit(name); } /** @@ -158,9 +214,11 @@ export function _create_validator(options) { ); } - const unprotected = [...unprotected_server_only_files] - .filter(([, is_unprotected]) => is_unprotected) - .map(([name]) => name); + const unprotected = [...analysed_files.keys()].filter((name) => { + const is_server_only = + path.basename(name).includes('.server.') || /(^|\/)server\//.test(name); + return is_server_only && !reaches_guard_import(name); + }); if (unprotected.length > 0) { const list = unprotected.map((name) => `- ${name}`).join('\n'); warnings.push( diff --git a/packages/package/test/index.spec.js b/packages/package/test/index.spec.js index 3db9d0f4713e..563df54b68c9 100644 --- a/packages/package/test/index.spec.js +++ b/packages/package/test/index.spec.js @@ -444,6 +444,89 @@ test('does not warn about server files when there are none', () => { expect(warnings.length).toEqual(0); }); +test('does not warn about server files that transitively import $app/env/private', () => { + const { analyse_code, validate } = _create_validator({ + config: {}, + cwd: '', + input: '', + output: '', + preserve_output: false, + types: true + }); + analyse_code('server/api.js', `import { db } from './db.js';`); + analyse_code( + 'server/db.js', + `import { DATABASE_URL } from '$app/env/private';\nimport { createClient } from 'database-library';\nexport const db = createClient(DATABASE_URL);` + ); + const warnings = validate({ + exports: { '.': { svelte: './dist/index.js' } }, + peerDependencies: { svelte: '^3.55.0', '@sveltejs/kit': '^2.0.0' } + }); + + expect(warnings.length).toEqual(0); +}); + +test('does not warn about .server. files that transitively import $app/server', () => { + const { analyse_code, validate } = _create_validator({ + config: {}, + cwd: '', + input: '', + output: '', + preserve_output: false, + types: true + }); + analyse_code('a.server.js', `export * from './b.js';`); + analyse_code('b.js', `import { c } from './c.js';`); + analyse_code('c.js', `import '$app/server';`); + const warnings = validate({ + exports: { '.': { svelte: './dist/index.js' } }, + peerDependencies: { svelte: '^3.55.0', '@sveltejs/kit': '^2.0.0' } + }); + + expect(warnings.length).toEqual(0); +}); + +test('warns about server files whose transitive imports do not reach a guard import', () => { + const { analyse_code, validate } = _create_validator({ + config: {}, + cwd: '', + input: '', + output: '', + preserve_output: false, + types: true + }); + analyse_code('server/api.js', `import { db } from './db.js';`); + analyse_code('server/db.js', `import { createClient } from 'database-library';`); + const warnings = validate({ + exports: { '.': { svelte: './dist/index.js' } }, + peerDependencies: { svelte: '^3.55.0', '@sveltejs/kit': '^2.0.0' } + }); + + has_warnings(warnings, [ + 'The following server-only files do not import `$app/server` or `$app/env/private`:\n- server/api.js\n- server/db.js\n' + + 'These files will not be blocked from being imported on the client.' + ]); +}); + +test('does not warn about server files that import a .ts file which imports $app/server', () => { + const { analyse_code, validate } = _create_validator({ + config: {}, + cwd: '', + input: '', + output: '', + preserve_output: false, + types: true + }); + analyse_code('server/api.js', `import { db } from './db.js';`); + analyse_code('server/db.ts', `import '$app/server';\nexport const db = 1;`); + const warnings = validate({ + exports: { '.': { svelte: './dist/index.js' } }, + peerDependencies: { svelte: '^3.55.0', '@sveltejs/kit': '^2.0.0' } + }); + + expect(warnings.length).toEqual(0); +}); + test('create package with preserved output', async () => { const output = join(import.meta.dirname, 'fixtures', 'preserve-output', 'dist'); rimraf(output); From 0fa99758974094bbe2f8eed210a22654f7935f52 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:26:48 +0200 Subject: [PATCH 3/4] Update packages/package/src/validate.js Co-authored-by: Rich Harris --- packages/package/src/validate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/package/src/validate.js b/packages/package/src/validate.js index 197e223a3256..22f008d61498 100644 --- a/packages/package/src/validate.js +++ b/packages/package/src/validate.js @@ -224,7 +224,7 @@ export function _create_validator(options) { warnings.push( `The following server-only files do not import \`$app/server\` or \`$app/env/private\`:\n${list}\n` + 'These files will not be blocked from being imported on the client. ' + - 'If you intend to use this package within a SvelteKit application and want to prevent this, add `import "$app/server"` or an import from `$app/env/private` to each file — both throw when imported on the client.\n' + + 'If you intend to use this package within a SvelteKit application and want to prevent this, add `import \'$app/server\'` which will throw when imported on the client.\n' + 'These files were deemed server-only because they either contain `.server.` in their filename or are located in a `server` directory, which have special meaning within SvelteKit apps.' ); } From 09c3b443d8313c76b85a24dbff44c89b1dddcf3d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Jul 2026 17:34:02 +0000 Subject: [PATCH 4/4] chore: autofix lint --- packages/package/src/validate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/package/src/validate.js b/packages/package/src/validate.js index 22f008d61498..069d5a123a2b 100644 --- a/packages/package/src/validate.js +++ b/packages/package/src/validate.js @@ -224,7 +224,7 @@ export function _create_validator(options) { warnings.push( `The following server-only files do not import \`$app/server\` or \`$app/env/private\`:\n${list}\n` + 'These files will not be blocked from being imported on the client. ' + - 'If you intend to use this package within a SvelteKit application and want to prevent this, add `import \'$app/server\'` which will throw when imported on the client.\n' + + "If you intend to use this package within a SvelteKit application and want to prevent this, add `import '$app/server'` which will throw when imported on the client.\n" + 'These files were deemed server-only because they either contain `.server.` in their filename or are located in a `server` directory, which have special meaning within SvelteKit apps.' ); }