diff --git a/packages/enhanced-img/src/vite-plugin.js b/packages/enhanced-img/src/vite-plugin.js index cbfb7b3a1ca7..101f5b6efdda 100644 --- a/packages/enhanced-img/src/vite-plugin.js +++ b/packages/enhanced-img/src/vite-plugin.js @@ -324,7 +324,7 @@ function stringToNumber(param) { * @param {import('vite-imagetools').Picture} image */ function img_to_picture(content, node, image) { - /** @type {import('../types/internal.js').Attribute[]} attributes */ + /** @type {import('../types/internal.js').Attribute[]} */ const attributes = node.attributes; const index = attributes.findIndex( (attribute) => 'name' in attribute && attribute.name === 'sizes' diff --git a/packages/kit/package.json b/packages/kit/package.json index aa8e2f760f0b..67403afcc13c 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -18,6 +18,7 @@ "homepage": "https://svelte.dev", "type": "module", "dependencies": { + "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", @@ -37,7 +38,7 @@ "@types/connect": "^3.4.38", "@types/node": "^18.19.48", "@types/set-cookie-parser": "^2.4.7", - "dts-buddy": "^0.6.1", + "dts-buddy": "^0.6.2", "rollup": "^4.14.2", "svelte": "^5.35.5", "svelte-preprocess": "^6.0.0", diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 9c577f5425c0..b76ca8b7cbf4 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -76,6 +76,9 @@ const get_defaults = (prefix = '') => ({ publicPrefix: 'PUBLIC_', privatePrefix: '' }, + experimental: { + remoteFunctions: false + }, files: { assets: join(prefix, 'static'), hooks: { diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index a2b9bb81759d..577ca4c9445d 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -120,6 +120,10 @@ const options = object( privatePrefix: string('') }), + experimental: object({ + remoteFunctions: boolean(false) + }), + files: object({ assets: string('static'), hooks: object({ diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js index eaaf9e6cd38e..1d983f5c7751 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 '../../utils/hash.js'; 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 39164feac688..c933d476d748 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -82,7 +82,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())); @@ -164,6 +165,18 @@ async function analyse({ }); } + // analyse remotes + for (const [remote, modules_fn] of Object.entries(manifest._.remotes)) { + const modules = await modules_fn(); + const exports = new Map(); + for (const [name, value] of Object.entries(modules)) { + const type = /** @type {import('types').RemoteInfo} */ (value.__)?.type; + if (!type) continue; + exports.set(type, (exports.get(type) ?? []).concat(name)); + } + metadata.remotes.set(remote, exports); + } + return { metadata, static_exports }; } diff --git a/packages/kit/src/core/postbuild/fallback.js b/packages/kit/src/core/postbuild/fallback.js index d77400a460f9..39356887a289 100644 --- a/packages/kit/src/core/postbuild/fallback.js +++ b/packages/kit/src/core/postbuild/fallback.js @@ -41,7 +41,8 @@ async function generate_fallback({ manifest_path, env }) { }, prerendering: { fallback: true, - dependencies: new Map() + dependencies: new Map(), + remote_responses: new Map() }, read: (file) => readFileSync(join(config.files.assets, file)) }); diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index 7c84269e3306..64a433deca1d 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -14,6 +14,7 @@ import { forked } from '../../utils/fork.js'; import * as devalue from 'devalue'; import { createReadableStream } from '@sveltejs/kit/node'; import generate_fallback from './fallback.js'; +import { stringify_remote_arg } from '../../runtime/shared.js'; export default forked(import.meta.url, prerender); @@ -186,6 +187,7 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env }) { } const seen = new Set(); const written = new Set(); + const remote_responses = new Map(); /** @type {Map>} */ const expected_hashlinks = new Map(); @@ -229,7 +231,8 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env }) { throw new Error('Cannot read clientAddress during prerendering'); }, prerendering: { - dependencies + dependencies, + remote_responses }, read: (file) => { // stuff we just wrote @@ -460,8 +463,25 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env }) { } } + /** @type {Array} */ + const remote_functions = []; + + for (const remote of Object.values(manifest._.remotes)) { + const functions = Object.values(await remote()).filter( + (value) => + typeof value === 'function' && + /** @type {import('types').RemoteInfo} */ (value.__)?.type === 'prerender' + ); + if (functions.length > 0) { + has_prerenderable_routes = true; + remote_functions.push(...functions); + } + } + if ( - (config.prerender.entries.length === 0 && route_level_entries.length === 0) || + (config.prerender.entries.length === 0 && + route_level_entries.length === 0 && + remote_functions.length === 0) || !has_prerenderable_routes ) { return { prerendered, prerender_map }; @@ -499,6 +519,32 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env }) { } } + const transport = (await internal.get_hooks()).transport ?? {}; + for (const remote_function of remote_functions) { + // TODO this writes to /prerender/pages/... eventually, should it go into + // /prerender/dependencies like indirect calls due to page prerenders? + // Does it really matter? + if (remote_function.__.entries) { + for (const entry of await remote_function.__.entries()) { + void enqueue( + null, + config.paths.base + + '/' + + config.appDir + + '/remote/' + + remote_function.__.id + + '/' + + stringify_remote_arg(entry, transport) + ); + } + } else { + void enqueue( + null, + config.paths.base + '/' + config.appDir + '/remote/' + remote_function.__.id + ); + } + } + await q.done(); // handle invalid fragment links 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..e4cca6e5be1c 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); 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,24 @@ function create_routes_and_nodes(cwd, config, fallback) { }; } +/** + * @param {import('types').ValidatedConfig} config + */ +function create_remotes(config) { + if (!config.kit.experimental.remoteFunctions) return []; + + 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/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index 5e93d5c1cd25..470a26215ba5 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -1,6 +1,6 @@ import path from 'node:path'; import process from 'node:process'; -import { hash } from '../../runtime/hash.js'; +import { hash } from '../../utils/hash.js'; import { posixify, resolve_entry } from '../../utils/filesystem.js'; import { s } from '../../utils/misc.js'; import { load_error_page, load_template } from '../config/index.js'; diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index d11e011d6d76..44e4b64f0ffd 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -188,7 +188,7 @@ export function text(body, init) { */ /** * Create an `ActionFailure` object. Call when form submission fails. - * @template {Record | undefined} [T=undefined] + * @template [T=undefined] * @param {number} status The [HTTP status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#client_error_responses). Must be in the range 400-599. * @param {T} data Data associated with the failure (e.g. validation errors) * @overload diff --git a/packages/kit/src/exports/internal/index.js b/packages/kit/src/exports/internal/index.js index aa0b93f8965b..7883c167386d 100644 --- a/packages/kit/src/exports/internal/index.js +++ b/packages/kit/src/exports/internal/index.js @@ -49,7 +49,7 @@ export class SvelteKitError extends Error { } /** - * @template {Record | undefined} [T=undefined] + * @template [T=undefined] */ export class ActionFailure { /** diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index ec930b7d073a..a4632b245637 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -73,7 +73,7 @@ type OptionalUnion< declare const uniqueSymbol: unique symbol; -export interface ActionFailure | undefined = undefined> { +export interface ActionFailure { status: number; data: T; [uniqueSymbol]: true; // necessary or else UnpackValidationError could wrongly unpack objects with the same shape as ActionFailure @@ -409,6 +409,16 @@ export interface KitConfig { */ privatePrefix?: string; }; + /** + * Experimental features which are exempt from semantic versioning. These features may change or be removed at any time. + */ + experimental?: { + /** + * Whether to enable the experimental remote functions feature. This feature is not yet stable and may change or be removed at any time. + * @default false + */ + remoteFunctions?: boolean; + }; /** * Where to find various files within your project. */ @@ -1250,6 +1260,11 @@ export interface RequestEvent< * `true` for `+server.js` calls coming from SvelteKit without the overhead of actually making an HTTP request. This happens when you make same-origin `fetch` requests on the server. */ isSubRequest: boolean; + /** + * `true` if the request comes from the client via a remote function. The `url` property will be stripped of the internal information + * related to the data request in this case. Use this property instead if the distinction is important to you. + */ + isRemoteRequest: boolean; } /** @@ -1324,6 +1339,8 @@ export interface SSRManifest { _: { client: NonNullable; nodes: SSRNodeLoader[]; + /** hashed filename -> import to that file */ + remotes: Record Promise>; routes: SSRRoute[]; prerendered_routes: Set; matchers: () => Promise>; @@ -1501,4 +1518,187 @@ export interface Snapshot { restore: (snapshot: T) => void; } +/** + * The return value of a remote `form` function. + * Spread it onto a `
` element to connect the form with the remote form action. + * ```svelte + * + * + * + * + * + *
+ * ``` + * Use the `enhance` method to influence what happens when the form is submitted. + * ```svelte + * + * + *
{ + * // do something with the form data, e.g. optimistic UI update + * getTodos.override([], (todos) => [...todos, { text: formData.get('text') }]); + * // submit the form + * const result = await submit(); + * // do something with the result + * })}> + * + * + *
+ * + *
    + * {#each await getTodos() as todo} + *
  • {todo.text}
  • + * {/each} + *
+ * ``` + */ +export type RemoteFormAction = ((data: FormData) => Promise) & { + method: 'POST'; + /** The URL to send the form to. */ + action: string; + /** Event handler that intercepts the form submission on the client to prevent a full page reload */ + onsubmit: (event: SubmitEvent) => void; + /** Use the `enhance` method to influence what happens when the form is submitted. */ + enhance: ( + callback: (opts: { + form: HTMLFormElement; + data: FormData; + submit: () => Promise & { + updates: ( + ...queries: Array< + | ReturnType> + | ReturnType>['withOverride']> + > + ) => Promise; + }; + }) => void + ) => { + method: 'POST'; + action: string; + onsubmit: (event: SubmitEvent) => void; + }; + /** + * Create an instance of the form for the given key. + * The key is stringified and used for deduplication to potentially reuse existing instances. + * Useful when you have multiple forms that use the same remote form action, for example in a loop. + * ```svelte + * {#each todos as todo} + * {@const todoForm = updateTodo.for(todo.id)} + *
+ * {#if todoForm.result?.invalid}

Invalid data

{/if} + * ... + *
+ * {/each} + * ``` + */ + for: (key: string | number | boolean) => Omit, 'for'>; + /** The result of the form submission */ + get result(): Success | Failure | undefined; + /** When there's an error during form submission, it appears on this property */ + get error(): App.Error | undefined; + /** Spread this onto a button or input of type submit */ + formAction: { + type: 'submit'; + formaction: string; + onclick: (event: Event) => void; + /** Use the `enhance` method to influence what happens when the form is submitted. */ + enhance: ( + callback: (opts: { + form: HTMLFormElement; + data: FormData; + submit: () => Promise & { + updates: ( + ...queries: Array< + | ReturnType> + | ReturnType>['withOverride']> + > + ) => Promise; + }; + }) => void + ) => { + type: 'submit'; + formaction: string; + onclick: (event: Event) => void; + }; + }; +}; + +/** + * The return value of a remote `query` or `prerender` function. + * Call it with the input arguments to retrieve the value. + * On the server, this will directly call through to the underlying function. + * On the client, this will do a fetch to the server to retrieve the value. + * When the query is called in a reactive context on the client, it will update its dependencies with a new value whenever `refresh()` or `override()` are called. + */ +export type RemoteQuery = (arg: Input) => Promise> & { + /** The current value of the query. Undefined as long as there's no value yet */ + get current(): Awaited | undefined; + /** The error in case the query fails */ + get error(): App.Error | undefined; + /** `true` before the first result is available and during refreshes */ + get pending(): boolean; + /** + * On the client, this function will re-fetch the query from the server. + * For queries with input arguments, all queries currently active will be re-fetched regardless of the input arguments. + * + * On the server, this can be called in the context of a `command` or `form` remote function. It will then + * transport the updated data to the client along with the response, if the action was successful. + */ + refresh: () => Promise; + /** + * Temporarily override the value of a query. Useful for optimistic UI updates. + * `override` expects a function that takes the current value and returns the new value. It returns a function that will release the override. + * Overrides are applied on new values, too, until they are released. + * + * ```svelte + * + * + *
{ + * const release = await getTodos.override((todos) => [...todos, { text: data.get('text') }]); + * try { + * await submit(); + * } finally { + * release(); + * } + * }}> + * + * + *
+ * ``` + * + * Can only be called on the client. + */ + override: (update: (current: Awaited) => Awaited) => () => void; + /** + * Temporarily override the value of a query. Useful for optimistic UI updates. + * `withOverride` expects a function that takes the current value and returns the new value. + * In other words this works like `override`, but is specifically for use as part of the `updates` method of a remote `command` or `form` submit + * in order to coordinate query refreshes and override releases at once, without causing e.g. flickering in the UI. + * + * ```svelte + * + * + *
{ + * await submit().updates(todos.withOverride((todos) => [...todos, { text: data.get('text') }])); + * }}> + * + * + *
+ * ``` + */ + withOverride: (update: (current: Awaited) => Awaited) => { + _key: string; + release: () => void; + }; +}; + export * from './index.js'; diff --git a/packages/kit/src/exports/vite/build/build_remote.js b/packages/kit/src/exports/vite/build/build_remote.js new file mode 100644 index 000000000000..d9e4989e726c --- /dev/null +++ b/packages/kit/src/exports/vite/build/build_remote.js @@ -0,0 +1,145 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { mkdirp, posixify, rimraf } from '../../../utils/filesystem.js'; +import { dedent } from '../../../core/sync/utils.js'; +import { import_peer } from '../../../utils/import.js'; + +/** + * Loads the remote modules, checks which of those have prerendered remote functions that should be treeshaken, + * then accomplishes the treeshaking by rewriting the remote files to only include the non-prerendered imports, + * replacing the prerendered remote functions with a dummy function that should never be called, + * and do a Vite build. This will not treeshake perfectly yet as everything except the remote files are treated as external, + * so it will not go into those files to check what can be treeshaken inside them. + * @param {string} out + */ +export async function treeshake_prerendered_remotes(out) { + if (!exists(out)) return; + + const vite = /** @type {typeof import('vite')} */ (await import_peer('vite')); + const remote_entry = posixify(`${out}/server/remote-entry.js`); + + for (const remote of fs.readdirSync(`${out}/server/remote`)) { + if (remote.startsWith('__sibling__.')) continue; // skip sibling files + const remote_file = posixify(path.join(`${out}/server/remote`, remote)); + const remote_module = await import(pathToFileURL(remote_file).href); + const prerendered_exports = Object.entries(remote_module) + .filter(([, _export]) => !(_export?.__?.type === 'prerender' && !_export.__.dynamic)) + .map(([name]) => name); + const dynamic_exports = Object.keys(remote_module).filter( + (name) => !prerendered_exports.includes(name) + ); + + if (dynamic_exports.length > 0) { + const temp_out_dir = path.join(out, 'server', 'remote-temp'); + const tmp_file = posixify(path.join(out, 'server/remote/tmp.js')); + mkdirp(temp_out_dir); + fs.writeFileSync( + remote_file, + dedent` + import {${prerendered_exports.join(',')}} from './__sibling__.${remote}'; + import { prerender } from '../${path.basename(remote_entry)}'; + ${dynamic_exports.map((name) => `const ${name} = prerender(() => {throw new Error('Unexpectedly called prerender function. Did you forget to set { dynamic: true } ?')});`).join('\n')} + for (const [key, fn] of Object.entries({${Object.keys(remote_module).join(',')}})) { + if (fn.__?.type === 'form') { + fn.__.set_action('${remote.slice(0, -3)}/' + key); + } else if (fn.__?.type === 'query' || fn.__?.type === 'prerender' || fn.__?.type === 'cache') { + fn.__.id = '${remote.slice(0, -3)}/' + key; + } + } + export {${Object.keys(remote_module).join(',')}}; + ` + ); + + await vite.build({ + configFile: false, + build: { + ssr: true, + outDir: temp_out_dir, + rollupOptions: { + external: (id) => { + return ( + id !== remote_entry && + id !== `../${path.basename(remote_entry)}` && + !id.endsWith(`/__sibling__.${remote}`) && + id !== remote_file + ); + }, + input: { + [`remote/${remote.slice(0, -3)}`]: remote_file, + [path.basename(remote_entry.slice(0, -3))]: remote_entry + } + } + } + }); + + fs.copyFileSync(path.join(temp_out_dir, 'remote', remote), remote_file); + rimraf(temp_out_dir); + rimraf(tmp_file); + rimraf(path.join(out, 'server', 'remote', `__sibling__.${remote}`)); + } + } +} + +/** + * Moves the remote files to a sibling file and rewrites the original remote file to import from that sibling file, + * enhancing the remote functions with their hashed ID. + * This is not done through a self-import like during DEV because we want to treeshake prerendered remote functions + * later, which wouldn't work if we do a self-import and iterate over all exports (since we're reading them then). + * @param {string} out + */ +export function build_remotes(out) { + if (!exists(out)) return + + const remote_dir = path.join(out, 'server', 'remote'); + + for (const remote_file_name of fs.readdirSync(remote_dir)) { + const remote_file_path = path.join(remote_dir, remote_file_name); + const sibling_file_name = `__sibling__.${remote_file_name}`; + const sibling_file_path = path.join(remote_dir, sibling_file_name); + const hashed_id = remote_file_name.slice(0, -3); // remove .js extension + const file_content = fs.readFileSync(remote_file_path, 'utf-8'); + + fs.writeFileSync(sibling_file_path, file_content); + fs.writeFileSync( + remote_file_path, + dedent` + import * as $$_self_$$ from './${sibling_file_name}'; + ${enhance_remotes(hashed_id, remote_file_path)} + export * from './${sibling_file_name}'; + ` + ); + } +} + +/** + * Generate the code that enhances the remote functions with their hashed ID. + * @param {string} hashed_id + * @param {string} remote_file_path + */ +export function enhance_remotes(hashed_id, remote_file_path) { + return dedent` + for (const key in $$_self_$$) { + if (key === 'default') { + throw new Error( + 'Cannot use a default export in a remote file. Please use named exports instead. (in ${posixify(remote_file_path)})' + ); + } + const fn = $$_self_$$[key]; + if (fn.__?.type === 'form') { + fn.__.set_action('${hashed_id}/' + key); + } else if (fn.__?.type === 'query' || fn.__?.type === 'prerender' || fn.__?.type === 'cache') { + fn.__.id = '${hashed_id}/' + key; + } else if (fn.__?.type !== 'command') { + throw new Error('Invalid export from remote file ${posixify(remote_file_path)}: ' + key + ' is not a remote function. Can only export remote functions from a .remote file'); + } + } + ` +} + +/** + * @param {string} out + */ +function exists(out) { + return fs.existsSync(path.join(out, 'server', 'remote')) +} \ No newline at end of file diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 818cc09096ef..c9f79e12d885 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 '../../../utils/hash.js'; import { create_node_analyser } from '../static_analysis/index.js'; const cwd = process.cwd(); @@ -266,6 +267,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; @@ -331,6 +338,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 6e9d0c7b1327..0b9433c3af6c 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -22,7 +22,7 @@ import { write_client_manifest } from '../../core/sync/write_client_manifest.js' import prerender from '../../core/postbuild/prerender.js'; import analyse from '../../core/postbuild/analyse.js'; import { s } from '../../utils/misc.js'; -import { hash } from '../../runtime/hash.js'; +import { hash } from '../../utils/hash.js'; import { dedent, isSvelte5Plus } from '../../core/sync/utils.js'; import { env_dynamic_private, @@ -36,6 +36,11 @@ import { } from './module_ids.js'; import { import_peer } from '../../utils/import.js'; import { compact } from '../../utils/array.js'; +import { + build_remotes, + enhance_remotes, + treeshake_prerendered_remotes +} from './build/build_remote.js'; const cwd = process.cwd(); @@ -167,6 +172,9 @@ let secondary_build_started = false; /** @type {import('types').ManifestData} */ let manifest_data; +/** @type {import('types').ServerMetadata['remotes'] | undefined} only set at build time */ +let remote_exports = undefined; + /** * Returns the SvelteKit Vite plugin. Vite executes Rollup hooks as well as some of its own. * Background reading is available at: @@ -320,6 +328,9 @@ async function kit({ svelte_config }) { __SVELTEKIT_APP_VERSION_POLL_INTERVAL__: s(kit.version.pollInterval), __SVELTEKIT_DEV__: 'false', __SVELTEKIT_EMBEDDED__: kit.embedded ? 'true' : 'false', + __SVELTEKIT_EXPERIMENTAL__REMOTE_FUNCTIONS__: kit.experimental.remoteFunctions + ? 'true' + : 'false', __SVELTEKIT_CLIENT_ROUTING__: kit.router.resolution === 'client' ? 'true' : 'false' }; @@ -331,6 +342,9 @@ async function kit({ svelte_config }) { __SVELTEKIT_APP_VERSION_POLL_INTERVAL__: '0', __SVELTEKIT_DEV__: 'true', __SVELTEKIT_EMBEDDED__: kit.embedded ? 'true' : 'false', + __SVELTEKIT_EXPERIMENTAL__REMOTE_FUNCTIONS__: kit.experimental.remoteFunctions + ? 'true' + : 'false', __SVELTEKIT_CLIENT_ROUTING__: kit.router.resolution === 'client' ? 'true' : 'false' }; @@ -397,6 +411,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/remote.svelte.js`; + } if (id.startsWith('__sveltekit/')) { return `\0virtual:${id}`; } @@ -577,6 +594,95 @@ Tips: } }; + /** @type {import('vite').ViteDevServer} */ + let dev_server; + + /** @type {import('vite').Plugin} */ + const plugin_remote = { + name: 'vite-plugin-sveltekit-remote', + + configureServer(_dev_server) { + dev_server = _dev_server; + }, + + async transform(code, id, opts) { + if (!svelte_config.kit.moduleExtensions.some((ext) => id.endsWith(`.remote${ext}`))) { + return; + } + + const hashed_id = hash(posixify(id)); + + // For SSR, use a self-import at dev time and a separate function at build time + // to iterate over all exports of the file and add the necessary metadata + if (opts?.ssr) { + /** using @type {import('types').RemoteInfo} in here */ + return !dev_server + ? code + : code + + dedent` + // Auto-generated part, do not edit + import * as $$_self_$$ from './${path.basename(id)}'; + ${enhance_remotes(hashed_id, id)} + `; + } + + // For the client, read the exports and create a new module that only contains fetch functions with the correct metadata + + /** @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) { + remotes.set(name, value); + } + } else if (dev_server) { + const modules = await dev_server.ssrLoadModule(id); + for (const [name, value] of Object.entries(modules)) { + const type = value?.__?.type; + if (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' + ); + } + + const exports = []; + const specifiers = []; + + for (const [type, _exports] of remotes) { + 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 query/command/prerender/cache/form() {...}` + 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', @@ -599,6 +705,7 @@ Tips: if (ssr) { input.index = `${runtime_directory}/server/index.js`; input.internal = `${kit.outDir}/generated/server/internal.js`; + input['remote-entry'] = `${runtime_directory}/app/server/remote.js`; // add entry points for every endpoint... manifest_data.routes.forEach((route) => { @@ -630,6 +737,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 { @@ -834,6 +946,8 @@ Tips: output_config: svelte_config.output }); + remote_exports = metadata.remotes; + log.info('Building app'); // create client build @@ -984,6 +1098,9 @@ Tips: static_exports ); + // ...make sure remote exports have their IDs assigned... + build_remotes(out); + // ...and prerender const { prerendered, prerender_map } = await prerender({ hash: kit.router.type === 'hash', @@ -1005,6 +1122,9 @@ Tips: })};\n` ); + // remove prerendered remote functions + await treeshake_prerendered_remotes(out); + if (service_worker_entry_file) { if (kit.paths.assets) { throw new Error('Cannot use service worker alongside config.kit.paths.assets'); @@ -1075,7 +1195,13 @@ Tips: } }; - return [plugin_setup, plugin_virtual_modules, plugin_guard, plugin_compile]; + return [ + plugin_setup, + kit.experimental.remoteFunctions && plugin_remote, + plugin_virtual_modules, + plugin_guard, + plugin_compile + ].filter((p) => !!p); } /** diff --git a/packages/kit/src/runtime/app/navigation.js b/packages/kit/src/runtime/app/navigation.js index ca3f95dd5984..dd210cdb5fd3 100644 --- a/packages/kit/src/runtime/app/navigation.js +++ b/packages/kit/src/runtime/app/navigation.js @@ -5,6 +5,7 @@ export { goto, invalidate, invalidateAll, + refreshAll, onNavigate, preloadCode, preloadData, diff --git a/packages/kit/src/runtime/app/server/index.js b/packages/kit/src/runtime/app/server/index.js index 19c384932107..04f77577c208 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, prerender, command, form } 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..9018aaf832c4 --- /dev/null +++ b/packages/kit/src/runtime/app/server/remote.js @@ -0,0 +1,961 @@ +/** @import { RemoteFormAction, RemoteQuery, RequestEvent, ActionFailure as IActionFailure } from '@sveltejs/kit' */ +/** @import { RemotePrerenderEntryGenerator, RemoteInfo, ServerHooks, MaybePromise } from 'types' */ +/** @import { StandardSchemaV1 } from '@standard-schema/spec' */ + +import { uneval, parse } from 'devalue'; +import { error, json } from '@sveltejs/kit'; +import { ActionFailure } from '@sveltejs/kit/internal'; +import { DEV } from 'esm-env'; +import { getRequestEvent, with_event } from './event.js'; +import { get_remote_info } from '../../server/remote.js'; +import { create_remote_cache_key, stringify, stringify_remote_arg } from '../../shared.js'; +import { prerendering } from '__sveltekit/environment'; +import { app_dir, base } from '__sveltekit/paths'; + +/** + * Creates a remote function that can be invoked like a regular function within components. + * The given function is invoked directly on the backend and via a fetch call on the client. + * ```ts + * import { blogPosts } from '$lib/server/db'; + * + * export const blogPosts = query(() => blogPosts.getAll()); + * ``` + * ```svelte + * + * + * {#await blogPosts() then posts} + * + * {/await} + * ``` + * + * @template Output + * @overload + * @param {() => Output} fn + * @returns {RemoteQuery} + */ +/** + * Creates a remote function that can be invoked like a regular function within components. + * The given function is invoked directly on the backend and via a fetch call on the client. + * ```ts + * import { blogPosts } from '$lib/server/db'; + * + * export const blogPosts = query(() => blogPosts.getAll()); + * ``` + * ```svelte + * + * + * {#await blogPosts() then posts} + * + * {/await} + * ``` + * + * @template Input + * @template Output + * @overload + * @param {'unchecked'} validate + * @param {(arg: Input) => Output} fn + * @returns {RemoteQuery} + */ +/** + * Creates a remote function that can be invoked like a regular function within components. + * The given function is invoked directly on the backend and via a fetch call on the client. + * ```ts + * import { blogPosts } from '$lib/server/db'; + * + * export const blogPosts = query(() => blogPosts.getAll()); + * ``` + * ```svelte + * + * + * {#await blogPosts() then posts} + * + * {/await} + * ``` + * + * @template {StandardSchemaV1} Schema + * @template Output + * @overload + * @param {Schema} schema + * @param {(arg: StandardSchemaV1.InferOutput) => Output} fn + * @returns {RemoteQuery, Output>} + */ +/** + * @template Input + * @template Output + * @param {any} validate_or_fn + * @param {(args?: Input) => Output} [maybe_fn] + * @returns {RemoteQuery} + */ +/*@__NO_SIDE_EFFECTS__*/ +export function query(validate_or_fn, maybe_fn) { + check_experimental('query'); + + /** @type {(arg?: Input) => Output} */ + const fn = maybe_fn ?? validate_or_fn; + /** @type {(arg?: any) => MaybePromise} */ + const validate = create_validator(validate_or_fn, maybe_fn); + + /** @type {RemoteQuery} */ + const wrapper = (arg) => { + /** @type {Partial>>} */ + const promise = (async () => { + if (prerendering) { + throw new Error( + 'Cannot call query() from $app/server while prerendering, as prerendered pages need static data. Use prerender() instead' + ); + } + + // TODO don't do the additional work when we're being called from the client? + const event = getRequestEvent(); + const result = await get_response( + /** @type {RemoteInfo} */ (/** @type {any} */ (wrapper).__).id, + arg, + event, + () => run_remote_function(event, false, arg, validate, fn) + ); + return result; + })(); + + promise.refresh = async () => { + const event = getRequestEvent(); + const info = get_remote_info(event); + const refreshes = info.refreshes; + if (!refreshes) { + throw new Error( + 'Cannot call refresh on a query that is not executed in the context of a command/form remote function' + ); + } + + refreshes[ + create_remote_cache_key( + /** @type {RemoteInfo} */ (/** @type {any} */ (wrapper).__).id, + stringify_remote_arg(arg, info.transport) + ) + ] = await /** @type {Promise} */ (promise); + }; + + promise.override = () => { + throw new Error('Cannot call override on the server'); + }; + + return /** @type {ReturnType>} */ (promise); + }; + + Object.defineProperty(wrapper, '__', { + value: /** @type {RemoteInfo} */ ({ type: 'query', id: '' }), + writable: false, + enumerable: false, + configurable: false + }); + + return wrapper; +} + +/** + * Creates a prerendered remote function. The given function is invoked at build time and the result is stored to disk. + * ```ts + * import { blogPosts } from '$lib/server/db'; + * + * export const blogPosts = prerender(() => blogPosts.getAll()); + * ``` + * + * In case your function has arguments, you need to provide an `entries` function that returns a list of arrays representing the arguments to be used for prerendering. + * ```ts + * import { blogPosts } from '$lib/server/db'; + * + * export const blogPost = prerender( + * (id: string) => blogPosts.get(id), + * { entries: () => blogPosts.getAll().map((post) => ([post.id])) } + * ); + * ``` + * + * @template Output + * @overload + * @param {() => Output} fn + * @param {{ entries?: RemotePrerenderEntryGenerator, dynamic?: boolean }} [options] + * @returns {RemoteQuery} + */ +/** + * Creates a prerendered remote function. The given function is invoked at build time and the result is stored to disk. + * ```ts + * import { blogPosts } from '$lib/server/db'; + * + * export const blogPosts = prerender(() => blogPosts.getAll()); + * ``` + * + * In case your function has arguments, you need to provide an `entries` function that returns a list of arrays representing the arguments to be used for prerendering. + * ```ts + * import { blogPosts } from '$lib/server/db'; + * + * export const blogPost = prerender( + * (id: string) => blogPosts.get(id), + * { entries: () => blogPosts.getAll().map((post) => ([post.id])) } + * ); + * ``` + * + * @template Input + * @template Output + * @overload + * @param {'unchecked'} validate + * @param {(arg: Input) => Output} fn + * @param {{ entries?: RemotePrerenderEntryGenerator, dynamic?: boolean }} [options] + * @returns {RemoteQuery} + */ +/** + * Creates a prerendered remote function. The given function is invoked at build time and the result is stored to disk. + * ```ts + * import { blogPosts } from '$lib/server/db'; + * + * export const blogPosts = prerender(() => blogPosts.getAll()); + * ``` + * + * In case your function has arguments, you need to provide an `entries` function that returns a list of arrays representing the arguments to be used for prerendering. + * ```ts + * import { blogPosts } from '$lib/server/db'; + * + * export const blogPost = prerender( + * (id: string) => blogPosts.get(id), + * { entries: () => blogPosts.getAll().map((post) => ([post.id])) } + * ); + * ``` + * + * @template {StandardSchemaV1} Schema + * @template Output + * @overload + * @param {Schema} schema + * @param {(arg: StandardSchemaV1.InferOutput) => Output} fn + * @param {{ entries?: RemotePrerenderEntryGenerator>, dynamic?: boolean }} [options] + * @returns {RemoteQuery, Output>} + */ +/** + * @template Input + * @template Output + * @param {any} validate_or_fn + * @param {any} [fn_or_options] + * @param {{ entries?: RemotePrerenderEntryGenerator, dynamic?: boolean }} [maybe_options] + * @returns {RemoteQuery} + */ +/*@__NO_SIDE_EFFECTS__*/ +export function prerender(validate_or_fn, fn_or_options, maybe_options) { + check_experimental('prerender'); + + const maybe_fn = typeof fn_or_options === 'function' ? fn_or_options : undefined; + /** @type {typeof maybe_options} */ + const options = maybe_options ?? (maybe_fn ? undefined : fn_or_options); + /** @type {(arg?: Input) => Output} */ + const fn = maybe_fn ?? validate_or_fn; + /** @type {(arg?: any) => MaybePromise} */ + const validate = create_validator(validate_or_fn, maybe_fn); + + /** @type {RemoteQuery} */ + const wrapper = (arg) => { + /** @type {Partial>>} */ + const promise = (async () => { + const event = getRequestEvent(); + const info = get_remote_info(event); + const stringified_arg = stringify_remote_arg(arg, info.transport); + const id = /** @type {RemoteInfo} */ (/** @type {any} */ (wrapper).__).id; + const url = `${base}/${app_dir}/remote/${id}${stringified_arg ? `/${stringified_arg}` : ''}`; + + if (!info.prerendering && !DEV && !event.isRemoteRequest) { + try { + return await get_response(id, arg, event, async () => { + const response = await fetch(event.url.origin + url); + if (!response.ok) { + throw new Error('Prerendered response not found'); + } + const prerendered = await response.json(); + info.results[create_remote_cache_key(id, stringified_arg)] = prerendered.result; + return parse_remote_response(prerendered.result, info.transport); + }); + } catch { + // not available prerendered, fallback to normal function + } + } + + if (info.prerendering?.remote_responses.has(url)) { + return /** @type {Promise} */ (info.prerendering.remote_responses.get(url)); + } + + const maybe_promise = get_response(id, arg, event, () => + run_remote_function(event, false, arg, validate, fn) + ); + + if (info.prerendering) { + info.prerendering.remote_responses.set(url, Promise.resolve(maybe_promise)); + Promise.resolve(maybe_promise).catch(() => info.prerendering?.remote_responses.delete(url)); + } + + const result = await maybe_promise; + + if (info.prerendering) { + const body = { type: 'result', result: stringify(result, info.transport) }; + info.prerendering.dependencies.set(url, { + body: JSON.stringify(body), + response: json(body) + }); + } + + return result; + })(); + + promise.refresh = async () => { + if (!options?.dynamic) { + console.warn( + 'Calling refresh on a prerendered function that is not dynamic will not have any effect' + ); + } + + const event = getRequestEvent(); + const info = get_remote_info(event); + const refreshes = info.refreshes; + if (!refreshes) { + throw new Error( + 'Cannot call refresh on a prerender function that is not executed in the context of a command/form remote function' + ); + } + + refreshes[ + create_remote_cache_key( + /** @type {RemoteInfo} */ (/** @type {any} */ (wrapper).__).id, + stringify_remote_arg(arg, info.transport) + ) + ] = await /** @type {Promise} */ (promise); + }; + + promise.override = () => { + throw new Error('Cannot call override on the server'); + }; + + return /** @type {ReturnType>} */ (promise); + }; + + Object.defineProperty(wrapper, '__', { + value: /** @type {RemoteInfo} */ ({ + type: 'prerender', + id: '', + entries: options?.entries, + dynamic: options?.dynamic + }), + configurable: false, + writable: false, + enumerable: false + }); + + return wrapper; +} + +// TODO decide how we wanna shape this API, until then commented out +// /** +// * Creates a cached remote function. The cache duration is set through the `expiration` property of the `config` object. +// * ```ts +// * import { blogPosts } from '$lib/server/db'; +// * +// * export const blogPosts = cache( +// * () => blogPosts.getAll(), +// * // cache for 60 seconds +// * { expiration: 60 } +// * ); +// * ``` +// * The cache is deployment provider-specific; some providers may not support it. Consult your adapter's documentation for details. +// * +// * @template {any[]} Input +// * @template Output +// * @param {(...args: Input) => Output} fn +// * @param {{expiration: number } & Record} config +// * @returns {RemoteQuery} +// */ +// export function cache(fn, config) { +// /** +// * @param {Input} args +// * @returns {Promise>} +// */ +// const wrapper = async (...args) => { +// if (prerendering) { +// throw new Error( +// 'Cannot call cache() from $app/server while prerendering, as prerendered pages need static data. Use prerender() instead' +// ); +// } + +// const event = getRequestEvent(); +// const info = get_remote_info(event); +// const stringified_args = stringify_remote_args(args, info.transport); +// const cached = await wrapper.cache.get(stringified_args); + +// if (typeof cached === 'string') { +// if (!event.isRemoteRequest) { +// info.results[stringified_args] = cached; +// } +// // TODO in case of a remote request we will stringify the result again right aftewards - save the work somehow? +// return parse_remote_response(cached, info.transport); +// } else { +// const result = await fn(...args); +// uneval_remote_response(wrapper.__.id, args, result, event); +// await wrapper.cache.set(stringified_args, stringify(result, info.transport)); +// return result; +// } +// }; + +// /** @type {{ get(input: string): MaybePromise; set(input:string, output: string): MaybePromise; delete(input:string): MaybePromise }} */ +// let cache = { +// // TODO warn somehow when adapter does not support cache? +// get() {}, +// set() {}, +// delete() {} +// }; + +// if (DEV) { +// // In memory cache +// /** @type {Record} */ +// const cached = {}; +// cache = { +// get(input) { +// return cached[input]; +// }, +// set(input, output) { +// const config = /** @type {RemoteInfo & { type: 'cache' }} */ (wrapper.__).config; +// cached[input] = output; +// if (typeof config.expiration === 'number') { +// setTimeout(() => { +// delete cached[input]; +// }, config.expiration * 1000); +// } +// }, +// delete(input) { +// delete cached[input]; +// } +// }; +// } + +// wrapper.cache = cache; + +// /** @type {RemoteQuery['refresh']} */ +// wrapper.refresh = (...args) => { +// // TODO is this agnostic enough / fine to require people calling this during a request event? +// const info = get_remote_info(getRequestEvent()); +// // TODO what about the arguments? are they required? we would need to have a way to know all the variants of a cached function +// wrapper.cache.delete(stringify_remote_args(args, info.transport)); +// }; + +// wrapper.override = () => { +// throw new Error('Cannot call override on the server'); +// }; + +// Object.defineProperty(wrapper, '__', { +// value: /** @type {RemoteInfo} */ ({ type: 'cache', id: '', config }), +// writable: false, +// enumerable: true, +// configurable: false +// }); + +// return wrapper; +// } + +/** + * Creates a remote command. The given function is invoked directly on the server and via a fetch call on the client. + * + * ```ts + * import { blogPosts } from '$lib/server/db'; + * + * export interface BlogPost { + * id: string; + * title: string; + * content: string; + * } + * + * export const like = command((postId: string) => { + * blogPosts.get(postId).like(); + * }); + * ``` + * + * ```svelte + * + * + *

{post.title}

+ *

{post.content}

+ * + * ``` + * + * @template Output + * @overload + * @param {() => Output} fn + * @returns {() => Promise> & { updates: (...queries: Array> | ReturnType>['withOverride']>>) => Promise> }} + */ +/** + * Creates a remote command. The given function is invoked directly on the server and via a fetch call on the client. + * + * ```ts + * import { blogPosts } from '$lib/server/db'; + * + * export interface BlogPost { + * id: string; + * title: string; + * content: string; + * } + * + * export const like = command((postId: string) => { + * blogPosts.get(postId).like(); + * }); + * ``` + * + * ```svelte + * + * + *

{post.title}

+ *

{post.content}

+ * + * ``` + * + * @template Input + * @template Output + * @overload + * @param {'unchecked'} validate + * @param {(arg: Input) => Output} fn + * @returns {(arg: Input) => Promise> & { updates: (...queries: Array> | ReturnType>['withOverride']>>) => Promise> }} + */ +/** + * Creates a remote command. The given function is invoked directly on the server and via a fetch call on the client. + * + * ```ts + * import { blogPosts } from '$lib/server/db'; + * + * export interface BlogPost { + * id: string; + * title: string; + * content: string; + * } + * + * export const like = command((postId: string) => { + * blogPosts.get(postId).like(); + * }); + * ``` + * + * ```svelte + * + * + *

{post.title}

+ *

{post.content}

+ * + * ``` + * + * @template {StandardSchemaV1} Schema + * @template Output + * @overload + * @param {Schema} validate + * @param {(arg: StandardSchemaV1.InferOutput) => Output} fn + * @returns {(arg: StandardSchemaV1.InferOutput) => Promise> & { updates: (...queries: Array> | ReturnType>['withOverride']>>) => Promise> }} + */ +/** + * @template Input + * @template Output + * @param {any} validate_or_fn + * @param {(arg?: Input) => Output} [maybe_fn] + * @returns {(arg?: Input) => Promise> & { updates: (...queries: Array> | ReturnType>['withOverride']>>) => Promise> }} + */ +/*@__NO_SIDE_EFFECTS__*/ +export function command(validate_or_fn, maybe_fn) { + check_experimental('command'); + + /** @type {(arg?: Input) => Output} */ + const fn = maybe_fn ?? validate_or_fn; + /** @type {(arg?: any) => MaybePromise} */ + const validate = create_validator(validate_or_fn, maybe_fn); + + /** + * @param {Input} [arg] + */ + const wrapper = (arg) => { + if (prerendering) { + throw new Error( + 'Cannot call command() from $app/server while prerendering, as prerendered pages need static data. Use prerender() instead' + ); + } + + const event = getRequestEvent(); + + if (!event.isRemoteRequest) { + throw new Error( + 'Cannot call command() from $app/server during server side rendering. The only callable remote functions are query() and prerender().' + ); + } + + if (!get_remote_info(event).refreshes) { + get_remote_info(event).refreshes = {}; + } + + const promise = Promise.resolve(run_remote_function(event, true, arg, validate, fn)); + // @ts-expect-error + promise.updates = () => { + throw new Error('Cannot call `command(...).updates(...)` on the server'); + }; + return /** @type {Promise> & { updates: (...arsg: any[]) => any}} */ (promise); + }; + + /** @type {any} */ (wrapper).__ = /** @type {RemoteInfo} */ ({ + type: 'command' + }); + + return wrapper; +} + +/** + * Creates a form action. The passed function will be called when the form is submitted. + * Returns an object that can be spread onto a form element to connect it to the function. + * ```ts + * import { createPost } from '$lib/server/db'; + * + * export const createPost = form((formData) => { + * const title = formData.get('title'); + * const content = formData.get('content'); + * return createPost({ title, content }); + * }); + * ``` + * ```svelte + * + * + *
+ * + *