diff --git a/.changeset/whole-symbols-cover.md b/.changeset/whole-symbols-cover.md new file mode 100644 index 000000000000..9ec2e3f36e99 --- /dev/null +++ b/.changeset/whole-symbols-cover.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: allow external remote functions (such as from `node_modules`) to be whitelisted diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 1a979613bba6..f69c32b9d6a3 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -98,6 +98,9 @@ const get_defaults = (prefix = '') => ({ moduleExtensions: ['.js', '.ts'], output: { preloadStrategy: 'modulepreload', bundleStrategy: 'split' }, outDir: join(prefix, '.svelte-kit'), + remoteFunctions: { + allowedPaths: [] + }, router: { type: 'pathname', resolution: 'client' diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index 8920514dd402..757a1957afdd 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -264,6 +264,10 @@ const options = object( }) }), + remoteFunctions: object({ + allowedPaths: string_array([]) + }), + router: object({ type: list(['pathname', 'hash']), resolution: list(['client', 'server']) diff --git a/packages/kit/src/core/sync/create_manifest_data/index.js b/packages/kit/src/core/sync/create_manifest_data/index.js index efc55fc5c574..e13bdbec1699 100644 --- a/packages/kit/src/core/sync/create_manifest_data/index.js +++ b/packages/kit/src/core/sync/create_manifest_data/index.js @@ -480,15 +480,20 @@ function create_remotes(config, cwd) { /** @type {import('types').ManifestData['remotes']} */ const remotes = []; - // TODO could files live in other directories, including node_modules? - for (const file of walk(config.kit.files.src)) { - if (extensions.some((ext) => file.endsWith(ext))) { - const posixified = posixify(path.relative(cwd, `${config.kit.files.src}/${file}`)); - - remotes.push({ - hash: hash(posixified), - file: posixified - }); + const externals = config.kit.remoteFunctions.allowedPaths.map((dir) => path.resolve(dir)); + + for (const dir of [config.kit.files.src, ...externals]) { + if (!fs.existsSync(dir)) continue; + + for (const file of walk(dir)) { + if (extensions.some((ext) => file.endsWith(ext))) { + const posixified = posixify(path.relative(cwd, `${dir}/${file}`)); + + remotes.push({ + hash: hash(posixified), + file: posixified + }); + } } } diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 00a842867146..6efc7296832a 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -675,6 +675,15 @@ export interface KitConfig { */ origin?: string; }; + remoteFunctions?: { + /** + * A list of external paths that are allowed to provide remote functions. + * By default, remote functions are only allowed inside the `routes` and `lib` folders. + * + * Accepts absolute paths or paths relative to the project root. + */ + allowedPaths?: string[]; + }; router?: { /** * What type of client-side router to use. diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index a5f211efa9af..2a6a4d1370f4 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -29,9 +29,10 @@ const vite_css_query_regex = /(?:\?|&)(?:raw|url|inline)(?:&|$)/; * @param {import('vite').ViteDevServer} vite * @param {import('vite').ResolvedConfig} vite_config * @param {import('types').ValidatedConfig} svelte_config + * @param {(manifest_data: import('types').ManifestData) => void} set_manifest_data * @return {Promise void>>} */ -export async function dev(vite, vite_config, svelte_config) { +export async function dev(vite, vite_config, svelte_config, set_manifest_data) { installPolyfills(); const async_local_storage = new AsyncLocalStorage(); @@ -108,6 +109,7 @@ export async function dev(vite, vite_config, svelte_config) { function update_manifest() { try { ({ manifest_data } = sync.create(svelte_config)); + set_manifest_data(manifest_data); if (manifest_error) { manifest_error = null; diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 002ef57b2b57..318523fb2ad2 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -34,7 +34,7 @@ import { sveltekit_server } from './module_ids.js'; import { import_peer } from '../../utils/import.js'; -import { compact } from '../../utils/array.js'; +import { compact, conjoin } from '../../utils/array.js'; import { build_remotes, treeshake_prerendered_remotes } from './build/build_remote.js'; import { should_ignore } from './static_analysis/utils.js'; @@ -706,6 +706,24 @@ async function kit({ svelte_config }) { } } + if (!manifest_data.remotes.some((remote) => remote.hash === hashed)) { + const relative_path = path.relative(dev_server.config.root, id); + const fn_names = [...remotes.values()].flat().map((name) => `"${name}"`); + const has_multiple = fn_names.length !== 1; + console.warn( + colors + .bold() + .yellow( + `Remote function${has_multiple ? 's' : ''} ${conjoin(fn_names)} from ${relative_path} ${has_multiple ? 'are' : 'is'} not accessible by default.` + ) + ); + console.warn( + colors.yellow( + `To whitelist ${has_multiple ? 'them' : 'it'}, add "${path.dirname(relative_path)}" to \`kit.remoteFunctions.allowedPaths\` in \`svelte.config.js\`.` + ) + ); + } + let namespace = '__remote'; let uid = 1; while (remotes.has(namespace)) namespace = `__remote${uid++}`; @@ -880,7 +898,7 @@ async function kit({ svelte_config }) { * @see https://vitejs.dev/guide/api-plugin.html#configureserver */ async configureServer(vite) { - return await dev(vite, vite_config, svelte_config); + return await dev(vite, vite_config, svelte_config, (data) => (manifest_data = data)); }, /** diff --git a/packages/kit/src/runtime/server/cookie.js b/packages/kit/src/runtime/server/cookie.js index 49da47b6eb96..d6e848831749 100644 --- a/packages/kit/src/runtime/server/cookie.js +++ b/packages/kit/src/runtime/server/cookie.js @@ -1,4 +1,5 @@ import { parse, serialize } from 'cookie'; +import { conjoin } from '../../utils/array.js'; import { normalize_path, resolve } from '../../utils/url.js'; import { add_data_suffix } from '../pathname.js'; import { text_encoder } from '../utils.js'; @@ -287,11 +288,3 @@ export function add_cookies_to_headers(headers, cookies) { } } } - -/** - * @param {string[]} array - */ -function conjoin(array) { - if (array.length <= 2) return array.join(' and '); - return `${array.slice(0, -1).join(', ')} and ${array.at(-1)}`; -} diff --git a/packages/kit/src/utils/array.js b/packages/kit/src/utils/array.js index 08f93845149b..1d6dc4b6abce 100644 --- a/packages/kit/src/utils/array.js +++ b/packages/kit/src/utils/array.js @@ -7,3 +7,12 @@ export function compact(arr) { return arr.filter(/** @returns {val is NonNullable} */ (val) => val != null); } + +/** + * Joins an array of strings with commas and 'and'. + * @param {string[]} array + */ +export function conjoin(array) { + if (array.length <= 2) return array.join(' and '); + return `${array.slice(0, -1).join(', ')} and ${array.at(-1)}`; +} diff --git a/packages/kit/test/apps/basics/external-remotes/allowed.remote.js b/packages/kit/test/apps/basics/external-remotes/allowed.remote.js new file mode 100644 index 000000000000..ea5b438b138c --- /dev/null +++ b/packages/kit/test/apps/basics/external-remotes/allowed.remote.js @@ -0,0 +1,3 @@ +import { query } from '$app/server'; + +export const external = query(async () => 'external success'); diff --git a/packages/kit/test/apps/basics/src/routes/remote/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/+page.svelte index 64e3183ac235..28daca5271e6 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/+page.svelte @@ -8,6 +8,7 @@ set_count_server, resolve_deferreds } from './query-command.remote.js'; + import { external } from '../../../external-remotes/allowed.remote.js'; let { data } = $props(); @@ -15,6 +16,8 @@ let release; const count = browser ? get_count() : null; // so that we get a remote request in the browser + + const external_result = browser ? external() : null;

{data.echo_result}

@@ -85,4 +88,12 @@ + +

+ {#await external_result then result} + {result} + {:catch error} + {error} + {/await} +

diff --git a/packages/kit/test/apps/basics/svelte.config.js b/packages/kit/test/apps/basics/svelte.config.js index 2410ff83d57f..7dc7981588cf 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -43,6 +43,9 @@ const config = { }, router: { resolution: /** @type {'client' | 'server'} */ (process.env.ROUTER_RESOLUTION) || 'client' + }, + remoteFunctions: { + allowedPaths: ['./external-remotes'] } } }; diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 1704a8c7da32..7db2939ddd6b 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1667,7 +1667,7 @@ test.describe('remote functions', () => { await page.goto('/remote'); await expect(page.locator('#count-result')).toHaveText('0 / 0 (false)'); // only the calls in the template are done, not the one in the load function - expect(request_count).toBe(2); + expect(request_count).toBe(3); }); test('command returns correct sum but does not refresh data by default', async ({ page }) => { @@ -1804,7 +1804,7 @@ test.describe('remote functions', () => { await page.click('#refresh-all'); await page.waitForTimeout(100); // allow things to rerun - expect(request_count).toBe(3); + expect(request_count).toBe(4); }); test('refreshAll({ includeLoadFunctions: false }) reloads remote functions only', async ({ @@ -1818,7 +1818,7 @@ test.describe('remote functions', () => { await page.click('#refresh-remote-only'); await page.waitForTimeout(100); // allow things to rerun - expect(request_count).toBe(2); + expect(request_count).toBe(3); }); test('command tracks pending state', async ({ page }) => { @@ -1860,6 +1860,11 @@ test.describe('remote functions', () => { await expect(page.locator('p')).toHaveText('success'); }); + test('external remote whitelisting works', async ({ page }) => { + await page.goto('/remote'); + await expect(page.locator('#external-allowed')).toHaveText('external success'); + }); + test('command pending state is tracked correctly', async ({ page }) => { await page.goto('/remote'); diff --git a/packages/kit/test/apps/basics/vite.config.js b/packages/kit/test/apps/basics/vite.config.js index d6742f261346..929c26dae4a5 100644 --- a/packages/kit/test/apps/basics/vite.config.js +++ b/packages/kit/test/apps/basics/vite.config.js @@ -15,7 +15,7 @@ const config = { plugins: [sveltekit()], server: { fs: { - allow: [path.resolve('../../../src')] + allow: [path.resolve('../../../src'), path.resolve('external-remotes')] } } }; diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index efb850a3f367..4e83147ed265 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -652,6 +652,15 @@ declare module '@sveltejs/kit' { */ origin?: string; }; + remoteFunctions?: { + /** + * A list of external paths that are allowed to provide remote functions. + * By default, remote functions are only allowed inside the `routes` and `lib` folders. + * + * Accepts absolute paths or paths relative to the project root. + */ + allowedPaths?: string[]; + }; router?: { /** * What type of client-side router to use.