diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js index eaaf9e6cd38e..c3c2fbcfbc75 100644 --- a/packages/kit/src/core/generate_manifest/index.js +++ b/packages/kit/src/core/generate_manifest/index.js @@ -8,6 +8,7 @@ import { compact } from '../../utils/array.js'; import { join_relative } from '../../utils/filesystem.js'; import { dedent } from '../sync/utils.js'; import { find_server_assets } from './find_server_assets.js'; +import { hash } from '../../runtime/hash.js'; // TODO move this function import { uneval } from 'devalue'; /** @@ -100,6 +101,9 @@ export function generate_manifest({ build_data, prerendered, relative_path, rout nodes: [ ${(node_paths).map(loader).join(',\n')} ], + remotes: { + ${build_data.manifest_data.remotes.map((filename) => `'${hash(filename)}': ${loader(join_relative(relative_path, resolve_symlinks(build_data.server_manifest, filename).chunk.file))}`).join('\n\t\t\t\t\t')} + }, routes: [ ${routes.map(route => { if (!route.page && !route.endpoint) return; diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index 25bd403f1eb8..5d8c9e10346b 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -61,7 +61,8 @@ async function analyse({ /** @type {import('types').ServerMetadata} */ const metadata = { nodes: [], - routes: new Map() + routes: new Map(), + remotes: new Map() }; const nodes = await Promise.all(manifest._.nodes.map((loader) => loader())); @@ -143,6 +144,17 @@ async function analyse({ }); } + // analyse remotes + for (const remote of Object.keys(manifest._.remotes)) { + const modules = await manifest._.remotes[remote](); + const exports = new Map(); + for (const [name, value] of Object.entries(modules)) { + const type = value.__type ?? 'other'; + exports.set(type, (exports.get(type) ?? []).concat(name)); + } + metadata.remotes.set(remote, exports); + } + return metadata; } 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 037f8dc8f6ba..ae10597b529a 100644 --- a/packages/kit/src/core/sync/create_manifest_data/index.js +++ b/packages/kit/src/core/sync/create_manifest_data/index.js @@ -4,7 +4,7 @@ import process from 'node:process'; import colors from 'kleur'; import { lookup } from 'mrmime'; import { list_files, runtime_directory } from '../../utils.js'; -import { posixify, resolve_entry } from '../../../utils/filesystem.js'; +import { posixify, resolve_entry, walk } from '../../../utils/filesystem.js'; import { parse_route_id } from '../../../utils/routing.js'; import { sort_routes } from './sort.js'; import { isSvelte5Plus } from '../utils.js'; @@ -27,6 +27,7 @@ export default function create_manifest_data({ const hooks = create_hooks(config, cwd); const matchers = create_matchers(config, cwd); const { nodes, routes } = create_routes_and_nodes(cwd, config, fallback); + const remotes = create_remotes(config, cwd); for (const route of routes) { for (const param of route.params) { @@ -41,6 +42,7 @@ export default function create_manifest_data({ hooks, matchers, nodes, + remotes, routes }; } @@ -465,6 +467,23 @@ function create_routes_and_nodes(cwd, config, fallback) { }; } +/** + * @param {import('types').ValidatedConfig} config + * @param {string} cwd + */ +function create_remotes(config, cwd) { + const extensions = config.kit.moduleExtensions.map((ext) => `.remote${ext}`); + + // TODO could files live in other directories, including node_modules? + return [config.kit.files.lib, config.kit.files.routes].flatMap((dir) => + fs.existsSync(dir) + ? walk(dir) + .filter((file) => extensions.some((ext) => file.endsWith(ext))) + .map((file) => posixify(`${dir}/${file}`)) + : [] + ); +} + /** * @param {string} project_relative * @param {string} file diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index 4d2bb75223f3..4ed2b9c9fa2e 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -262,3 +262,74 @@ export function normalizeUrl(url) { } }; } + +/** + * @template {(formData: FormData) => any} T + * @param {T} fn + * @returns {T} + */ +export function formAction(fn) { + // Better safe than sorry: Seal these properties to prevent modification + Object.defineProperty(fn, 'method', { + value: 'POST', + writable: false, + enumerable: true, + configurable: false + }); + Object.defineProperty(fn, '__type', { + value: 'formAction', + writable: false, + enumerable: true, + configurable: false + }); + let set = false; + Object.defineProperty(fn, '_set_action', { + /** @param {string} action */ + value: (action) => { + if (set) return; + set = true; + Object.defineProperty(fn, 'action', { + value: action, + writable: false, + enumerable: true, + configurable: false + }); + }, + writable: false, + enumerable: true, + configurable: false + }); + return fn; +} + +/** + * @template {(...args: any[]) => any} T + * @param {T} fn + * @returns {T} + */ +export function query(fn) { + // Better safe than sorry: Seal these properties to prevent modification + Object.defineProperty(fn, '__type', { + value: 'query', + writable: false, + enumerable: true, + configurable: false + }); + return fn; +} + +/** + * @template {(...args: any[]) => any} T + * @param {T} fn + * @returns {T} + */ +export function action(fn) { + // Better safe than sorry: Seal these properties to prevent modification + Object.defineProperty(fn, '__type', { + value: 'action', + writable: false, + enumerable: true, + configurable: false + }); + return fn; +} diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 5d41031b1613..1a566e4f9720 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1324,6 +1324,8 @@ export interface SSRManifest { _: { client: NonNullable; nodes: SSRNodeLoader[]; + /** hashed filename -> import to that file */ + remotes: Record Promise>; routes: SSRRoute[]; prerendered_routes: Set; matchers: () => Promise>; diff --git a/packages/kit/src/exports/vite/build/build_remotes.js b/packages/kit/src/exports/vite/build/build_remotes.js new file mode 100644 index 000000000000..092544212f14 --- /dev/null +++ b/packages/kit/src/exports/vite/build/build_remotes.js @@ -0,0 +1,44 @@ +import fs from 'fs'; +import path from 'path'; +import { dedent } from '../../../core/sync/utils.js'; + +/** + * Adjusts the remote entry points such that that they include the correct action URL if needed + * @param {import('types').ServerMetadata} metadata + * @param {import('types').ValidatedConfig} svelte_config + * @param {string} out + */ +export function build_remotes(metadata, svelte_config, out) { + for (const [name, exports] of metadata.remotes) { + const file_path = `${out}/server/remote/${name}.js`; + const sibling_file_path = file_path + '__internal.js'; + const merged_exports = [...exports.values()].flatMap(names => names); + + fs.copyFileSync(file_path, sibling_file_path); + fs.writeFileSync( + file_path, + create_public_remote_file(merged_exports, `./${path.basename(sibling_file_path)}`, name, svelte_config), + 'utf-8' + ); + } +} + +/** + * @param {string[]} exports + * @param {string} id + * @param {string} hash + * @param {import('types').ValidatedConfig} svelte_config + */ +export function create_public_remote_file(exports, id, hash, svelte_config) { + return dedent` + import { ${exports.join(', ')} } from '${id}'; + let $$_exports = {${exports.join(',')}}; + for (const key in $$_exports) { + const fn = $$_exports[key]; + if (fn.__type === 'formAction') { + fn._set_action('${svelte_config.kit.paths.base}/${svelte_config.kit.appDir}/remote/${hash}/' + key); + } + } + export { ${exports.join(', ')} }; +`; +} diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 7049d8910508..ac009a77b19f 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -19,6 +19,7 @@ import { 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'; +import { hash } from '../../../runtime/hash.js'; const cwd = process.cwd(); // vite-specifc queries that we should skip handling for css urls @@ -248,6 +249,12 @@ export async function dev(vite, vite_config, svelte_config) { }; }), prerendered_routes: new Set(), + remotes: Object.fromEntries( + manifest_data.remotes.map((filename) => [ + hash(filename), + () => vite.ssrLoadModule(filename) + ]) + ), routes: compact( manifest_data.routes.map((route) => { if (!route.page && !route.endpoint) return null; @@ -313,6 +320,7 @@ export async function dev(vite, vite_config, svelte_config) { if ( file.startsWith(svelte_config.kit.files.routes + path.sep) || file.startsWith(svelte_config.kit.files.params + path.sep) || + svelte_config.kit.moduleExtensions.some((ext) => file.endsWith(`.remote${ext}`)) || // 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 bdb37b1f9cff..d5ae469a4739 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -36,6 +36,7 @@ import { } from './module_ids.js'; import { resolve_peer_dependency } from '../../utils/import.js'; import { compact } from '../../utils/array.js'; +import { build_remotes, create_public_remote_file } from './build/build_remotes.js'; const cwd = process.cwd(); @@ -398,6 +399,9 @@ async function kit({ svelte_config }) { // ids with :$ don't work with reverse proxies like nginx return `\0virtual:${id.substring(1)}`; } + if (id === '__sveltekit/remote') { + return `${runtime_directory}/client/client.js`; + } if (id.startsWith('__sveltekit/')) { return `\0virtual:${id}`; } @@ -578,6 +582,132 @@ Tips: } }; + /** @type {import('vite').ViteDevServer} */ + let dev_server; + + const remote_virtual_suffix = '.__virtual'; + /** @type {Record} */ + const remote_cache = {}; + /** @type {import('types').ServerMetadata['remotes'] | undefined} only set at build time */ + let remote_exports = undefined; + + /** @type {import('vite').Plugin} */ + const plugin_remote = { + name: 'vite-plugin-sveltekit-remote', + + configureServer(_dev_server) { + dev_server = _dev_server; + }, + + async resolveId(id, importer) { + if (id.endsWith(remote_virtual_suffix)) { + return id; + } + + if (importer?.endsWith(remote_virtual_suffix)) { + return this.resolve( + id, + posixify(process.cwd()) + importer.slice(0, -remote_virtual_suffix.length) + ); + } + }, + + async load(id) { + if (id.endsWith(remote_virtual_suffix)) { + return remote_cache[posixify(process.cwd()) + id.slice(0, -remote_virtual_suffix.length)]; + } + }, + + /** + * @param {string} code + * @param {string} id + * @param {any} opts + */ + async transform(code, id, opts) { + if (!svelte_config.kit.moduleExtensions.some((ext) => id.endsWith(`.remote${ext}`))) { + return; + } + + const hashed_id = hash(posixify(id)); + + if (opts.ssr) { + // build does this in a separate step because dev_server is not available to it + if (!dev_server) return; + + remote_cache[id] = code; + const module = await dev_server.ssrLoadModule(id + remote_virtual_suffix); + const exports = Object.keys(module); + return create_public_remote_file( + exports, + id + remote_virtual_suffix, + hashed_id, + svelte_config + ); + } + + /** @type {Map} */ + const remotes = new Map(); + + if (remote_exports) { + const exports = remote_exports.get(hashed_id); + if (!exports) throw new Error('Expected to find metadata for remote file ' + id); + + for (const [name, value] of exports) { + if (name === 'other') continue; + const type = name_to_client_export(name); + remotes.set(type, value); + } + } else if (dev_server) { + const modules = await dev_server.ssrLoadModule(id); + for (const [name, value] of Object.entries(modules)) { + if (value.__type) { + const type = name_to_client_export(value.__type); + remotes.set(type, (remotes.get(type) ?? []).concat(name)); + } + } + } else { + throw new Error( + 'plugin-remote error: Expected one of dev_server and remote_exports to be available' + ); + } + + /** @param {string} name */ + function name_to_client_export(name) { + return 'remote' + name[0].toUpperCase() + name.slice(1); + } + + const exports = []; + const specifiers = []; + + for (const [type, _exports] of remotes) { + // TODO handle default export + const result = exports_and_fn(type, _exports); + exports.push(...result.exports); + specifiers.push(result.specifier); + } + + /** + * @param {string} remote_import + * @param {string[]} names + */ + function exports_and_fn(remote_import, names) { + // belt and braces — guard against an existing `export function remote() {...}` + let n = 1; + let fn = remote_import; + while (names.includes(fn)) fn = `${fn}$${n++}`; + + const exports = names.map((n) => `export const ${n} = ${fn}('${hashed_id}/${n}');`); + const specifier = fn === remote_import ? fn : `${fn} as ${fn}`; + + return { exports, specifier }; + } + + return { + code: `import { ${specifiers.join(', ')} } from '__sveltekit/remote';\n\n${exports.join('\n')}\n` + }; + } + }; + /** @type {import('vite').Plugin} */ const plugin_compile = { name: 'vite-plugin-sveltekit-compile', @@ -631,6 +761,11 @@ Tips: const name = posixify(path.join('entries/matchers', key)); input[name] = path.resolve(file); }); + + // ...and every .remote file + for (const filename of manifest_data.remotes) { + input[`remote/${hash(filename)}`] = filename; + } } else if (svelte_config.kit.output.bundleStrategy !== 'split') { input['bundle'] = `${runtime_directory}/client/bundle.js`; } else { @@ -828,8 +963,12 @@ Tips: env: { ...env.private, ...env.public } }); + remote_exports = metadata.remotes; + log.info('Building app'); + build_remotes(metadata, svelte_config, out); + // create client build write_client_manifest( kit, @@ -1072,7 +1211,7 @@ Tips: } }; - return [plugin_setup, plugin_virtual_modules, plugin_guard, plugin_compile]; + return [plugin_setup, plugin_remote, plugin_virtual_modules, plugin_guard, plugin_compile]; } /** diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index ecfb49b74da8..3ffb2bcbba36 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -22,7 +22,7 @@ import { create_updated_store, load_css } from './utils.js'; -import { base } from '__sveltekit/paths'; +import { app_dir, base } from '__sveltekit/paths'; import * as devalue from 'devalue'; import { HISTORY_INDEX, @@ -2949,3 +2949,66 @@ if (DEV) { }); } } + +/** + * @param {string} id + */ +export function remoteQuery(id) { + return async (/** @type {any} */ ...args) => { + const transport = app.hooks.transport; + const encoders = Object.fromEntries( + Object.entries(transport).map(([key, value]) => [key, value.encode]) + ); + + const response = await fetch( + `/${app_dir}/remote/${id}?args=${encodeURIComponent(devalue.stringify(args, encoders))}` + ); + const result = await response.json(); + + if (!response.ok) { + // TODO should this go through `handleError`? + throw new Error(result.message); + } + + return devalue.parse(result, app.decoders); + }; +} + +/** + * @param {string} id + */ +export function remoteAction(id) { + return async (/** @type {any} */ ...args) => { + const transport = app.hooks.transport; + const encoders = Object.fromEntries( + Object.entries(transport).map(([key, value]) => [key, value.encode]) + ); + + const response = await fetch(`/${app_dir}/remote/${id}`, { + method: 'POST', + body: devalue.stringify(args, encoders), // TODO maybe don't use devalue.stringify here + headers: { + 'Content-Type': 'application/json' + } + }); + + const result = await response.json(); + + if (!response.ok) { + // TODO should this go through `handleError`? + throw new Error(result.message); + } + + return devalue.parse(result, app.decoders); + }; +} + +/** + * @param {string} id + */ +export function remoteFormAction(id) { + return { + method: 'POST', + action: `/${app_dir}/remote/${id}` + }; +} diff --git a/packages/kit/src/runtime/server/remote/index.js b/packages/kit/src/runtime/server/remote/index.js new file mode 100644 index 000000000000..bd2f27f63d93 --- /dev/null +++ b/packages/kit/src/runtime/server/remote/index.js @@ -0,0 +1,68 @@ +import { json } from '../../../exports/index.js'; +import * as devalue from 'devalue'; +import { app_dir } from '__sveltekit/paths'; +import { error } from 'console'; +import { with_event } from '../../app/server/event.js'; +import { is_form_content_type } from '../../../utils/http.js'; +import { SvelteKitError } from '../../control.js'; + +/** + * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('types').SSROptions} options + * @param {import('@sveltejs/kit').SSRManifest} manifest + */ +export async function handle_remote_call(event, options, manifest) { + const id = event.url.pathname.replace(`/${app_dir}/remote/`, ''); + + const [hash, func_name] = id.split('/'); + const remotes = manifest._.remotes; + + if (!remotes[hash]) error(404); + + const module = await remotes[hash](); + const func = module[func_name]; + + if (!func) error(404); + + const transport = options.hooks.transport; + + if (func.__type === 'formAction') { + if (!is_form_content_type(event.request)) { + throw new SvelteKitError( + 415, + 'Unsupported Media Type', + `Form actions expect form-encoded data — received ${event.request.headers.get( + 'content-type' + )}` + ); + } + + const form_data = await event.request.formData(); + const data = await with_event(event, () => func.apply(null, form_data)); + // TODO what should happen with the result? how to incorporate it into the page? + throw new Error('Form actions are not yet supported'); + } else { + const args_json = + func.__type === 'query' + ? /** @type {string} */ (event.url.searchParams.get('args')) + : await event.request.text(); + const decoders = Object.fromEntries(Object.entries(transport).map(([k, v]) => [k, v.decode])); + const args = devalue.parse(args_json, decoders); + const data = await with_event(event, () => func.apply(null, args)); + + return json(stringify_rpc_response(data, transport)); + } +} + +/** + * Try to `devalue.stringify` the data object, and if it fails, return a proper Error with context + * @param {any} data + * @param {import('types').ServerHooks['transport']} transport + */ +function stringify_rpc_response(data, transport) { + const encoders = Object.fromEntries( + Object.entries(transport).map(([key, value]) => [key, value.encode]) + ); + + return devalue.stringify(data, encoders); +} diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 81b30e0756a5..9f11d2c2a0de 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -17,7 +17,7 @@ import { redirect_json_response, render_data } from './data/index.js'; import { add_cookies_to_headers, get_cookies } from './cookie.js'; import { create_fetch } from './fetch.js'; import { PageNodes } from '../../utils/page_nodes.js'; -import { HttpError, Redirect, SvelteKitError } from '../control.js'; +import { Redirect, SvelteKitError } from '../control.js'; import { validate_server_exports } from '../../utils/exports.js'; import { json, text } from '../../exports/index.js'; import { action_json_redirect, is_action_json_request } from './page/actions.js'; @@ -33,6 +33,7 @@ import { strip_data_suffix, strip_resolution_suffix } from '../pathname.js'; +import { handle_remote_call } from './remote/index.js'; import { with_event } from '../app/server/event.js'; /* global __SVELTEKIT_ADAPTER_NAME__ */ @@ -62,24 +63,39 @@ export async function respond(request, options, manifest, state) { /** URL but stripped from the potential `/__data.json` suffix and its search param */ const url = new URL(request.url); - if (options.csrf_check_origin) { + const is_route_resolution_request = has_resolution_suffix(url.pathname); + const is_data_request = has_data_suffix(url.pathname); + const is_remote_request = url.pathname.startsWith(`/${app_dir}/remote/`); + + if (options.csrf_check_origin && request.headers.get('origin') !== url.origin) { + const opts = { status: 403 }; + + if ( + is_remote_request && + // TODO get doesn't have an origin header - any way we can still forbid other origins? + request.method !== 'GET' + ) { + return json( + { + message: 'Cross-site remote requests are forbidden' + }, + opts + ); + } + const forbidden = is_form_content_type(request) && (request.method === 'POST' || request.method === 'PUT' || request.method === 'PATCH' || - request.method === 'DELETE') && - request.headers.get('origin') !== url.origin; + request.method === 'DELETE'); if (forbidden) { - const csrf_error = new HttpError( - 403, - `Cross-site ${request.method} form submissions are forbidden` - ); + const message = `Cross-site ${request.method} form submissions are forbidden`; if (request.headers.get('accept') === 'application/json') { - return json(csrf_error.body, { status: csrf_error.status }); + return json({ message }, opts); } - return text(csrf_error.body.message, { status: csrf_error.status }); + return text(message, opts); } } @@ -90,14 +106,11 @@ export async function respond(request, options, manifest, state) { /** @type {boolean[] | undefined} */ let invalidated_data_nodes; - /** - * If the request is for a route resolution, first modify the URL, then continue as normal - * for path resolution, then return the route object as a JS file. - */ - const is_route_resolution_request = has_resolution_suffix(url.pathname); - const is_data_request = has_data_suffix(url.pathname); - if (is_route_resolution_request) { + /** + * If the request is for a route resolution, first modify the URL, then continue as normal + * for path resolution, then return the route object as a JS file. + */ url.pathname = strip_resolution_suffix(url.pathname); } else if (is_data_request) { url.pathname = @@ -181,23 +194,25 @@ export async function respond(request, options, manifest, state) { }); } - let resolved_path; - - const prerendering_reroute_state = state.prerendering?.inside_reroute; - try { - // For the duration or a reroute, disable the prerendering state as reroute could call API endpoints - // which would end up in the wrong logic path if not disabled. - if (state.prerendering) state.prerendering.inside_reroute = true; + let resolved_path = url.pathname; - // reroute could alter the given URL, so we pass a copy - resolved_path = - (await options.hooks.reroute({ url: new URL(url), fetch: event.fetch })) ?? url.pathname; - } catch { - return text('Internal Server Error', { - status: 500 - }); - } finally { - if (state.prerendering) state.prerendering.inside_reroute = prerendering_reroute_state; + if (!is_remote_request) { + const prerendering_reroute_state = state.prerendering?.inside_reroute; + try { + // For the duration or a reroute, disable the prerendering state as reroute could call API endpoints + // which would end up in the wrong logic path if not disabled. + if (state.prerendering) state.prerendering.inside_reroute = true; + + // reroute could alter the given URL, so we pass a copy + resolved_path = + (await options.hooks.reroute({ url: new URL(url), fetch: event.fetch })) ?? url.pathname; + } catch { + return text('Internal Server Error', { + status: 500 + }); + } finally { + if (state.prerendering) state.prerendering.inside_reroute = prerendering_reroute_state; + } } try { @@ -252,7 +267,7 @@ export async function respond(request, options, manifest, state) { return get_public_env(request); } - if (resolved_path.startsWith(`/${app_dir}`)) { + if (!is_remote_request && resolved_path.startsWith(`/${app_dir}`)) { // Ensure that 404'd static assets are not cached - some adapters might apply caching by default const headers = new Headers(); headers.set('cache-control', 'public, max-age=0, must-revalidate'); @@ -474,6 +489,10 @@ export async function respond(request, options, manifest, state) { }); } + if (is_remote_request) { + return handle_remote_call(event, options, manifest); + } + if (route) { const method = /** @type {import('types').HttpMethod} */ (event.request.method); diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 2d54b37ac145..9cbff26392a9 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -189,6 +189,7 @@ export interface ManifestData { universal: string | null; }; nodes: PageNode[]; + remotes: string[]; routes: RouteData[]; matchers: Record; } @@ -347,6 +348,8 @@ export interface ServerMetadata { has_server_load: boolean; }>; routes: Map; + /** For each hashed remote file, its export names grouped by query/action/formAction/other */ + remotes: Map>; } export interface SSRComponent { diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 1fe7fbcb88d6..53733a6d0842 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1306,6 +1306,8 @@ declare module '@sveltejs/kit' { _: { client: NonNullable; nodes: SSRNodeLoader[]; + /** hashed filename -> import to that file */ + remotes: Record Promise>; routes: SSRRoute[]; prerendered_routes: Set; matchers: () => Promise>; @@ -1766,6 +1768,7 @@ declare module '@sveltejs/kit' { universal: string | null; }; nodes: PageNode[]; + remotes: string[]; routes: RouteData[]; matchers: Record; } @@ -2034,6 +2037,12 @@ declare module '@sveltejs/kit' { wasNormalized: boolean; denormalize: (url?: string | URL) => URL; }; + + export function formAction any>(fn: T): T; + + export function query any>(fn: T): T; + + export function action any>(fn: T): T; export type LessThan = TNumber extends TArray["length"] ? TArray[number] : LessThan; export type NumericRange = Exclude, LessThan>; export const VERSION: string; diff --git a/playgrounds/basic/src/lib/foo.remote.ts b/playgrounds/basic/src/lib/foo.remote.ts new file mode 100644 index 000000000000..467b44620792 --- /dev/null +++ b/playgrounds/basic/src/lib/foo.remote.ts @@ -0,0 +1,22 @@ +import { x } from './relative'; +import { query, action, formAction } from '@sveltejs/kit'; + +export const add = query(async (a: number, b: number) => { + console.log('add', x, a, b); + + return a + b; +}); + +export const multiply = action(async (a: number, b: number) => { + console.log('multiply', a, b); + + return a * b; +}); + +export const divide = formAction(async (form) => { + const a = form.get('a'); + const b = form.get('b'); + console.log('divide', a, b); + + return a / b; +}); diff --git a/playgrounds/basic/src/routes/+layout.svelte b/playgrounds/basic/src/routes/+layout.svelte index 707b3afe5a1e..e85e983d2ee4 100644 --- a/playgrounds/basic/src/routes/+layout.svelte +++ b/playgrounds/basic/src/routes/+layout.svelte @@ -2,8 +2,6 @@ import { page, navigating } from '$app/state'; let { children } = $props(); - - $inspect(navigating.to);