From 3684cbe204961ff6997eb55cc363b19838d398ca Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Wed, 12 Mar 2025 22:29:47 +0000 Subject: [PATCH 01/24] initial commit --- packages/kit/package.json | 8 +- .../core/sync/create_manifest_data/index.js | 54 +++++++++ packages/kit/src/exports/public.d.ts | 1 + packages/kit/src/exports/vite/dev/index.js | 1 + packages/kit/src/exports/vite/index.js | 39 ++++++- packages/kit/src/exports/vite/remote/index.js | 72 ++++++++++++ packages/kit/src/runtime/client/client.js | 27 +++++ packages/kit/src/runtime/server/page/index.js | 5 + packages/kit/src/runtime/server/page/rpc.js | 104 ++++++++++++++++++ packages/kit/src/types/internal.d.ts | 1 + playgrounds/basic/src/lib/foo.remote.js | 9 ++ playgrounds/basic/src/routes/+layout.svelte | 2 - playgrounds/basic/src/routes/+page.svelte | 14 +-- pnpm-lock.yaml | 21 ++++ 14 files changed, 346 insertions(+), 12 deletions(-) create mode 100644 packages/kit/src/exports/vite/remote/index.js create mode 100644 packages/kit/src/runtime/server/page/rpc.js create mode 100644 playgrounds/basic/src/lib/foo.remote.js diff --git a/packages/kit/package.json b/packages/kit/package.json index 80763f3056c5..a29f9a0d0ec0 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -28,7 +28,10 @@ "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", - "sirv": "^3.0.0" + "sirv": "^3.0.0", + "acorn": "^8.12.1", + "@sveltejs/acorn-typescript": "^1.0.5", + "zimmerframe": "^1.1.2" }, "devDependencies": { "@playwright/test": "^1.44.1", @@ -42,7 +45,8 @@ "svelte-preprocess": "^6.0.0", "typescript": "^5.3.3", "vite": "^6.0.11", - "vitest": "^3.0.1" + "vitest": "^3.0.1", + "@types/estree": "^1.0.5" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", 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..e0288370fd8e 100644 --- a/packages/kit/src/core/sync/create_manifest_data/index.js +++ b/packages/kit/src/core/sync/create_manifest_data/index.js @@ -8,6 +8,7 @@ import { posixify, resolve_entry } from '../../../utils/filesystem.js'; import { parse_route_id } from '../../../utils/routing.js'; import { sort_routes } from './sort.js'; import { isSvelte5Plus } from '../utils.js'; +import { hash } from '../../../runtime/hash.js'; /** * Generates the manifest data used for the client-side manifest and types generation. @@ -27,6 +28,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(cwd); for (const route of routes) { for (const param of route.params) { @@ -41,6 +43,7 @@ export default function create_manifest_data({ hooks, matchers, nodes, + remotes, routes }; } @@ -465,6 +468,57 @@ function create_routes_and_nodes(cwd, config, fallback) { }; } +/** + * @param {string} cwd + */ +function create_remotes(cwd) { + /** + * @type {Map} + */ + const remotes = new Map(); + + const valid_extensions = ['.remote.ts', '.remote.js']; + + /** + * @param {number} depth + * @param {string} id + */ + const walk = (depth, id) => { + const dir = path.join(cwd, id); + console.log(dir); + + // We can't use withFileTypes because of a NodeJs bug which returns wrong results + // with isDirectory() in case of symlinks: https://github.com/nodejs/node/issues/30646 + const files = fs.readdirSync(dir).map((name) => ({ + is_dir: fs.statSync(path.join(dir, name)).isDirectory(), + name + })); + + // process files first + for (const file of files) { + if (file.is_dir) continue; + + const ext = valid_extensions.find((ext) => file.name.endsWith(ext)); + if (!ext) continue; + const source = fs.readFileSync(path.join(dir, file.name), 'utf-8'); + + remotes.set(hash(source), path.join(cwd, id, file.name)); + } + + // then handle children + for (const file of files) { + if (file.is_dir) { + if (file.name === 'node_modules') continue; + walk(depth + 1, path.posix.join(id, file.name)); + } + } + }; + + walk(0, '/'); + + return remotes; +} + /** * @param {string} project_relative * @param {string} file diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 2ff29f3571a0..4d37c6ecf46c 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1319,6 +1319,7 @@ export interface SSRManifest { _: { client: NonNullable; nodes: SSRNodeLoader[]; + remotes: Map; routes: SSRRoute[]; prerendered_routes: Set; matchers: () => Promise>; diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 7049d8910508..57a4e819c46b 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -248,6 +248,7 @@ export async function dev(vite, vite_config, svelte_config) { }; }), prerendered_routes: new Set(), + remotes: manifest_data.remotes, routes: compact( manifest_data.routes.map((route) => { if (!route.page && !route.endpoint) return null; diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index bdb37b1f9cff..721092a95242 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -36,6 +36,7 @@ import { } from './module_ids.js'; import { resolve_peer_dependency } from '../../utils/import.js'; import { compact } from '../../utils/array.js'; +import { transform_client } from './remote/index.js'; const cwd = process.cwd(); @@ -398,6 +399,9 @@ async function kit({ svelte_config }) { // ids with :$ don't work with reverse proxies like nginx return `\0virtual:${id.substring(1)}`; } + if (id === '__sveltekit/remote') { + return `${runtime_directory}/client/client.js`; + } if (id.startsWith('__sveltekit/')) { return `\0virtual:${id}`; } @@ -578,6 +582,39 @@ Tips: } }; + /** + * @type {any} + */ + let root; + + const plugin_remote = { + name: 'vite-plugin-sveltekit-remote', + + /** + * @param {{ root: any; }} config + */ + configResolved(config) { + root = config.root; + }, + + /** + * @param {string} code + * @param {string} id + * @param {any} opts + */ + transform(code, id, opts) { + if (!id.endsWith('.remote.js') && !id.endsWith('.remote.ts')) return; + + if (opts.ssr) { + return; + } + + const filename = id.replace(root, ''); + + return transform_client(code, filename); + } + }; + /** @type {import('vite').Plugin} */ const plugin_compile = { name: 'vite-plugin-sveltekit-compile', @@ -1072,7 +1109,7 @@ Tips: } }; - return [plugin_setup, plugin_virtual_modules, plugin_guard, plugin_compile]; + return [plugin_setup, plugin_remote, plugin_virtual_modules, plugin_guard, plugin_compile]; } /** diff --git a/packages/kit/src/exports/vite/remote/index.js b/packages/kit/src/exports/vite/remote/index.js new file mode 100644 index 000000000000..7beecf365c18 --- /dev/null +++ b/packages/kit/src/exports/vite/remote/index.js @@ -0,0 +1,72 @@ +/** @import { Visitors } from 'zimmerframe' */ +/** @import * as ESTree from 'estree' */ + +import * as acorn from 'acorn'; +import { tsPlugin } from '@sveltejs/acorn-typescript'; +import { walk } from 'zimmerframe'; +import MagicString from 'magic-string'; +import { hash } from '../../../runtime/hash.js'; + +const ParserWithTS = acorn.Parser.extend(tsPlugin()); + +/** + * @param {string} code + * @param {string} filename + */ +export function transform_client(code, filename) { + const parser = filename.endsWith('.ts') ? ParserWithTS : acorn.Parser; + + const ast = /** @type {ESTree.Program} */ ( + parser.parse(code, { + sourceType: 'module', + ecmaVersion: 13, + locations: true + }) + ); + + const s = new MagicString(code); + const _hash = hash(code); + + /** @type {Visitors} */ + const visitors = { + ExportNamedDeclaration(node, context) { + const declaration = node.declaration; + + if (declaration?.type === 'FunctionDeclaration') { + const name = declaration.id.name; + s.update( + // @ts-ignore + declaration.start, + // @ts-ignore + declaration.end, + `async function ${name}(...args) { return await remote_call('${_hash}', '${name}', args); }` + ); + } else if (declaration?.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations) { + const id = declarator.id; + if (id.type == 'Identifier') { + s.update( + // @ts-ignore + declarator.start, + // @ts-ignore + declarator.end, + `${id.name} = async (...args) => { return await remote_call('${_hash}', '${id.name}', args); }` + ); + } else { + // TODO: do we throw an error here? + } + } + } else { + // TODO: do we throw an error here? + } + + context.next(); + } + }; + + walk(ast, null, visitors); + + s.prepend("import { remote_call } from '__sveltekit/remote';"); + + return s.toString(); +} diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index ecfb49b74da8..bbebb1ab7873 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2949,3 +2949,30 @@ if (DEV) { }); } } + +/** + * @param {string} hash + * @param {string} func_name + * @param {any} args + */ +export async function remote_call(hash, func_name, args) { + const transport = app.hooks.transport; + const encoders = Object.fromEntries( + Object.entries(transport).map(([key, value]) => [key, value.encode]) + ); + const body = devalue.stringify(args, encoders); + + const response = await fetch('/remote', { + method: 'POST', + body, + headers: { + 'Content-Type': 'application/json', + 'sk-rpc': JSON.stringify([hash, func_name]) + } + }); + + const json = await response.text(); + const parsed = JSON.parse(json); + + return devalue.parse(parsed.data, app.decoders); +} diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index 01337e4cac1c..e19dee72da0d 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -15,6 +15,7 @@ import { render_response } from './render.js'; import { respond_with_error } from './respond_with_error.js'; import { get_data_json } from '../data/index.js'; import { DEV } from 'esm-env'; +import { handle_rpc_json_request, is_rpc_json_request } from './rpc.js'; /** * The maximum request depth permitted before assuming we're stuck in an infinite loop @@ -39,6 +40,10 @@ export async function render_page(event, page, options, manifest, state, nodes, }); } + if (is_rpc_json_request(event)) { + return await handle_rpc_json_request(event, options, manifest); + } + if (is_action_json_request(event)) { const node = await manifest._.nodes[page.leaf](); return handle_action_json_request(event, options, node?.server); diff --git a/packages/kit/src/runtime/server/page/rpc.js b/packages/kit/src/runtime/server/page/rpc.js new file mode 100644 index 000000000000..a1ea7f34fff9 --- /dev/null +++ b/packages/kit/src/runtime/server/page/rpc.js @@ -0,0 +1,104 @@ +import { negotiate } from '../../../utils/http.js'; +import { json } from '../../../exports/index.js'; +import { SvelteKitError } from '../../control.js'; +import { handle_error_and_jsonify } from '../utils.js'; +import * as devalue from 'devalue'; + +/** @param {import('@sveltejs/kit').RequestEvent} event */ +export function is_rpc_json_request(event) { + const rpc_data = event.request.headers.get('sk-rpc'); + + if (rpc_data == null) return false; + const json = JSON.parse(rpc_data); + if (!Array.isArray(json) || json.length !== 2) return false; + + const accept = negotiate(event.request.headers.get('accept') ?? '*/*', ['application/json']); + + return accept === 'application/json' && event.request.method === 'POST'; +} + +/** + * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import("types").SSROptions} options + */ +async function bad_rpc(event, options) { + const bad_rpc = new SvelteKitError( + 405, + 'Method Not Allowed', + 'POST method not allowed for this remote procedure call' + ); + + return json( + { + type: 'error', + error: await handle_error_and_jsonify(event, options, bad_rpc) + }, + { + status: bad_rpc.status, + headers: { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405 + // "The server must generate an Allow header field in a 405 status code response" + allow: 'GET' + } + } + ); +} + +/** + * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('types').SSROptions} options + * @param {import('@sveltejs/kit').SSRManifest} manifest + */ +export async function handle_rpc_json_request(event, options, manifest) { + const [hash, func_name] = JSON.parse(/** @type {string} */ (event.request.headers.get('sk-rpc'))); + const remotes = manifest._.remotes; + + if (!remotes.has(hash)) { + return await bad_rpc(event, options); + } + + const remote = /** @type {string} */ (remotes.get(hash)); + let func; + + try { + const module = await import(remote); + func = module[func_name]; + } catch { + return await bad_rpc(event, options); + } + + const transport = options.hooks.transport; + const args_json = await event.request.text(); + const decoders = Object.fromEntries(Object.entries(transport).map(([k, v]) => [k, v.decode])); + const args = devalue.parse(args_json, decoders); + let data; + + try { + data = await func.apply(null, args); + } catch (/** @type {any} */ e) { + return json({ + type: 'error', + status: 500, + error: await handle_error_and_jsonify(event, options, e) + }); + } + + return json({ + type: 'success', + status: data ? 200 : 204, + data: stringify_rpc_response(data, transport) + }); +} + +/** + * Try to `devalue.stringify` the data object, and if it fails, return a proper Error with context + * @param {any} data + * @param {import('types').ServerHooks['transport']} transport + */ +function stringify_rpc_response(data, transport) { + const encoders = Object.fromEntries( + Object.entries(transport).map(([key, value]) => [key, value.encode]) + ); + + return devalue.stringify(data, encoders); +} diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 82317e8417ab..05445fb8176a 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -188,6 +188,7 @@ export interface ManifestData { universal: string | null; }; nodes: PageNode[]; + remotes: Map; routes: RouteData[]; matchers: Record; } diff --git a/playgrounds/basic/src/lib/foo.remote.js b/playgrounds/basic/src/lib/foo.remote.js new file mode 100644 index 000000000000..e069a73977d4 --- /dev/null +++ b/playgrounds/basic/src/lib/foo.remote.js @@ -0,0 +1,9 @@ +/** + * @param {number} a + * @param {number} b + */ +export const add = (a, b) => { + console.log('add', a, b); + + return a + b; +}; diff --git a/playgrounds/basic/src/routes/+layout.svelte b/playgrounds/basic/src/routes/+layout.svelte index 707b3afe5a1e..e85e983d2ee4 100644 --- a/playgrounds/basic/src/routes/+layout.svelte +++ b/playgrounds/basic/src/routes/+layout.svelte @@ -2,8 +2,6 @@ import { page, navigating } from '$app/state'; let { children } = $props(); - - $inspect(navigating.to);