diff --git a/.changeset/tidy-remotes-own.md b/.changeset/tidy-remotes-own.md new file mode 100644 index 000000000000..7f73c71f9ea3 --- /dev/null +++ b/.changeset/tidy-remotes-own.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: only treat files in node_modules as remote modules when the package has a peer dependency on @sveltejs/kit diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index dc97b757bb29..fefdefa7e0fe 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -35,7 +35,7 @@ export default defineConfig({ ## Overview -Remote functions are exported from a _remote module_, which is a file whose name includes a `remote` segment (e.g. `remote.ts` or `data.remote.ts`). They come in four flavours: `query`, `form`, `command` and `prerender`. On the client, the exported functions are transformed to `fetch` wrappers that invoke their counterparts on the server via a generated HTTP endpoint. Remote modules can be placed anywhere in your `src` directory (except inside a `server` directory — files here [cannot be imported into client-side code](server-only-modules)), and third-party libraries can provide them too. +Remote functions are exported from a _remote module_, which is a file whose name includes a `remote` segment (e.g. `remote.ts` or `data.remote.ts`). They come in four flavours: `query`, `form`, `command` and `prerender`. On the client, the exported functions are transformed to `fetch` wrappers that invoke their counterparts on the server via a generated HTTP endpoint. Remote modules can be placed anywhere in your `src` directory (except inside a `server` directory — files here [cannot be imported into client-side code](server-only-modules)), and third-party libraries that list `@sveltejs/kit` in their `peerDependencies` can provide them too. ## query diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 7e3b161c7505..01238fcb614d 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -20,12 +20,7 @@ import * as sync from '../../../core/sync/sync.js'; import { get_mime_lookup, get_runtime_base } from '../../../core/utils.js'; import '../../../utils/mime.js'; // extend mrmime with additional types (affects sirv too) import { compact } from '../../../utils/array.js'; -import { - is_chrome_devtools_request, - log_response, - not_found, - remote_module_pattern -} from '../utils.js'; +import { is_chrome_devtools_request, is_remote_module, log_response, not_found } from '../utils.js'; import { SCHEME } from '../../../utils/url.js'; import { check_feature } from '../../../utils/features.js'; import { escape_html } from '../../../utils/escape.js'; @@ -390,7 +385,7 @@ export async function dev(vite, vite_config, svelte_config, get_remotes, root, s file.startsWith(svelte_config.kit.files.routes + path.sep) || file.startsWith(svelte_config.kit.files.assets + path.sep) || (params_file && file === params_file) || - remote_module_pattern.test(file) || + is_remote_module(file) || // in contrast to server hooks, client hooks are written to the client manifest // and therefore need rebuilding when they are added/removed file.startsWith(svelte_config.kit.files.hooks.client) diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 726833c3cdb0..484127707446 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -35,6 +35,7 @@ import { preview } from './preview/index.js'; import { error_for_missing_config, get_config_aliases, + is_remote_module, normalize_id, remote_module_pattern, server_only_directory_pattern, @@ -490,6 +491,7 @@ function kit({ svelte_config }) { async handler(id, importer) { const resolved = await this.resolve(id, importer, { skipSelf: true }); if (!resolved) return { id, external: true }; + if (!is_remote_module(resolved.id)) return; // a servable /@fs url; 'absolute' stops rolldown relativizing it in the deps bundle return { id: to_fs(resolved.id), external: 'absolute' }; } @@ -920,6 +922,8 @@ function kit({ svelte_config }) { id: remote_module_pattern }, async handler(code, id) { + if (!is_remote_module(id)) return; + const file = posixify(path.relative(root, id)); const remote = { hash: hash(file), diff --git a/packages/kit/src/exports/vite/utils.js b/packages/kit/src/exports/vite/utils.js index 320a176efdee..e0babb501ae5 100644 --- a/packages/kit/src/exports/vite/utils.js +++ b/packages/kit/src/exports/vite/utils.js @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import path from 'node:path'; import { posixify } from '../../utils/os.js'; import { negotiate } from '../../utils/http.js'; @@ -151,6 +152,45 @@ export function normalize_id(id, aliases, cwd) { } export const remote_module_pattern = /[/.]remote(\.[^/]+)+$/; +/** @type {Map} */ +const remote_module_cache = new Map(); + +/** + * Whether `id` is a remote module. Files in node_modules only count if the + * package they belong to has a peer dependency on `@sveltejs/kit` + * @param {string} id + * @returns {boolean} + */ +export function is_remote_module(id) { + id = posixify(id); + if (!remote_module_pattern.test(id)) return false; + if (!id.includes('node_modules')) return true; + + const directory = path.dirname(id); + const cached = remote_module_cache.get(directory); + if (cached !== undefined) return cached; + + let current = directory; + + while (true) { + try { + const package_json = JSON.parse(fs.readFileSync(path.join(current, 'package.json'), 'utf8')); + + if (package_json.peerDependencies?.['@sveltejs/kit']) { + remote_module_cache.set(directory, true); + return true; + } + } catch {} + + const parent = path.dirname(current); + if (path.basename(current) === 'node_modules' || parent === current) break; + current = parent; + } + + remote_module_cache.set(directory, false); + return false; +} + export const server_only_module_pattern = /[/.]server(\.[^/]+)+$/; export const server_only_directory_pattern = /\/server\//; diff --git a/packages/kit/src/exports/vite/utils.spec.js b/packages/kit/src/exports/vite/utils.spec.js index 86db25bf23ef..405eb8db493a 100644 --- a/packages/kit/src/exports/vite/utils.spec.js +++ b/packages/kit/src/exports/vite/utils.spec.js @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { expect, test } from 'vitest'; import { validate_config } from '../../core/config/index.js'; @@ -6,6 +8,7 @@ import { dedent } from '../../core/sync/utils.js'; import { error_for_missing_config, get_config_aliases, + is_remote_module, remote_module_pattern, server_only_directory_pattern, server_only_module_pattern @@ -62,6 +65,61 @@ test('recognizes remote module filenames', () => { expect(remote_module_pattern.test('dir/remote/module.js')).toBe(false); }); +test('recognizes remote modules', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'sveltekit-remote-module-')); + + /** + * @param {string} directory + * @param {Record} contents + * @returns {void} + */ + function write_package_json(directory, contents) { + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(path.join(directory, 'package.json'), JSON.stringify(contents)); + } + + try { + expect(is_remote_module(path.join(temp, 'src', 'remote.js'))).toBe(true); + expect(is_remote_module(path.join(temp, 'src', 'remotely.js'))).toBe(false); + expect(is_remote_module('C:\\app\\src\\remote.js')).toBe(true); + + const plain = path.join(temp, 'node_modules', 'plain'); + write_package_json(plain, { + dependencies: { + '@sveltejs/kit': '*' + } + }); + expect(is_remote_module(path.join(plain, 'dist', 'remote.js'))).toBe(false); + + const with_peer = path.join(temp, 'node_modules', 'with-peer'); + write_package_json(with_peer, { + peerDependencies: { + '@sveltejs/kit': '*' + } + }); + expect(is_remote_module(path.join(with_peer, 'dist', 'remote.js'))).toBe(true); + + const with_stub = path.join(temp, 'node_modules', 'with-stub'); + write_package_json(with_stub, { + peerDependencies: { + '@sveltejs/kit': '*' + } + }); + write_package_json(path.join(with_stub, 'dist'), {}); + expect(is_remote_module(path.join(with_stub, 'dist', 'jwks', 'remote.js'))).toBe(true); + + const pnpm_package = path.join(temp, 'node_modules', '.pnpm', 'x@1', 'node_modules', 'x'); + write_package_json(pnpm_package, { + peerDependencies: { + '@sveltejs/kit': '*' + } + }); + expect(is_remote_module(path.join(pnpm_package, 'dist', 'remote.js'))).toBe(true); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + test('error_for_missing_config - simple single level config', () => { expect(() => error_for_missing_config('feature', 'adapter', 'true')).toThrow( dedent` diff --git a/packages/kit/test/apps/async/_test_dependencies/plain-lib/index.js b/packages/kit/test/apps/async/_test_dependencies/plain-lib/index.js new file mode 100644 index 000000000000..9776a321c5f1 --- /dev/null +++ b/packages/kit/test/apps/async/_test_dependencies/plain-lib/index.js @@ -0,0 +1 @@ +export { create_key_set } from './jwks/remote.js'; diff --git a/packages/kit/test/apps/async/_test_dependencies/plain-lib/jwks/remote.js b/packages/kit/test/apps/async/_test_dependencies/plain-lib/jwks/remote.js new file mode 100644 index 000000000000..401a09256b98 --- /dev/null +++ b/packages/kit/test/apps/async/_test_dependencies/plain-lib/jwks/remote.js @@ -0,0 +1,3 @@ +export function create_key_set(url) { + return `key set for ${url}`; +} diff --git a/packages/kit/test/apps/async/_test_dependencies/plain-lib/package.json b/packages/kit/test/apps/async/_test_dependencies/plain-lib/package.json new file mode 100644 index 000000000000..2f53775ab3a1 --- /dev/null +++ b/packages/kit/test/apps/async/_test_dependencies/plain-lib/package.json @@ -0,0 +1,8 @@ +{ + "name": "e2e-test-dep-plain", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "index.js", + "svelte": "./index.js" +} diff --git a/packages/kit/test/apps/async/_test_dependencies/remote-lib/package.json b/packages/kit/test/apps/async/_test_dependencies/remote-lib/package.json index 5cdb3672b492..f5832a14d750 100644 --- a/packages/kit/test/apps/async/_test_dependencies/remote-lib/package.json +++ b/packages/kit/test/apps/async/_test_dependencies/remote-lib/package.json @@ -4,5 +4,8 @@ "private": true, "type": "module", "main": "index.js", - "svelte": "./index.js" + "svelte": "./index.js", + "peerDependencies": { + "@sveltejs/kit": "*" + } } diff --git a/packages/kit/test/apps/async/package.json b/packages/kit/test/apps/async/package.json index d022b7a658a0..48682a57998f 100644 --- a/packages/kit/test/apps/async/package.json +++ b/packages/kit/test/apps/async/package.json @@ -22,6 +22,7 @@ "typescript": "catalog:", "valibot": "catalog:", "vite": "catalog:", + "e2e-test-dep-plain": "file:./_test_dependencies/plain-lib", "e2e-test-dep-remote": "file:./_test_dependencies/remote-lib" }, "imports": { diff --git a/packages/kit/test/apps/async/src/routes/plain-lib/+page.server.js b/packages/kit/test/apps/async/src/routes/plain-lib/+page.server.js new file mode 100644 index 000000000000..20460d6a3ad7 --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/plain-lib/+page.server.js @@ -0,0 +1,6 @@ +// @ts-ignore +import { create_key_set } from 'e2e-test-dep-plain'; + +export function load() { + return { key_set: create_key_set('https://example.com/jwks') }; +} diff --git a/packages/kit/test/apps/async/src/routes/plain-lib/+page.svelte b/packages/kit/test/apps/async/src/routes/plain-lib/+page.svelte new file mode 100644 index 000000000000..66adfb67a942 --- /dev/null +++ b/packages/kit/test/apps/async/src/routes/plain-lib/+page.svelte @@ -0,0 +1,5 @@ + + +

{data.key_set}

diff --git a/packages/kit/test/apps/async/test/client.test.js b/packages/kit/test/apps/async/test/client.test.js index 81bc2b6361d8..3dac77610e81 100644 --- a/packages/kit/test/apps/async/test/client.test.js +++ b/packages/kit/test/apps/async/test/client.test.js @@ -37,6 +37,11 @@ test.describe('remote functions', () => { await page.getByRole('button', { name: 'call remote function' }).click(); await expect(page.locator('p')).toHaveText('lib says client'); }); + + test('packages can contain ordinary remote.js files', async ({ page }) => { + await page.goto('/plain-lib'); + await expect(page.locator('p')).toHaveText('key set for https://example.com/jwks'); + }); }); // have to run in serial because commands mutate in-memory data on the server (should fix this at some point) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d296f27ddb2..152e067494b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -692,9 +692,12 @@ importers: '@sveltejs/vite-plugin-svelte': specifier: 'catalog:' version: 7.0.0(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vite@8.1.5(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.4.2)(yaml@2.9.0)) + e2e-test-dep-plain: + specifier: file:./_test_dependencies/plain-lib + version: file:packages/kit/test/apps/async/_test_dependencies/plain-lib e2e-test-dep-remote: specifier: file:./_test_dependencies/remote-lib - version: file:packages/kit/test/apps/async/_test_dependencies/remote-lib + version: file:packages/kit/test/apps/async/_test_dependencies/remote-lib(@sveltejs/kit@packages+kit) svelte: specifier: 'catalog:' version: 5.56.3(@typescript-eslint/types@8.61.1) @@ -3131,8 +3134,13 @@ packages: e2e-test-dep-cjs-only@file:packages/kit/test/apps/dev-only/_test_dependencies/cjs-only: resolution: {directory: packages/kit/test/apps/dev-only/_test_dependencies/cjs-only, type: directory} + e2e-test-dep-plain@file:packages/kit/test/apps/async/_test_dependencies/plain-lib: + resolution: {directory: packages/kit/test/apps/async/_test_dependencies/plain-lib, type: directory} + e2e-test-dep-remote@file:packages/kit/test/apps/async/_test_dependencies/remote-lib: resolution: {directory: packages/kit/test/apps/async/_test_dependencies/remote-lib, type: directory} + peerDependencies: + '@sveltejs/kit': '*' emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -5740,7 +5748,11 @@ snapshots: e2e-test-dep-cjs-only@file:packages/kit/test/apps/dev-only/_test_dependencies/cjs-only: {} - e2e-test-dep-remote@file:packages/kit/test/apps/async/_test_dependencies/remote-lib: {} + e2e-test-dep-plain@file:packages/kit/test/apps/async/_test_dependencies/plain-lib: {} + + e2e-test-dep-remote@file:packages/kit/test/apps/async/_test_dependencies/remote-lib(@sveltejs/kit@packages+kit): + dependencies: + '@sveltejs/kit': link:packages/kit emoji-regex@8.0.0: {}