diff --git a/.changeset/heavy-insects-wash.md b/.changeset/heavy-insects-wash.md new file mode 100644 index 000000000000..2fb26c166f46 --- /dev/null +++ b/.changeset/heavy-insects-wash.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: remote functions diff --git a/documentation/docs/30-advanced/20-hooks.md b/documentation/docs/30-advanced/20-hooks.md index 4a2239ad2240..745072403f7e 100644 --- a/documentation/docs/30-advanced/20-hooks.md +++ b/documentation/docs/30-advanced/20-hooks.md @@ -143,6 +143,37 @@ export async function handleFetch({ event, request, fetch }) { } ``` +### handleValidationError + +This hook is called when a remote function is called with an argument that does not match its schema. It must return an object matching the shape of [`App.Error`](types#Error). + +Say you have a remote function that expects a string as its argument ... + +```js +/// file: todos.remote.js +import z from 'zod'; +import { query } from '$app/server'; + +export getTodo = query(z.string(), (id) => { + // implementation... +}); +``` + +...but it is called with something that doesn't match the schema — such as a number (e.g `await getTodos(1)`) — then validation will fail, the server will respond with a [400 status code](https://http.dog/400), and the function will throw with the message 'Bad Request'. + +To customise this message and add additional properties to the error object, implement `handleValidationError`: + +```js +/// file: src/hooks.server.js +import z from 'zod'; + +/** @type {import('@sveltejs/kit').HandleValidationError} */ +export const handleValidationError = ({ issues }) => { + // @ts-expect-error The types are too strict and disallow this but it's perfectly valid + return { message: 'Schema Error', validationErrors: z.treeifyError({ issues })}; +} +``` + ## Shared hooks The following can be added to `src/hooks.server.js` _and_ `src/hooks.client.js`: diff --git a/packages/enhanced-img/src/vite-plugin.js b/packages/enhanced-img/src/vite-plugin.js index 99081b869a49..92ea66626b4a 100644 --- a/packages/enhanced-img/src/vite-plugin.js +++ b/packages/enhanced-img/src/vite-plugin.js @@ -316,7 +316,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 72486d026c49..06385f980595 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.119", "@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 419f30416d9c..90da427483d7 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..c7a41d29529f 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 [hash, load] of Object.entries(manifest._.remotes)) { + const modules = await load(); + 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(hash, 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..3e4f51fce01c 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.__.has_arg) { + 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 b7c5e93d658d..57bd98951768 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..5dcd7cdec89a 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'; @@ -67,8 +67,9 @@ export async function get_hooks() { let handle; let handleFetch; let handleError; + let handleValidationError; let init; - ${server_hooks ? `({ handle, handleFetch, handleError, init } = await import(${s(server_hooks)}));` : ''} + ${server_hooks ? `({ handle, handleFetch, handleError, handleValidationError, init } = await import(${s(server_hooks)}));` : ''} let reroute; let transport; @@ -78,6 +79,7 @@ export async function get_hooks() { handle, handleFetch, handleError, + handleValidationError, init, reroute, transport 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 afc5f1d6d450..048a14835772 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -18,6 +18,7 @@ import { } from '../types/private.js'; import { BuildData, SSRNodeLoader, SSRRoute, ValidatedConfig } from 'types'; import type { SvelteConfig } from '@sveltejs/vite-plugin-svelte'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; export { PrerenderOption } from '../types/private.js'; @@ -72,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 @@ -401,6 +402,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. */ @@ -768,6 +779,15 @@ export type HandleServerError = (input: { message: string; }) => MaybePromise; +/** + * The server-side [`handleValidationError`](https://svelte.dev/docs/kit/hooks#Server-hooks-handleValidationError) hook runs when schema validation fails in a remote function. + * + * If schema validation fails in a remote function, this function will be called with the validation issues and the event. + * This function is expected return an object shape that matches `App.Error`. + */ +export type HandleValidationError = + (input: { issues: Issue[]; event: RequestEvent }) => MaybePromise; + /** * The client-side [`handleError`](https://svelte.dev/docs/kit/hooks#Shared-hooks-handleError) hook runs when an unexpected error is thrown while navigating. * @@ -1242,6 +1262,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; } /** @@ -1316,6 +1341,8 @@ export interface SSRManifest { _: { client: NonNullable; nodes: SSRNodeLoader[]; + /** hashed filename -> import to that file */ + remotes: Record Promise>; routes: SSRRoute[]; prerendered_routes: Set; matchers: () => Promise>; @@ -1493,4 +1520,266 @@ 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 + * + * + *
{ + * // `data` is an instance of FormData (https://developer.mozilla.org/en-US/docs/Web/API/FormData) + * const text = data.get('text'); + * const todo = { text, done: false }; + * + * // `updates` and `withOverride` enable optimistic UI updates + * await submit().updates(getTodos.withOverride((todos) => [...todos, todo])); + * })}> + * + * + *
+ * + *
    + * {#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 `command` function. + * Call it with the input arguments to execute the command. + * + * Note: Prefer remote `form` functions when possible, as they + * work without JavaScript enabled. + * + * ```svelte + * + * + * + * + * ``` + * Use the `updates` method to specify which queries to update in response to the command. + * ```svelte + * + * + * + * + * + *
    + * {#each await getTodos() as todo} + *
  • {todo.text}
  • + * {/each} + *
+ * ``` + */ +export type RemoteCommand = (arg: Input) => Promise> & { + updates: ( + ...queries: Array< + | ReturnType> + | ReturnType>['withOverride']> + > + ) => Promise>; +}; + +/** + * 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 error in case the query fails. Most often this is a [`HttpError`](https://svelte.dev/docs/kit/@sveltejs-kit#HttpError) but it isn't guaranteed to be. */ + get error(): any; + /** `true` before the first result is available and during refreshes */ + get loading(): boolean; + /** + * On the client, this function will re-fetch the query from the server. + * + * 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 outside of a `command` or `form` remote function (for those, use `withOverride`). + * `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 + * + * + *

Select items to remove

+ * + *
    + * {#each list as item} + *
  • {item.text}
  • + * + * {/each} + *
+ * + * + * + * + * ``` + * + * 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; + }; +} & ( + | { + /** The current value of the query. Undefined as long as there's no value yet */ + get current(): undefined; + status: 'loading'; + } + | { + /** The current value of the query. Undefined as long as there's no value yet */ + get current(): Awaited; + status: 'success' | 'reloading'; + } + | { + /** The current value of the query. Undefined as long as there's no value yet */ + get current(): Awaited | undefined; + status: 'error'; + } + ); + 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..fcc0c1210503 --- /dev/null +++ b/packages/kit/src/exports/vite/build/build_remote.js @@ -0,0 +1,182 @@ +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'; +import { s } from '../../../utils/misc.js'; +import { hash } from '../../../utils/hash.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__.') || remote === '__sveltekit__remote.js') 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); + fn.__.name = key; + } else if (fn.__?.type === 'query' || fn.__?.type === 'prerender' || fn.__?.type === 'cache') { + fn.__.id = '${remote.slice(0, -3)}/' + key; + fn.__.name = key; + } else if (fn.__?.type === 'command') { + fn.__.name = 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.endsWith(`/__sveltekit__remote.js`) && + 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}`)); + } + } +} + +export const remote_code = dedent` + export default function enhance_remote_functions(exports, hashed_id, original_filename) { + for (const key in exports) { + if (key === 'default') { + throw new Error( + 'Cannot use a default export in a remote file. Please use named exports instead. (in ' + original_filename + ')' + ); + } + const fn = exports[key]; + if (fn?.__?.type === 'form') { + fn.__.set_action(hashed_id + '/' + key); + fn.__.name = key; + } else if (fn?.__?.type === 'query' || fn?.__?.type === 'prerender' || fn?.__?.type === 'cache') { + fn.__.id = hashed_id + '/' + key; + fn.__.name = key; + } else if (fn?.__?.type === 'command') { + fn.__.name = key; + } else { + throw new Error('Invalid export from remote file ' + original_filename + ': ' + key + ' is not a remote function. Can only export remote functions from a .remote file'); + } + } + } +`; + +/** + * 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 + * @param {(path: string) => string} normalize_id + * @param {import('types').ManifestData} manifest_data + */ +export function build_remotes(out, normalize_id, manifest_data) { + if (!exists(out)) return + + const remote_dir = path.join(out, 'server', 'remote'); + + // Create a mapping from hashed ID to original filename + const hash_to_original = new Map(); + for (const filename of manifest_data.remotes) { + const hashed_id = hash(posixify(filename)); + hash_to_original.set(hashed_id, filename); + } + + 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 original_filename = normalize_id(hash_to_original.get(hashed_id) || remote_file_name); + const file_content = fs.readFileSync(remote_file_path, 'utf-8'); + + fs.writeFileSync(sibling_file_path, file_content); + fs.writeFileSync( + remote_file_path, + // We can't use __sveltekit/remotes here because it runs after the build where aliases would be resolved + dedent` + import * as $$_self_$$ from './${sibling_file_name}'; + ${enhance_remotes(hashed_id, './__sveltekit__remote.js', original_filename)} + export * from './${sibling_file_name}'; + ` + ); + } + + fs.writeFileSync( + path.join(remote_dir, '__sveltekit__remote.js'), + remote_code, + 'utf-8' + ); +} + +/** + * Generate the code that enhances the remote functions with their hashed ID. + * @param {string} hashed_id + * @param {string} import_path - where to import the helper function from + * @param {string} original_filename - The original filename for better error messages + */ +export function enhance_remotes(hashed_id, import_path, original_filename) { + return dedent` + import $$_enhance_remote_functions_$$ from '${import_path}'; + + $$_enhance_remote_functions_$$($$_self_$$, ${s(hashed_id)}, ${s(original_filename)}); + ` +} + +/** + * @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 7ec66dab251c..cffaa3fa3716 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 a0b8f5c29cd0..aad905da10fe 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, @@ -32,10 +32,17 @@ import { service_worker, sveltekit_environment, sveltekit_paths, - sveltekit_server + sveltekit_server, + sveltekit_remotes } from './module_ids.js'; import { import_peer } from '../../utils/import.js'; import { compact } from '../../utils/array.js'; +import { + build_remotes, + enhance_remotes, + remote_code, + treeshake_prerendered_remotes +} from './build/build_remote.js'; const cwd = process.cwd(); @@ -167,6 +174,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: @@ -213,6 +223,9 @@ async function kit({ svelte_config }) { const service_worker_entry_file = resolve_entry(kit.files.serviceWorker); const parsed_service_worker = path.parse(kit.files.serviceWorker); + const normalized_cwd = vite.normalizePath(cwd); + const normalized_lib = vite.normalizePath(kit.files.lib); + /** * A map showing which features (such as `$app/server:read`) are defined * in which chunks, so that we can later determine which routes use which features @@ -322,7 +335,8 @@ async function kit({ svelte_config }) { __SVELTEKIT_APP_VERSION_FILE__: s(`${kit.appDir}/version.json`), __SVELTEKIT_APP_VERSION_POLL_INTERVAL__: s(kit.version.pollInterval), __SVELTEKIT_DEV__: 'false', - __SVELTEKIT_EMBEDDED__: kit.embedded ? 'true' : 'false', + __SVELTEKIT_EMBEDDED__: s(kit.embedded), + __SVELTEKIT_EXPERIMENTAL__REMOTE_FUNCTIONS__: s(kit.experimental.remoteFunctions), __SVELTEKIT_CLIENT_ROUTING__: kit.router.resolution === 'client' ? 'true' : 'false' }; @@ -333,7 +347,8 @@ async function kit({ svelte_config }) { new_config.define = { __SVELTEKIT_APP_VERSION_POLL_INTERVAL__: '0', __SVELTEKIT_DEV__: 'true', - __SVELTEKIT_EMBEDDED__: kit.embedded ? 'true' : 'false', + __SVELTEKIT_EMBEDDED__: s(kit.embedded), + __SVELTEKIT_EXPERIMENTAL__REMOTE_FUNCTIONS__: s(kit.experimental.remoteFunctions), __SVELTEKIT_CLIENT_ROUTING__: kit.router.resolution === 'client' ? 'true' : 'false' }; @@ -381,8 +396,6 @@ async function kit({ svelte_config }) { parsed_importer.name === parsed_service_worker.name; if (importer_is_service_worker && id !== '$service-worker' && id !== '$env/static/public') { - const normalized_cwd = vite.normalizePath(cwd); - const normalized_lib = vite.normalizePath(kit.files.lib); throw new Error( `Cannot import ${normalize_id( id, @@ -400,6 +413,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}`; } @@ -413,8 +429,6 @@ async function kit({ svelte_config }) { : 'globalThis.__sveltekit_dev'; if (options?.ssr === false && process.env.TEST !== 'true') { - const normalized_cwd = vite.normalizePath(cwd); - const normalized_lib = vite.normalizePath(kit.files.lib); if ( is_illegal(id, { cwd: normalized_cwd, @@ -547,6 +561,10 @@ Tips: } `; } + + case sveltekit_remotes: { + return remote_code; + } } } }; @@ -580,6 +598,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, '__sveltekit/remotes', normalize_id(id, normalized_lib, normalized_cwd))} + `; + } + + // 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', @@ -602,6 +709,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) => { @@ -633,6 +741,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 +947,8 @@ Tips: output_config: svelte_config.output }); + remote_exports = metadata.remotes; + log.info('Building app'); // create client build @@ -984,6 +1099,9 @@ Tips: static_exports ); + // ...make sure remote exports have their IDs assigned... + build_remotes(out, (id) => normalize_id(id, normalized_lib, normalized_cwd), manifest_data); + // ...and prerender const { prerendered, prerender_map } = await prerender({ hash: kit.router.type === 'hash', @@ -1005,6 +1123,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 +1196,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/exports/vite/module_ids.js b/packages/kit/src/exports/vite/module_ids.js index 91b5caeddb4f..6fb41a8dedaf 100644 --- a/packages/kit/src/exports/vite/module_ids.js +++ b/packages/kit/src/exports/vite/module_ids.js @@ -10,6 +10,7 @@ export const service_worker = '\0virtual:service-worker'; export const sveltekit_environment = '\0virtual:__sveltekit/environment'; export const sveltekit_paths = '\0virtual:__sveltekit/paths'; export const sveltekit_server = '\0virtual:__sveltekit/server'; +export const sveltekit_remotes = '\0virtual:__sveltekit/remotes'; export const app_server = fileURLToPath( new URL('../../runtime/app/server/index.js', import.meta.url) 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..8b2ef5019223 --- /dev/null +++ b/packages/kit/src/runtime/app/server/remote.js @@ -0,0 +1,930 @@ +/** @import { RemoteFormAction, RemoteQuery, RemoteCommand, 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 & { __: RemoteInfo }} */ + const wrapper = (arg) => { + /** @type {Partial>>} */ + const promise = (async () => { + if (prerendering) { + throw new Error( + `Cannot call query '${wrapper.__.name}' while prerendering, as prerendered pages need static data. Use 'prerender' from $app/server 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} */ (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 query '${wrapper.__.name}' because it is not executed in the context of a command/form remote function` + ); + } + + refreshes[ + create_remote_cache_key( + /** @type {RemoteInfo} */ (wrapper.__).id, + stringify_remote_arg(arg, info.transport) + ) + ] = await /** @type {Promise} */ (promise); + }; + + promise.override = () => { + throw new Error(`Cannot call '${wrapper.__.name}.override()' on the server`); + }; + + return /** @type {ReturnType>} */ (promise); + }; + + Object.defineProperty(wrapper, '__', { + value: /** @type {RemoteInfo} */ ({ type: 'query', id: '' }) + }); + + 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 an argument, you need to provide an `entries` function that returns a list representing the arguments to be used for prerendering. + * ```ts + * import z from 'zod'; + * import { blogPosts } from '$lib/server/db'; + * + * export const blogPost = prerender( + * z.string(), + * (id) => 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 an argument, you need to provide an `entries` function that returns a list representing the arguments to be used for prerendering. + * ```ts + * import { blogPosts } from '$lib/server/db'; + * + * export const blogPost = prerender( + * 'unchecked', + * (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 an argument, you need to provide an `entries` function that returns a list representing the arguments to be used for prerendering. + * ```ts + * import z from 'zod'; + * import { blogPosts } from '$lib/server/db'; + * + * export const blogPost = prerender( + * z.string(), + * (id) => 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 & { __: RemoteInfo }} */ + 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 = 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 = () => { + throw new Error( + `Cannot call '${wrapper.__.name}.refresh()'. Remote prerender functions are immutable and cannot be refreshed.` + ); + }; + + promise.override = () => { + throw new Error(`Cannot call '${wrapper.__.name}.override()' on the server`); + }; + + return /** @type {ReturnType>} */ (promise); + }; + + Object.defineProperty(wrapper, '__', { + value: /** @type {RemoteInfo} */ ({ + type: 'prerender', + id: '', + has_arg: !!maybe_fn, + entries: options?.entries, + dynamic: options?.dynamic + }) + }); + + 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 }), +// }); + +// 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 {RemoteCommand} + */ +/** + * 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 {RemoteCommand} + */ +/** + * 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 {RemoteCommand, Output>} + */ +/** + * @template Input + * @template Output + * @param {any} validate_or_fn + * @param {(arg?: Input) => Output} [maybe_fn] + * @returns {RemoteCommand} + */ +/*@__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); + + /** @type {RemoteCommand & { __: RemoteInfo }} */ + const wrapper = (arg) => { + if (prerendering) { + throw new Error( + `Cannot call command '${wrapper.__.name}' while prerendering, as prerendered pages need static data. Use 'prerender' from $app/server instead` + ); + } + + const event = getRequestEvent(); + + if (!event.isRemoteRequest) { + throw new Error( + `Cannot call command '${wrapper.__.name}' during server side rendering. The only callable remote function types during server side rendering 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 '${wrapper.__.name}(...).updates(...)' on the server`); + }; + return /** @type {ReturnType>} */ (promise); + }; + + Object.defineProperty(wrapper, '__', { + value: /** @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 * as db from '$lib/server/db'; + * + * export const createPost = form((formData) => { + * const title = formData.get('title'); + * const content = formData.get('content'); + * return db.createPost({ title, content }); + * }); + * ``` + * ```svelte + * + * + *
+ * + *