Skip to content

Remote Functions #13986

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions documentation/docs/30-advanced/20-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,36 @@ 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 = ({ result }) => {
return { message: 'Schema Error', validationErrors: z.treeifyError(result.error)};
}
Comment on lines +171 to +173
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

per above, we will need to rethink this example

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It works, we just need to pass result instead of result.error (it will give you a type error I think but it works; will open an issue over at Zod.

```

## Shared hooks

The following can be added to `src/hooks.server.js` _and_ `src/hooks.client.js`:
Expand Down
2 changes: 1 addition & 1 deletion packages/enhanced-img/src/vite-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ const get_defaults = (prefix = '') => ({
publicPrefix: 'PUBLIC_',
privatePrefix: ''
},
experimental: {
remoteFunctions: false
},
files: {
assets: join(prefix, 'static'),
hooks: {
Expand Down
4 changes: 4 additions & 0 deletions packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ const options = object(
privatePrefix: string('')
}),

experimental: object({
remoteFunctions: boolean(false)
}),

files: object({
assets: string('static'),
hooks: object({
Expand Down
4 changes: 4 additions & 0 deletions packages/kit/src/core/generate_manifest/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 14 additions & 1 deletion packages/kit/src/core/postbuild/analyse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down Expand Up @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a .remote.ts file exports something like export lol = null then this fails cryptically:

Cannot read properties of null (reading '__')

I don't know if it's possible to restructure things such that we can reuse the logic in enhance_remotes (IIUC that needs this to run first) — maybe we need to duplicate stuff — but it would be nice to have a consistent 'lol is not a remote function' error

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, by deferring the check until later, this is the error I see:

Error: Invalid export from remote file /Users/rich/Development/SVELTE/KIT/kit/playgrounds/basic/.svelte-kit/output/server/remote/5mmnej.js: blah is not a remote function. Can only export remote functions from a .remote file

We could use path.relative(cwd, file) as we do elsewhere and it would become this...

Error: Invalid export from remote file .svelte-kit/output/server/remote/5mmnej.js: blah is not a remote function. Can only export remote functions from a .remote file

...but that still doesn't really help me — what the user needs to see is something like this:

Error: blah exported from src/lib/todos.remote.ts is invalid — all exports from this file must be remote functions

exports.set(type, (exports.get(type) ?? []).concat(name));
}
metadata.remotes.set(hash, exports);
}

return { metadata, static_exports };
}

Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/core/postbuild/fallback.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
});
Expand Down
50 changes: 48 additions & 2 deletions packages/kit/src/core/postbuild/prerender.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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<string, Set<string>>} */
const expected_hashlinks = new Map();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -460,8 +463,25 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env }) {
}
}

/** @type {Array<Function & { __: import('types').RemoteInfo & { type: 'prerender'}}>} */
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 };
Expand Down Expand Up @@ -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?
Comment on lines +524 to +526
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The separation is at best helpful and at worst harmless, though I would opt for prerender/data

if (remote_function.__.entries) {
for (const entry of await remote_function.__.entries()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

am wondering if entries is the right terminology to use here — we're using it because that's what it is for pages today, but in this context I wonder if arguments or something might be clearer

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

arguments as not that much clearer to me compared to entries, and entries is already for page prerendering, and since it's the same concept it felt better to also use the same name

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I almost think that's more reason to call it something else, as it's helpful to be able to distinguish between them. 'Entries' specifically means entry points for the crawler, which is almost the opposite concept — in one case you're describing the start of the process ('start at blog/one, blog/two and blog/three and see what you can find'), in the other you're describing the end ('the user is going to need this data so I'm just going to tell you about it up-front').

What about data?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data is too generic IMO. What about another very generic name: inputs

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
);
Comment on lines +541 to +544
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this right? the absence of entries doesn't indicate that there's no argument

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My first though was that if you have prerender with an argument but no entries then what are you prerendering? But if it happens as part of a page prerender then that's fine, so you're right, we gotta find a different indicator.

}
}

await q.done();

// handle invalid fragment links
Expand Down
22 changes: 21 additions & 1 deletion packages/kit/src/core/sync/create_manifest_data/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) {
Expand All @@ -41,6 +42,7 @@ export default function create_manifest_data({
hooks,
matchers,
nodes,
remotes,
routes
};
}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/core/sync/write_root.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function write_root(manifest_data, output) {
${
isSvelte5Plus()
? `<!-- svelte-ignore binding_property_non_reactive -->
<Pyramid_${l} bind:this={components[${l}]} data={data_${l}} {form} />`
<Pyramid_${l} bind:this={components[${l}]} data={data_${l}} {form} params={page.params}/>`
: `<svelte:component this={constructors[${l}]} bind:this={components[${l}]} data={data_${l}} {form} />`
}`;

Expand Down
6 changes: 4 additions & 2 deletions packages/kit/src/core/sync/write_server.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -78,6 +79,7 @@ export async function get_hooks() {
handle,
handleFetch,
handleError,
handleValidationError,
init,
reroute,
transport
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/exports/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ export function text(body, init) {
*/
/**
* Create an `ActionFailure` object. Call when form submission fails.
* @template {Record<string, unknown> | 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
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/exports/internal/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class SvelteKitError extends Error {
}

/**
* @template {Record<string, unknown> | undefined} [T=undefined]
* @template [T=undefined]
*/
export class ActionFailure {
/**
Expand Down
Loading