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/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..3417afdb4b87 --- /dev/null +++ b/packages/kit/src/exports/vite/build/build_remotes.js @@ -0,0 +1,46 @@ +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 + */ +export function create_public_remote_file(exports, id, hash) { + 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('${hash}/' + key); + } + if (fn.__type === 'query') { + fn.__id = '${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/app/server/index.js b/packages/kit/src/runtime/app/server/index.js index 19c384932107..f33e9c4735f1 100644 --- a/packages/kit/src/runtime/app/server/index.js +++ b/packages/kit/src/runtime/app/server/index.js @@ -73,3 +73,5 @@ export function read(asset) { } export { getRequestEvent } from './event.js'; + +export { query, action, formAction } from './remote.js'; diff --git a/packages/kit/src/runtime/app/server/remote.js b/packages/kit/src/runtime/app/server/remote.js new file mode 100644 index 000000000000..27d7132e1217 --- /dev/null +++ b/packages/kit/src/runtime/app/server/remote.js @@ -0,0 +1,128 @@ +import { stringify, uneval } from 'devalue'; +import { getRequestEvent } from './event.js'; + +/** + * @template {(formData: FormData) => any} T + * @param {T} fn + * @returns {T} + */ +export function formAction(fn) { + /** @param {FormData} form_data */ + const wrapper = async (form_data) => { + // TODO don't do the additional work when we're being called from the client? + const event = getRequestEvent(); + const result = await fn(form_data); + event._.remote_results[wrapper.form.action] = uneval_remote_response(result, event._.transport); + return result; + }; + // Better safe than sorry: Seal these properties to prevent modification + Object.defineProperty(wrapper, 'form', { + value: { method: 'POST', action: '' }, + writable: false, + enumerable: true, + configurable: false + }); + Object.defineProperty(wrapper, 'formAction', { + value: { formaction: '' }, + writable: false, + enumerable: true, + configurable: false + }); + Object.defineProperty(wrapper, '__type', { + value: 'formAction', + writable: false, + enumerable: true, + configurable: false + }); + let set = false; + Object.defineProperty(wrapper, '_set_action', { + /** @param {string} action */ + value: (action) => { + if (set) return; + set = true; + wrapper.form.action = `?/remote=${encodeURIComponent(action)}`; + wrapper.formAction.formaction = `?/remote=${encodeURIComponent(action)}`; + }, + writable: false, + enumerable: true, + configurable: false + }); + Object.defineProperty(wrapper, 'result', { + get() { + try { + const event = getRequestEvent(); + return event._.remote_results[wrapper.form.action] ?? null; + } catch (e) { + return null; + } + }, + enumerable: true, + configurable: false + }); + + // @ts-expect-error + return wrapper; +} + +/** + * @template {(...args: any[]) => any} T + * @param {T} fn + * @returns {T} + */ +export function query(fn) { + /** @param {...Parameters} args */ + const wrapper = async (...args) => { + // TODO don't do the additional work when we're being called from the client? + const event = getRequestEvent(); + const result = await fn(...args); + const stringified_args = stringify(args, event._.transport); + event._.remote_results[wrapper.__id + stringified_args] = uneval_remote_response( + result, + event._.transport + ); + return result; + }; + // Better safe than sorry: Seal these properties to prevent modification + Object.defineProperty(wrapper, '__type', { + value: 'query', + writable: false, + enumerable: true, + configurable: false + }); + // @ts-expect-error + return wrapper; +} + +/** + * @param {any} data + * @param {import('types').ServerHooks['transport']} transport + */ +export function uneval_remote_response(data, transport) { + const replacer = (/** @type {any} */ thing) => { + for (const key in transport) { + const encoded = transport[key].encode(thing); + if (encoded) { + return `app.decode('${key}', ${uneval(encoded, replacer)})`; + } + } + }; + + // TODO try_serialize + return uneval(data, replacer); +} + +/** + * @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/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index ecfb49b74da8..15945ca04e9a 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -43,6 +43,7 @@ import { page, update, navigating } from './state.svelte.js'; import { add_data_suffix, add_resolution_suffix } from '../pathname.js'; export { load_css }; +export { remoteAction, remoteFormAction, remoteQuery } from './remote.svelte.js'; const ICON_REL_ATTRIBUTES = new Set(['icon', 'shortcut icon', 'apple-touch-icon']); @@ -173,6 +174,8 @@ let container; let target; /** @type {import('./types.js').SvelteKitApp} */ export let app; +/** @type {Record} */ +export let remote_responses; /** @type {Array<((url: URL) => boolean)>} */ const invalidated = []; @@ -222,7 +225,7 @@ let current = { /** this being true means we SSR'd */ let hydrated = false; -let started = false; +export let started = false; let autoscroll = true; let updating = false; let is_navigating = false; @@ -276,6 +279,7 @@ export async function start(_app, _target, hydrate) { } app = _app; + remote_responses = hydrate?.remote; await _app.hooks.init?.(); diff --git a/packages/kit/src/runtime/client/remote.svelte.js b/packages/kit/src/runtime/client/remote.svelte.js new file mode 100644 index 000000000000..be8ead2dd718 --- /dev/null +++ b/packages/kit/src/runtime/client/remote.svelte.js @@ -0,0 +1,200 @@ +import { app_dir } from '__sveltekit/paths'; +import * as devalue from 'devalue'; +import { DEV } from 'esm-env'; +import { app, remote_responses, started } from './client.js'; + +/** + * @param {string} id + */ +export function remoteQuery(id) { + // TODO disable "use event.fetch method instead" warning which can show up when you use remote functions in load functions + return async (/** @type {any} */ ...args) => { + const transport = app.hooks.transport; + const encoders = Object.fromEntries( + Object.entries(transport).map(([key, value]) => [key, value.encode]) + ); + + const stringified_args = devalue.stringify(args, encoders); + if (!started) { + const result = remote_responses[id + stringified_args]; + if (result) return result; + } + + const response = await fetch( + `/${app_dir}/remote/${id}?args=${encodeURIComponent(stringified_args)}` + ); + 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) { + /** + * Shallow clone an element, so that we can access e.g. `form.action` without worrying + * that someone has added an `` (https://github.com/sveltejs/kit/issues/7593) + * @template {HTMLElement} T + * @param {T} element + * @returns {T} + */ + function clone(element) { + return /** @type {T} */ (HTMLElement.prototype.cloneNode.call(element)); + } + + const action = '?/remote=' + encodeURIComponent(id); + + /** @type {any} */ + let result = $state(!started ? (remote_responses[action] ?? null) : null); + + return { + get result() { + return result; + }, + form: { + method: 'POST', + action, + /** @param {SubmitEvent} event */ + onsubmit: async (event) => { + const form_element = /** @type {HTMLFormElement} */ (event.target); + const method = event.submitter?.hasAttribute('formmethod') + ? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formMethod + : clone(form_element).method; + + if (method !== 'post') return; + + const action = new URL( + // We can't do submitter.formAction directly because that property is always set + event.submitter?.hasAttribute('formaction') + ? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formAction + : clone(form_element).action + ); + + if (action.searchParams.get('/remote') !== id) { + return; + } + + event.preventDefault(); + + const form_data = new FormData(form_element); + + if (DEV) { + const enctype = event.submitter?.hasAttribute('formenctype') + ? /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter).formEnctype + : clone(form_element).enctype; + if (enctype !== 'multipart/form-data') { + for (const value of form_data.values()) { + if (value instanceof File) { + throw new Error( + 'Your form contains fields, but is missing the necessary `enctype="multipart/form-data"` attribute. This will lead to inconsistent behavior between enhanced and native forms. For more details, see https://github.com/sveltejs/kit/issues/9819.' + ); + } + } + } + } + + const submitter_name = event.submitter?.getAttribute('name'); + if (submitter_name) { + form_data.append(submitter_name, event.submitter?.getAttribute('value') ?? ''); + } + + const response = await fetch(`/${app_dir}/remote/${id}`, { + method: 'POST', + body: form_data + }); + const json = await response.json(); + + if (!response.ok) { + // TODO should this go through `handleError`? + throw new Error(json.message); + } + + return (result = devalue.parse(json, app.decoders)); + } + }, + formAction: { + formaction: action, + /** @param {Event} event */ + onclick: async (event) => { + const target = /** @type {HTMLButtonElement} */ (event.target); + const form_element = target.form; + if (!form_element) return; + + // Prevent this from firing the form's submit event + event.stopPropagation(); + event.preventDefault(); + + const form_data = new FormData(form_element); + + if (DEV) { + const enctype = target.hasAttribute('formenctype') + ? target.formEnctype + : clone(form_element).enctype; + if (enctype !== 'multipart/form-data') { + for (const value of form_data.values()) { + if (value instanceof File) { + throw new Error( + 'Your form contains fields, but is missing the necessary `enctype="multipart/form-data"` attribute. This will lead to inconsistent behavior between enhanced and native forms. For more details, see https://github.com/sveltejs/kit/issues/9819.' + ); + } + } + } + } + + const submitter_name = target.getAttribute('name'); + if (submitter_name) { + form_data.append(submitter_name, target.getAttribute('value') ?? ''); + } + + const response = await fetch(`/${app_dir}/remote/${id}`, { + method: 'POST', + body: form_data + }); + const json = await response.json(); + + if (!response.ok) { + // TODO should this go through `handleError`? + throw new Error(json.message); + } + + return (result = devalue.parse(json, app.decoders)); + } + } + }; +} diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index 01337e4cac1c..3e7bd5af421a 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -15,6 +15,7 @@ import { render_response } from './render.js'; import { respond_with_error } from './respond_with_error.js'; import { get_data_json } from '../data/index.js'; import { DEV } from 'esm-env'; +import { handle_remote_call } from '../remote/index.js'; /** * The maximum request depth permitted before assuming we're stuck in an infinite loop @@ -53,17 +54,26 @@ export async function render_page(event, page, options, manifest, state, nodes, let action_result = undefined; if (is_action_request(event)) { - // for action requests, first call handler in +page.server.js - // (this also determines status code) - action_result = await handle_action_request(event, leaf_node.server); - if (action_result?.type === 'redirect') { - return redirect_response(action_result.status, action_result.location); - } - if (action_result?.type === 'error') { - status = get_status(action_result.error); - } - if (action_result?.type === 'failure') { - status = action_result.status; + if (event.url.searchParams.has('/remote')) { + await handle_remote_call( + event, + options, + manifest, + /** @type {string} */ (event.url.searchParams.get('/remote')) + ); + } else { + // for action requests, first call handler in +page.server.js + // (this also determines status code) + action_result = await handle_action_request(event, leaf_node.server); + if (action_result?.type === 'redirect') { + return redirect_response(action_result.status, action_result.location); + } + if (action_result?.type === 'error') { + status = get_status(action_result.error); + } + if (action_result?.type === 'failure') { + status = action_result.status; + } } } diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js index 2ada2b6ecde6..efdcde5c2971 100644 --- a/packages/kit/src/runtime/server/page/load_data.js +++ b/packages/kit/src/runtime/server/page/load_data.js @@ -196,21 +196,28 @@ export async function load_data({ }) { const server_data_node = await server_data_promise; - if (!node?.universal?.load) { + const load = node?.universal?.load; + + if (!load) { return server_data_node?.data ?? null; } - const result = await node.universal.load.call(null, { - url: event.url, - params: event.params, - data: server_data_node?.data ?? null, - route: event.route, - fetch: create_universal_fetch(event, state, fetched, csr, resolve_opts), - setHeaders: event.setHeaders, - depends: () => {}, - parent, - untrack: (fn) => fn() - }); + // TODO it's not great that we're adding getRequestEvent context to the universal load function, + // but it's needed in order to be able to use remote calls that are hydrated within it. + // Do we need another, hidden getRequestEvent-like function for this? + const result = await with_event(event, () => + load.call(null, { + url: event.url, + params: event.params, + data: server_data_node?.data ?? null, + route: event.route, + fetch: create_universal_fetch(event, state, fetched, csr, resolve_opts), + setHeaders: event.setHeaders, + depends: () => {}, + parent, + untrack: (fn) => fn() + }) + ); if (__SVELTEKIT_DEV__) { validate_load_response(result, node.universal_id); diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 59d9229164dc..0a49e432116b 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -15,6 +15,7 @@ import { SVELTE_KIT_ASSETS } from '../../../constants.js'; import { SCHEME } from '../../../utils/url.js'; import { create_server_routing_response, generate_route_object } from './server_routing.js'; import { add_resolution_suffix } from '../../pathname.js'; +import { with_event } from '../../app/server/event.js'; // TODO rename this function/module @@ -189,7 +190,7 @@ export async function render_response({ }; try { - rendered = options.root.render(props, render_opts); + rendered = with_event(event, () => options.root.render(props, render_opts)); } finally { globalThis.fetch = fetch; paths.reset(); @@ -404,7 +405,8 @@ export async function render_response({ `node_ids: [${branch.map(({ node }) => node.index).join(', ')}]`, `data: ${data}`, `form: ${serialized.form}`, - `error: ${serialized.error}` + `error: ${serialized.error}`, + `remote: ${s(event._.remote_results)}` ]; if (status !== 200) { 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..e8067dce2b5d --- /dev/null +++ b/packages/kit/src/runtime/server/remote/index.js @@ -0,0 +1,72 @@ +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 + * @param {string} [id] + */ +export async function handle_remote_call( + event, + options, + manifest, + id = event.url.pathname.replace(`/${app_dir}/remote/`, '') +) { + console.log('id', id); + 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(form_data)); // TODO func.apply(null, form_data) doesn't work for unknown reasons + return json(stringify_rpc_response(data, transport)); + } 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..78c002e94c94 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 = @@ -162,7 +175,12 @@ export async function respond(request, options, manifest, state) { }, url, isDataRequest: is_data_request, - isSubRequest: state.depth > 0 + isSubRequest: state.depth > 0, + // TODO tidy up + _: { + remote_results: {}, + transport: options.hooks.transport + } }; event.fetch = create_fetch({ @@ -181,23 +199,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 +272,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 +494,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..5ae2a09a0363 --- /dev/null +++ b/playgrounds/basic/src/lib/foo.remote.ts @@ -0,0 +1,30 @@ +import { x } from './relative'; +import { query, action, formAction } from '$app/server'; + +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; +}); + +export const multiply2 = formAction(async (form) => { + const a = form.get('a'); + const b = form.get('b'); + console.log('multiply', a, b); + + return a * b; +}); diff --git a/playgrounds/basic/src/lib/relative.ts b/playgrounds/basic/src/lib/relative.ts new file mode 100644 index 000000000000..30dc55a73230 --- /dev/null +++ b/playgrounds/basic/src/lib/relative.ts @@ -0,0 +1 @@ +export const x = true; 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);