diff --git a/alias.ts b/alias.ts index 4d31a9ba..109a0d10 100644 --- a/alias.ts +++ b/alias.ts @@ -9,6 +9,8 @@ export const alias = { '@vitejs/devtools-rpc/presets/ws/server': r('rpc/src/presets/ws/server.ts'), '@vitejs/devtools-rpc/presets/ws/client': r('rpc/src/presets/ws/client.ts'), '@vitejs/devtools-rpc/presets': r('rpc/src/presets/index.ts'), + '@vitejs/devtools-rpc/client': r('rpc/src/client.ts'), + '@vitejs/devtools-rpc/server': r('rpc/src/server.ts'), '@vitejs/devtools-rpc': r('rpc/src'), '@vitejs/devtools-kit/client': r('kit/src/client/index.ts'), '@vitejs/devtools-kit/constants': r('kit/src/constants.ts'), diff --git a/docs/kit/rpc.md b/docs/kit/rpc.md index 817fe439..1d0813d5 100644 --- a/docs/kit/rpc.md +++ b/docs/kit/rpc.md @@ -60,11 +60,12 @@ const plugin: Plugin = { ### Function Types -| Type | Description | Caching | -|------|-------------|---------| -| `query` | Fetch data, read operations | Can be cached | -| `action` | Side effects, mutations | Not cached | -| `static` | Constant data that never changes | Cached indefinitely | +| Type | Description | Caching | Dump Support | +|------|-------------|---------|--------------| +| `query` | Fetch data, read operations | Can be cached | ✓ (manual) | +| `static` | Constant data that never changes | Cached indefinitely | ✓ (automatic) | +| `action` | Side effects, mutations | Not cached | ✗ | +| `event` | Emit events, no response | Not cached | ✗ | ### Handler Arguments @@ -104,6 +105,99 @@ setup: (ctx) => { } ``` +> [!IMPORTANT] +> For build mode compatibility, compute data in the setup function using the context rather than relying on runtime global state. This allows the dump feature to pre-compute results at build time. + +### Dump Feature for Build Mode + +When using `vite devtools build` to create a static DevTools build, the server cannot execute functions at runtime. The **dump feature** solves this by pre-computing RPC results at build time. + +#### How It Works + +1. At build time, `dumpFunctions()` executes your RPC handlers with predefined arguments +2. Results are stored in `.vdt-rpc-dump.json` in the build output +3. The static client reads from this JSON file instead of making live RPC calls + +#### Static Functions (Recommended) + +Functions with `type: 'static'` are **automatically dumped** with no arguments: + +```ts +const getConfig = defineRpcFunction({ + name: 'my-plugin:get-config', + type: 'static', // Auto-dumped with inputs: [[]] + setup: ctx => ({ + handler: async () => ({ + root: ctx.viteConfig.root, + plugins: ctx.viteConfig.plugins.map(p => p.name), + }), + }), +}) +``` + +This works in both dev mode (live) and build mode (pre-computed). + +#### Query Functions with Dumps + +For `query` functions that need arguments, define `dump` in the setup: + +```ts +const getModule = defineRpcFunction({ + name: 'my-plugin:get-module', + type: 'query', + setup: (ctx) => { + // Collect all module IDs at build time + const moduleIds = Array.from(ctx.viteServer?.moduleGraph?.idToModuleMap.keys() || []) + + return { + handler: async (id: string) => { + const module = ctx.viteServer?.moduleGraph?.getModuleById(id) + return module ? { id, size: module.transformResult?.code.length } : null + }, + dump: { + inputs: moduleIds.map(id => [id]), // Pre-compute for all modules + fallback: null, // Return null for unknown modules + }, + } + }, +}) +``` + +#### Recommendations for Plugin Authors + +To ensure your DevTools work in build mode: + +1. **Prefer `type: 'static'`** for functions that return constant data +2. **Return context-based data in setup** rather than accessing global state in handlers +3. **Define dumps in setup function** for query functions that need pre-computation +4. **Use fallback values** for graceful degradation when arguments don't match + +```ts +// ✓ Good: Returns static data, works in build mode +const getPluginInfo = defineRpcFunction({ + name: 'my-plugin:info', + type: 'static', + setup: ctx => ({ + handler: async () => ({ + version: '1.0.0', + root: ctx.viteConfig.root, + }), + }), +}) + +// ✗ Avoid: Depends on runtime server state +const getLiveMetrics = defineRpcFunction({ + name: 'my-plugin:metrics', + type: 'query', // No dump - won't work in build mode + handler: async () => { + return getCurrentMetrics() // Requires live server + }, +}) +``` + +> [!TIP] +> If your data genuinely needs live server state, use `type: 'query'` without dumps. The function will work in dev mode but gracefully fail in build mode. + ## Client-Side Calls ### In Iframe Pages diff --git a/packages/core/package.json b/packages/core/package.json index 80aef85d..edda582c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -56,7 +56,6 @@ "@vitejs/devtools-rolldown": "workspace:*", "@vitejs/devtools-rpc": "workspace:*", "birpc": "catalog:deps", - "birpc-x": "catalog:deps", "cac": "catalog:deps", "h3": "catalog:deps", "immer": "catalog:deps", diff --git a/packages/core/src/node/host-functions.ts b/packages/core/src/node/host-functions.ts index ea1f9c3c..744da499 100644 --- a/packages/core/src/node/host-functions.ts +++ b/packages/core/src/node/host-functions.ts @@ -1,7 +1,7 @@ import type { DevToolsNodeContext, DevToolsNodeRpcSession, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, RpcBroadcastOptions, RpcFunctionsHost as RpcFunctionsHostType, RpcSharedStateHost } from '@vitejs/devtools-kit' import type { BirpcGroup } from 'birpc' import type { AsyncLocalStorage } from 'node:async_hooks' -import { RpcFunctionsCollectorBase } from 'birpc-x' +import { RpcFunctionsCollectorBase } from '@vitejs/devtools-rpc' import { createDebug } from 'obug' import { createRpcSharedStateServerHost } from './rpc-shared-state' diff --git a/packages/core/src/node/ws.ts b/packages/core/src/node/ws.ts index cf537bd1..e6296582 100644 --- a/packages/core/src/node/ws.ts +++ b/packages/core/src/node/ws.ts @@ -4,8 +4,8 @@ import type { WebSocket } from 'ws' import type { RpcFunctionsHost } from './host-functions' import { AsyncLocalStorage } from 'node:async_hooks' import process from 'node:process' -import { createRpcServer } from '@vitejs/devtools-rpc' import { createWsRpcPreset } from '@vitejs/devtools-rpc/presets/ws/server' +import { createRpcServer } from '@vitejs/devtools-rpc/server' import c from 'ansis' import { getPort } from 'get-port-please' import { createDebug } from 'obug' diff --git a/packages/kit/package.json b/packages/kit/package.json index e29613c0..9104b152 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -42,7 +42,6 @@ "dependencies": { "@vitejs/devtools-rpc": "workspace:*", "birpc": "catalog:deps", - "birpc-x": "catalog:deps", "immer": "catalog:deps" }, "devDependencies": { diff --git a/packages/kit/src/client/docks.ts b/packages/kit/src/client/docks.ts index 09b3cf8b..95960409 100644 --- a/packages/kit/src/client/docks.ts +++ b/packages/kit/src/client/docks.ts @@ -1,4 +1,4 @@ -import type { RpcFunctionsCollector } from 'birpc-x' +import type { RpcFunctionsCollector } from '@vitejs/devtools-rpc' import type { Raw } from 'vue' import type { DevToolsDockEntriesGrouped } from '../../../core/src/client/webcomponents/state/dock-settings' import type { DevToolsDockEntry, DevToolsDocksUserSettings, DevToolsDockUserEntry, DevToolsRpcClientFunctions, EventEmitter } from '../types' diff --git a/packages/kit/src/client/rpc.ts b/packages/kit/src/client/rpc.ts index cbd14222..a12a5745 100644 --- a/packages/kit/src/client/rpc.ts +++ b/packages/kit/src/client/rpc.ts @@ -2,9 +2,9 @@ import type { WebSocketRpcClientOptions } from '@vitejs/devtools-rpc/presets/ws/ import type { BirpcOptions, BirpcReturn } from 'birpc' import type { ConnectionMeta, DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, EventEmitter, RpcSharedStateHost } from '../types' import type { DevToolsClientContext, DevToolsClientRpcHost, RpcClientEvents } from './docks' -import { createRpcClient } from '@vitejs/devtools-rpc' +import { RpcFunctionsCollectorBase } from '@vitejs/devtools-rpc' +import { createRpcClient } from '@vitejs/devtools-rpc/client' import { createWsRpcPreset } from '@vitejs/devtools-rpc/presets/ws/client' -import { RpcFunctionsCollectorBase } from 'birpc-x' import { UAParser } from 'my-ua-parser' import { createEventEmitter } from '../utils/events' import { nanoid } from '../utils/nanoid' diff --git a/packages/kit/src/types/index.ts b/packages/kit/src/types/index.ts index 04b28a70..ff63400c 100644 --- a/packages/kit/src/types/index.ts +++ b/packages/kit/src/types/index.ts @@ -9,4 +9,4 @@ export * from './views' export * from './vite-augment' export * from './vite-plugin' -export type { RpcDefinitionsFilter, RpcDefinitionsToFunctions } from 'birpc-x' +export type { RpcDefinitionsFilter, RpcDefinitionsToFunctions } from '@vitejs/devtools-rpc' diff --git a/packages/kit/src/types/rpc.ts b/packages/kit/src/types/rpc.ts index 14d42f40..89ece63e 100644 --- a/packages/kit/src/types/rpc.ts +++ b/packages/kit/src/types/rpc.ts @@ -1,6 +1,6 @@ +import type { RpcFunctionsCollectorBase } from '@vitejs/devtools-rpc' import type { DevToolsNodeRpcSessionMeta } from '@vitejs/devtools-rpc/presets/ws/server' import type { BirpcReturn } from 'birpc' -import type { RpcFunctionsCollectorBase } from 'birpc-x' import type { SharedState } from '../utils/shared-state' import type { DevToolsRpcClientFunctions, DevToolsRpcServerFunctions, DevToolsRpcSharedStates } from './rpc-augments' import type { DevToolsNodeContext } from './vite-plugin' diff --git a/packages/kit/src/utils/define.ts b/packages/kit/src/utils/define.ts index 34ac8361..16a28f74 100644 --- a/packages/kit/src/utils/define.ts +++ b/packages/kit/src/utils/define.ts @@ -1,4 +1,4 @@ import type { DevToolsNodeContext } from '../types' -import { createDefineWrapperWithContext } from 'birpc-x' +import { createDefineWrapperWithContext } from '@vitejs/devtools-rpc' export const defineRpcFunction = createDefineWrapperWithContext() diff --git a/packages/rpc/README.md b/packages/rpc/README.md new file mode 100644 index 00000000..51226add --- /dev/null +++ b/packages/rpc/README.md @@ -0,0 +1,214 @@ +# @vitejs/devtools-rpc + +DevTools RPC for Vite, featuring extensible [birpc](https://github.com/antfu-collective/birpc) interfaces with advanced type-safe function definitions. + +## Features + +- **Type-safe function definitions** with automatic type inference +- **Dynamic function registration** with hot updates +- **User-provided function context** for setup and handlers +- **Schema validation** via [`valibot`](https://valibot.dev) +- **Cache Manager** for RPC result caching +- **Dump feature** for pre-computing results (static hosting, testing, offline mode) +- **Basic RPC Client/Server** built on birpc +- **WebSocket Presets** ready-to-use transport presets + +## Installation + +```bash +pnpm install @vitejs/devtools-rpc +``` + +## Usage + +### Basic RPC Client/Server + +```ts +import { createRpcClient } from '@vitejs/devtools-rpc/client' +import { createWsRpcPreset } from '@vitejs/devtools-rpc/presets/ws/client' +import { createRpcServer } from '@vitejs/devtools-rpc/server' +``` + +### Defining Functions + +Use `defineRpcFunction` to create type-safe RPC function definitions: + +```ts +import { defineRpcFunction } from '@vitejs/devtools-rpc' + +// Simple function +const greet = defineRpcFunction({ + name: 'greet', + handler: (name: string) => `Hello, ${name}!` +}) +``` + +You can provide a context to functions for setup and initialization: + +```ts +import { defineRpcFunction } from '@vitejs/devtools-rpc' + +// With setup and context +const getUser = defineRpcFunction({ + name: 'getUser', + setup: (context) => { + console.log(context) + return { + handler: (id: string) => context.users[id] + } + } +}) +``` + +#### Schema Validation + +Use Valibot schemas for automatic argument and return value validation: + +```ts +import { defineRpcFunction } from '@vitejs/devtools-rpc' +import * as v from 'valibot' + +const add = defineRpcFunction({ + name: 'add', + args: [v.number(), v.number()] as const, + returns: v.number(), + handler: (a, b) => a + b // Types are automatically inferred +}) +``` + +### Function Collector + +`RpcFunctionsCollector` manages dynamic function registration and provides a type-safe proxy for accessing functions: + +```ts +import { defineRpcFunction, RpcFunctionsCollectorBase } from '@vitejs/devtools-rpc' + +// Provide a custom context to the collector +const collector = new RpcFunctionsCollectorBase({ users: [/* ... */] }) + +// Register functions +collector.register(defineRpcFunction({ + name: 'greet', + handler: (name: string) => `Hello, ${name}!`, +})) +collector.register(defineRpcFunction({ + name: 'getUser', + setup: (context) => { + return { + handler: (id: string) => context.users.find((user: { id: string }) => user.id === id) + } + } +})) + +// Access via proxy +await collector.functions.greet('Alice') // "Hello, Alice!" + +// Listen for changes +const unsubscribe = collector.onChanged((fnName) => { + console.log(`Function ${fnName} changed`) +}) +``` + +### Dump Feature + +The dump feature allows pre-computing RPC results for static hosting, testing, or offline mode. This is useful for static sites or when you want to avoid runtime computation. + +```ts +import { createClientFromDump, defineRpcFunction, dumpFunctions } from '@vitejs/devtools-rpc' + +// Define functions with dump configurations +const greet = defineRpcFunction({ + name: 'greet', + handler: (name: string) => `Hello, ${name}!`, + dump: { + inputs: [ + ['Alice'], + ['Bob'], + ['Charlie'] + ], + fallback: 'Hello, stranger!' + } +}) + +// Collect pre-computed results +const store = await dumpFunctions([greet]) + +// Create a client that serves from the dump store +const client = createClientFromDump(store) + +await client.greet('Alice') // Returns pre-computed: "Hello, Alice!" +await client.greet('Unknown') // Returns fallback: "Hello, stranger!" +``` + +Functions with `type: 'static'` automatically get dumped with empty arguments if no dump configuration is provided. + +#### Pre-computed Records + +You can provide pre-computed records directly to bypass function execution: + +```ts +import { defineRpcFunction } from '@vitejs/devtools-rpc' + +const multiply = defineRpcFunction({ + name: 'multiply', + handler: (a: number, b: number) => a * b, + dump: { + records: [ + { inputs: [2, 3], output: 6 }, + { inputs: [4, 5], output: 20 }, + ], + }, +}) +``` + +You can also mix computed (`inputs`) and pre-computed (`records`) in the same dump configuration. + +#### Parallel Execution + +Enable parallel processing for faster dump collection: + +```ts +import { dumpFunctions } from '@vitejs/devtools-rpc' + +// Enable parallel with default concurrency of 5 +const store = await dumpFunctions([greet], context, { + concurrency: true +}) + +// Or specify a custom concurrency limit +const store = await dumpFunctions([greet], context, { + concurrency: 10 // Limit to 10 concurrent executions +}) +``` + +Set `concurrency` to `true` for parallel execution (default limit: 5) or a number to specify the exact concurrency limit. + +## Package Exports + +- **`.`** - Type-safe function definitions and utilities (main export) + - `RpcFunctionsCollectorBase`, `defineRpcFunction`, `createDefineWrapperWithContext` + - `dumpFunctions`, `createClientFromDump`, `RpcCacheManager` + - Type definitions and utilities + +- **`./client`** - RPC client + - `createRpcClient` + +- **`./server`** - RPC server + - `createRpcServer` + +- **`./presets`** - RPC presets + - `defineRpcClientPreset`, `defineRpcServerPreset` + +- **`./presets/ws/client`** - WebSocket client preset + - `createWsRpcPreset` + +- **`./presets/ws/server`** - WebSocket server preset + - `createWsRpcPreset` + +## Examples + +See [src/examples](./src/examples) and [test files](./src) for complete integration examples. + +## License + +MIT License © [VoidZero Inc.](https://github.com/vitejs) diff --git a/packages/rpc/package.json b/packages/rpc/package.json index 82352614..55780396 100644 --- a/packages/rpc/package.json +++ b/packages/rpc/package.json @@ -2,7 +2,7 @@ "name": "@vitejs/devtools-rpc", "type": "module", "version": "0.0.0-alpha.26", - "description": "DevTools rpc for Vite (work in progress)", + "description": "Vite DevTools RPC Layer", "author": "VoidZero Inc.", "license": "MIT", "homepage": "https://github.com/vitejs/devtools#readme", @@ -20,9 +20,11 @@ "sideEffects": false, "exports": { ".": "./dist/index.mjs", + "./client": "./dist/client.mjs", "./presets": "./dist/presets/index.mjs", "./presets/ws/client": "./dist/presets/ws/client.mjs", "./presets/ws/server": "./dist/presets/ws/server.mjs", + "./server": "./dist/server.mjs", "./package.json": "./package.json" }, "types": "./dist/index.d.mts", @@ -41,7 +43,10 @@ }, "dependencies": { "birpc": "catalog:deps", - "structured-clone-es": "catalog:deps" + "ohash": "catalog:deps", + "p-limit": "catalog:deps", + "structured-clone-es": "catalog:deps", + "valibot": "catalog:deps" }, "devDependencies": { "tsdown": "catalog:build", diff --git a/packages/rpc/src/__snapshots__/dumps.test.ts.snap b/packages/rpc/src/__snapshots__/dumps.test.ts.snap new file mode 100644 index 00000000..d02630fa --- /dev/null +++ b/packages/rpc/src/__snapshots__/dumps.test.ts.snap @@ -0,0 +1,316 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`dumps > dump snapshots > should snapshot comprehensive dump with multiple scenarios 1`] = ` +{ + "definitions": { + "add": { + "name": "add", + "type": undefined, + }, + "divide": { + "name": "divide", + "type": undefined, + }, + "getConfig": { + "name": "getConfig", + "type": "static", + }, + "multiply": { + "name": "multiply", + "type": undefined, + }, + }, + "records": { + "add---KMXmOL3ys81zNdaAp_MtASEYJWGbVMdGiX1IQsRrWHg": { + "inputs": [ + 10, + 20, + ], + "output": 30, + }, + "add---SaZHF9XUyxmVLm6sKUZBXPaHmtrPmQjn2HIzLTLG5oQ": { + "inputs": [ + 1, + 2, + ], + "output": 3, + }, + "add---fallback": { + "inputs": [], + "output": 0, + }, + "divide---Ql6R6f8bSyzgiAJFMv-GGfAebD0Q1EvpkCvtiEN8Ojk": { + "inputs": [ + 10, + 2, + ], + "output": 5, + }, + "divide---clP6nyWEfmD9PThtSkrVBIT7mo2UYRyJ8lCA18HUJVI": { + "error": { + "message": "Division by zero", + "name": "Error", + }, + "inputs": [ + 10, + 0, + ], + }, + "getConfig---T1PNoYwrqgwDVLtfmj7L5e0Sq02OEbqHPC8RFhICuUU": { + "inputs": [], + "output": { + "version": "1.0.0", + }, + }, + "multiply---GLBYaJ0BAGKiXNf7lJsqv-oXV9LeRco6vdoX25VuUGs": { + "inputs": [ + 2, + 3, + ], + "output": 6, + }, + }, +} +`; + +exports[`dumps > dump snapshots > should snapshot dump with context-dependent functions 1`] = ` +{ + "definitions": { + "getConfig": { + "name": "getConfig", + "type": undefined, + }, + }, + "records": { + "getConfig---Xl4-LA88xSJCWOOL_2hL9CFG3twBWmstDJ_Yqg77nWo": { + "inputs": [ + "apiUrl", + ], + "output": { + "apiUrl": "http://localhost:3000", + "debug": true, + }, + }, + }, +} +`; + +exports[`dumps > dump snapshots > should snapshot dump with errors 1`] = ` +{ + "definitions": { + "divide": { + "name": "divide", + "type": undefined, + }, + }, + "records": { + "divide---NKq_csykACfT_qAmmFEcDyyuAxmaR7agpibi0BY6-oY": { + "inputs": [ + 20, + 4, + ], + "output": 5, + }, + "divide---Ql6R6f8bSyzgiAJFMv-GGfAebD0Q1EvpkCvtiEN8Ojk": { + "inputs": [ + 10, + 2, + ], + "output": 5, + }, + "divide---clP6nyWEfmD9PThtSkrVBIT7mo2UYRyJ8lCA18HUJVI": { + "error": { + "message": "Division by zero", + "name": "Error", + }, + "inputs": [ + 10, + 0, + ], + }, + }, +} +`; + +exports[`dumps > dump snapshots > should snapshot dump with fallback values 1`] = ` +{ + "definitions": { + "greet": { + "name": "greet", + "type": undefined, + }, + }, + "records": { + "greet---_O8fg6KUjOBNeL20buGWXHbcFzcF4DNQsu_J9Yd8esk": { + "inputs": [ + "Bob", + ], + "output": "Hello, Bob!", + }, + "greet---acIfQW5j2P6VtOYcCUwCi1SNi7Phz7_1JeqXfb4eb3s": { + "inputs": [ + "Alice", + ], + "output": "Hello, Alice!", + }, + "greet---fallback": { + "inputs": [], + "output": "Hello, stranger!", + }, + }, +} +`; + +exports[`dumps > dump snapshots > should snapshot dump with mixed inputs and records 1`] = ` +{ + "definitions": { + "add": { + "name": "add", + "type": undefined, + }, + }, + "records": { + "add---KMXmOL3ys81zNdaAp_MtASEYJWGbVMdGiX1IQsRrWHg": { + "inputs": [ + 10, + 20, + ], + "output": 30, + }, + "add---SaZHF9XUyxmVLm6sKUZBXPaHmtrPmQjn2HIzLTLG5oQ": { + "inputs": [ + 1, + 2, + ], + "output": 3, + }, + "add---i-bWbpCZxo2P61LOQkeNIVPKwnY7eEF0rmrpbNY2tZY": { + "inputs": [ + 3, + 4, + ], + "output": 7, + }, + "add---mERJaNNmqgwfjCQYdik6YYdy6IwTcTZ1rmtFR7DzNRw": { + "inputs": [ + 100, + 200, + ], + "output": 300, + }, + }, +} +`; + +exports[`dumps > dump snapshots > should snapshot dump with pre-computed records 1`] = ` +{ + "definitions": { + "multiply": { + "name": "multiply", + "type": undefined, + }, + }, + "records": { + "multiply---1MepjaVUkLClplzFBX25mqcIpDZgmxd3SFBTQtVpRXs": { + "inputs": [ + 4, + 5, + ], + "output": 20, + }, + "multiply---GLBYaJ0BAGKiXNf7lJsqv-oXV9LeRco6vdoX25VuUGs": { + "inputs": [ + 2, + 3, + ], + "output": 6, + }, + "multiply---clP6nyWEfmD9PThtSkrVBIT7mo2UYRyJ8lCA18HUJVI": { + "inputs": [ + 10, + 0, + ], + "output": 0, + }, + }, +} +`; + +exports[`dumps > dump snapshots > should snapshot dump with static functions 1`] = ` +{ + "definitions": { + "getConfig": { + "name": "getConfig", + "type": "static", + }, + "getVersion": { + "name": "getVersion", + "type": "static", + }, + }, + "records": { + "getConfig---T1PNoYwrqgwDVLtfmj7L5e0Sq02OEbqHPC8RFhICuUU": { + "inputs": [], + "output": { + "apiUrl": "https://api.example.com", + "features": [ + "auth", + "cache", + ], + "version": "v1", + }, + }, + "getVersion---T1PNoYwrqgwDVLtfmj7L5e0Sq02OEbqHPC8RFhICuUU": { + "inputs": [], + "output": "1.0.0", + }, + }, +} +`; + +exports[`dumps > should snapshot the store structure 1`] = ` +{ + "definitions": { + "add": { + "name": "add", + "type": undefined, + }, + "greet": { + "name": "greet", + "type": undefined, + }, + }, + "records": { + "add---SaZHF9XUyxmVLm6sKUZBXPaHmtrPmQjn2HIzLTLG5oQ": { + "inputs": [ + 1, + 2, + ], + "output": 3, + }, + "add---fallback": { + "inputs": [], + "output": 0, + }, + "add---i-bWbpCZxo2P61LOQkeNIVPKwnY7eEF0rmrpbNY2tZY": { + "inputs": [ + 3, + 4, + ], + "output": 7, + }, + "greet---_O8fg6KUjOBNeL20buGWXHbcFzcF4DNQsu_J9Yd8esk": { + "inputs": [ + "Bob", + ], + "output": "Hello, Bob!", + }, + "greet---acIfQW5j2P6VtOYcCUwCi1SNi7Phz7_1JeqXfb4eb3s": { + "inputs": [ + "Alice", + ], + "output": "Hello, Alice!", + }, + }, +} +`; diff --git a/packages/rpc/src/cache.test.ts b/packages/rpc/src/cache.test.ts new file mode 100644 index 00000000..bdc1dcb4 --- /dev/null +++ b/packages/rpc/src/cache.test.ts @@ -0,0 +1,21 @@ +import { expect, it } from 'vitest' +import { RpcCacheManager } from './cache' + +it('cache', async () => { + const cache = new RpcCacheManager({ functions: ['fn3'] }) + + expect(cache.validate('fn1')).toBe(false) + expect(cache.validate('fn3')).toBe(true) + + cache.updateOptions({ functions: ['fn1', 'fn2'] }) + expect(cache.validate('fn1')).toBe(true) + expect(cache.validate('fn2')).toBe(true) + expect(cache.validate('fn3')).toBe(false) + cache.apply({ m: 'fn1', a: [1, 2] }, 100) + cache.apply({ m: 'fn2', a: [3, 4] }, 200) + expect(cache.cached('fn1', [1, 2])).toBe(100) + cache.clear('fn1') + expect(cache.cached('fn1', [1, 2])).toBeUndefined() + cache.clear() + expect(cache.cached('fn2', [3, 4])).toBeUndefined() +}) diff --git a/packages/rpc/src/cache.ts b/packages/rpc/src/cache.ts new file mode 100644 index 00000000..7c69ceab --- /dev/null +++ b/packages/rpc/src/cache.ts @@ -0,0 +1,54 @@ +import { hash } from 'ohash' + +export interface RpcCacheOptions { + functions: string[] + keySerializer?: (args: unknown[]) => string +} + +/** + * @experimental API is expected to change. + */ +export class RpcCacheManager { + private cacheMap = new Map>() + private options: RpcCacheOptions + private keySerializer: (args: unknown[]) => string + + constructor(options: RpcCacheOptions) { + this.options = options + this.keySerializer = options.keySerializer || ((args: unknown[]) => hash(args)) + } + + updateOptions(options: Partial): void { + this.options = { + ...this.options, + ...options, + } + } + + cached(m: string, a: unknown[]): T | undefined { + const methodCache = this.cacheMap.get(m) + if (methodCache) { + return methodCache.get(this.keySerializer(a)) as T + } + return undefined + } + + apply(req: { m: string, a: unknown[] }, res: unknown): void { + const methodCache = this.cacheMap.get(req.m) || new Map() + methodCache.set(this.keySerializer(req.a), res) + this.cacheMap.set(req.m, methodCache) + } + + validate(m: string): boolean { + return this.options.functions.includes(m) + } + + clear(fn?: string): void { + if (fn) { + this.cacheMap.delete(fn) + } + else { + this.cacheMap.clear() + } + } +} diff --git a/packages/rpc/src/collector.test.ts b/packages/rpc/src/collector.test.ts new file mode 100644 index 00000000..15709059 --- /dev/null +++ b/packages/rpc/src/collector.test.ts @@ -0,0 +1,51 @@ +import { expect, it, vi } from 'vitest' +import { RpcFunctionsCollectorBase } from './collector' + +it('collector', async () => { + const context = { + name: 'test', + } + const collector = new RpcFunctionsCollectorBase(context) + + expect(collector.functions).toMatchInlineSnapshot(`{}`) + + let _context: any + collector.register({ + name: 'hello', + type: 'static', + setup: async (_c) => { + await new Promise(resolve => setTimeout(resolve, 1)) + _context = _c + return { + handler: () => 100, + } + }, + }) + + expect((await collector.getHandler('hello'))()).toBe(100) + expect(_context).toBe(context) + + const onUpdate = vi.fn() + const handler = vi.fn() + collector.onChanged(onUpdate) + collector.register({ + name: 'new', + type: 'action', + handler, + }) + expect(onUpdate).toHaveBeenCalledWith('new') + + expect((await collector.getHandler('new'))()).toBe(undefined) + expect(handler).toBeCalled() + + onUpdate.mockClear() + handler.mockClear() + collector.update({ + name: 'new', + type: 'static', + handler: () => 100, + }) + expect(onUpdate).toHaveBeenCalledWith('new') + expect((await collector.getHandler('new'))()).toBe(100) + expect(handler).not.toBeCalled() +}) diff --git a/packages/rpc/src/collector.ts b/packages/rpc/src/collector.ts new file mode 100644 index 00000000..85cf0be6 --- /dev/null +++ b/packages/rpc/src/collector.ts @@ -0,0 +1,92 @@ +import type { RpcArgsSchema, RpcFunctionDefinition, RpcFunctionsCollector, RpcReturnSchema } from './types' +import { getRpcHandler } from './handler' + +export class RpcFunctionsCollectorBase< + LocalFunctions extends Record, + SetupContext, +> implements RpcFunctionsCollector { + public readonly definitions: Map> = new Map() + public readonly functions: LocalFunctions + private readonly _onChanged: ((id?: string) => void)[] = [] + + constructor( + public readonly context: SetupContext, + ) { + const definitions = this.definitions + // eslint-disable-next-line ts/no-this-alias + const self = this + this.functions = new Proxy({}, { + get(_, prop) { + const definition = definitions.get(prop as string) + if (!definition) + return undefined + return getRpcHandler(definition, self.context) + }, + has(_, prop) { + return definitions.has(prop as string) + }, + getOwnPropertyDescriptor(_, prop) { + return { + value: definitions.get(prop as string)?.handler, + configurable: true, + enumerable: true, + } + }, + ownKeys() { + return Array.from(definitions.keys()) + }, + }) as LocalFunctions + } + + register(fn: RpcFunctionDefinition, force = false): void { + if (this.definitions.has(fn.name) && !force) { + throw new Error(`RPC function "${fn.name}" is already registered`) + } + this.definitions.set(fn.name, fn) + this._onChanged.forEach(cb => cb(fn.name)) + } + + update(fn: RpcFunctionDefinition, force = false): void { + if (!this.definitions.has(fn.name) && !force) { + throw new Error(`RPC function "${fn.name}" is not registered. Use register() to add new functions.`) + } + this.definitions.set(fn.name, fn) + this._onChanged.forEach(cb => cb(fn.name)) + } + + onChanged(fn: (id?: string) => void): () => void { + this._onChanged.push(fn) + return () => { + const index = this._onChanged.indexOf(fn) + if (index !== -1) { + this._onChanged.splice(index, 1) + } + } + } + + async getHandler(name: T): Promise { + return await getRpcHandler(this.definitions.get(name as string)!, this.context) as LocalFunctions[T] + } + + getSchema(name: T): { args: RpcArgsSchema | undefined, returns: RpcReturnSchema | undefined } { + const definition = this.definitions.get(name as string) + if (!definition) + throw new Error(`RPC function "${String(name)}" is not registered`) + return { + args: definition.args, + returns: definition.returns, + } + } + + has(name: string): boolean { + return this.definitions.has(name) + } + + get(name: string): RpcFunctionDefinition | undefined { + return this.definitions.get(name) + } + + list(): string[] { + return Array.from(this.definitions.keys()) + } +} diff --git a/packages/rpc/src/define.ts b/packages/rpc/src/define.ts new file mode 100644 index 00000000..43aae86f --- /dev/null +++ b/packages/rpc/src/define.ts @@ -0,0 +1,29 @@ +import type { RpcArgsSchema, RpcFunctionDefinition, RpcFunctionType, RpcReturnSchema } from './types' + +export function defineRpcFunction< + NAME extends string, + TYPE extends RpcFunctionType, + ARGS extends any[], + RETURN = void, + const AS extends RpcArgsSchema | undefined = undefined, + const RS extends RpcReturnSchema | undefined = undefined, +>( + definition: RpcFunctionDefinition, +): RpcFunctionDefinition { + return definition +} + +export function createDefineWrapperWithContext() { + return function defineRpcFunctionWithContext< + NAME extends string, + TYPE extends RpcFunctionType, + ARGS extends any[], + RETURN = void, + const AS extends RpcArgsSchema | undefined = undefined, + const RS extends RpcReturnSchema | undefined = undefined, + >( + definition: RpcFunctionDefinition, + ): RpcFunctionDefinition { + return definition + } +} diff --git a/packages/rpc/src/dumps.test.ts b/packages/rpc/src/dumps.test.ts new file mode 100644 index 00000000..2204664b --- /dev/null +++ b/packages/rpc/src/dumps.test.ts @@ -0,0 +1,844 @@ +import type { RpcDumpRecord } from './types' +import * as v from 'valibot' +import { describe, expect, it } from 'vitest' +import { createClientFromDump, createDefineWrapperWithContext, defineRpcFunction, dumpFunctions } from '.' + +describe('dumps', () => { + it('should collect dumps from definition', async () => { + const add = defineRpcFunction({ + name: 'add', + dump: { + inputs: [[1, 2], [3, 4], [5, 6]], + }, + handler: (a: number, b: number) => a + b, + }) + + const store = await dumpFunctions([add]) + + expect(Object.keys(store.definitions).length).toBe(1) + expect('add' in store.definitions).toBe(true) + + // Get all records for 'add' function + const addRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('add---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(addRecords.length).toBe(3) + expect(addRecords[0]).toMatchObject({ inputs: [1, 2], output: 3 }) + expect(addRecords[1]).toMatchObject({ inputs: [3, 4], output: 7 }) + expect(addRecords[2]).toMatchObject({ inputs: [5, 6], output: 11 }) + }) + + it('should create dump client and return correct results', async () => { + const greet = defineRpcFunction({ + name: 'greet', + dump: { + inputs: [['Alice'], ['Bob'], ['Charlie']], + }, + handler: (name: string) => `Hello, ${name}!`, + }) + + const store = await dumpFunctions([greet]) + const client = createClientFromDump(store) + + await expect(client.greet('Alice')).resolves.toBe('Hello, Alice!') + await expect(client.greet('Bob')).resolves.toBe('Hello, Bob!') + await expect(client.greet('Charlie')).resolves.toBe('Hello, Charlie!') + }) + + it('should return fallback for non-matching args when fallback is provided', async () => { + const greet = defineRpcFunction({ + name: 'greet', + dump: { + inputs: [['Alice'], ['Bob']], + fallback: 'Hello, stranger!', + }, + handler: (name: string) => `Hello, ${name}!`, + }) + + const store = await dumpFunctions([greet]) + const client = createClientFromDump(store) + + await expect(client.greet('Alice')).resolves.toBe('Hello, Alice!') + await expect(client.greet('Unknown')).resolves.toBe('Hello, stranger!') + }) + + it('should throw error for non-matching args when fallback is not provided', async () => { + const greet = defineRpcFunction({ + name: 'greet', + dump: { + inputs: [['Alice'], ['Bob']], + }, + handler: (name: string) => `Hello, ${name}!`, + }) + + const store = await dumpFunctions([greet]) + const client = createClientFromDump(store) + + await expect(client.greet('Alice')).resolves.toBe('Hello, Alice!') + await expect(client.greet('Unknown')).rejects.toThrow('[devtools-rpc] No dump match for "greet"') + }) + + it('should handle errors in dumps', async () => { + const divide = defineRpcFunction({ + name: 'divide', + dump: { + inputs: [[10, 2], [10, 0]], + }, + handler: (a: number, b: number) => { + if (b === 0) + throw new Error('Division by zero') + return a / b + }, + }) + + const store = await dumpFunctions([divide]) + + // Get all records for 'divide' function + const divideRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('divide---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(divideRecords[0]).toMatchObject({ inputs: [10, 2], output: 5 }) + expect(divideRecords[1]!.inputs).toEqual([10, 0]) + expect(divideRecords[1]!.error).toBeDefined() + expect(divideRecords[1]!.error?.message).toBe('Division by zero') + expect(divideRecords[1]!.error?.name).toBe('Error') + + const client = createClientFromDump(store) + + await expect(client.divide(10, 2)).resolves.toBe(5) + await expect(client.divide(10, 0)).rejects.toThrow('Division by zero') + }) + + it('should collect dumps from setup result', async () => { + const defineWithContext = createDefineWrapperWithContext<{ balance: number }>() + + const getBalance = defineWithContext({ + name: 'getBalance', + setup: (context) => { + return { + handler: () => context.balance, + dump: { + inputs: [[]] as [][], // Type as tuple array + }, + } + }, + }) + + const store = await dumpFunctions([getBalance], { balance: 100 }) + + // Get all records for 'getBalance' function + const balanceRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('getBalance---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(balanceRecords.length).toBe(1) + expect(balanceRecords[0]).toMatchObject({ inputs: [], output: 100 }) + + const client = createClientFromDump(store) + await expect(client.getBalance()).resolves.toBe(100) + }) + + it('should prioritize setup dumps over definition dumps', async () => { + const defineWithContext = createDefineWrapperWithContext<{ multiplier: number }>() + + const getValue = defineWithContext({ + name: 'getValue', + dump: { + inputs: [[1], [2]], + }, + setup: (context) => { + return { + handler: (x: number) => x * context.multiplier, + dump: { + inputs: [[5], [10]], // Different inputs from definition + }, + } + }, + }) + + const store = await dumpFunctions([getValue], { multiplier: 3 }) + + // Get all records for 'getValue' function + const valueRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('getValue---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + // Should use setup dumps, not definition dumps + expect(valueRecords.length).toBe(2) + expect(valueRecords[0]).toMatchObject({ inputs: [5], output: 15 }) + expect(valueRecords[1]).toMatchObject({ inputs: [10], output: 30 }) + }) + + it('should handle context-dependent dumps', async () => { + const defineWithContext = createDefineWrapperWithContext<{ env: 'dev' | 'prod' }>() + + const getConfig = defineWithContext({ + name: 'getConfig', + setup: (context) => { + return { + handler: (_key: string) => { + const configs = { + dev: { apiUrl: 'http://localhost:3000' }, + prod: { apiUrl: 'https://api.example.com' }, + } + return configs[context.env] + }, + dump: { + inputs: [['apiUrl']] as [string][], // Type as tuple array + }, + } + }, + }) + + const devStore = await dumpFunctions([getConfig], { env: 'dev' }) + const devClient = createClientFromDump(devStore) + await expect(devClient.getConfig('apiUrl')).resolves.toEqual({ apiUrl: 'http://localhost:3000' }) + + const prodStore = await dumpFunctions([getConfig], { env: 'prod' }) + const prodClient = createClientFromDump(prodStore) + await expect(prodClient.getConfig('apiUrl')).resolves.toEqual({ apiUrl: 'https://api.example.com' }) + }) + + it('should match arguments correctly with ohash', async () => { + const complexArgs = defineRpcFunction({ + name: 'complexArgs', + dump: { + inputs: [ + [{ id: 1, name: 'Alice' }, [1, 2, 3]], + [{ id: 2, name: 'Bob' }, [4, 5, 6]], + ], + }, + handler: (user: { id: number, name: string }, nums: number[]) => { + return `${user.name}: ${nums.join(',')}` + }, + }) + + const store = await dumpFunctions([complexArgs]) + const client = createClientFromDump(store) + + await expect(client.complexArgs({ id: 1, name: 'Alice' }, [1, 2, 3])).resolves.toBe('Alice: 1,2,3') + await expect(client.complexArgs({ id: 2, name: 'Bob' }, [4, 5, 6])).resolves.toBe('Bob: 4,5,6') + + // Different order should still match due to object hashing + await expect(client.complexArgs({ name: 'Alice', id: 1 }, [1, 2, 3])).resolves.toBe('Alice: 1,2,3') + }) + + it('should call onMiss callback when no match found', async () => { + const add = defineRpcFunction({ + name: 'add', + dump: { + inputs: [[1, 2]], + fallback: 0, + }, + handler: (a: number, b: number) => a + b, + }) + + const store = await dumpFunctions([add]) + + const misses: Array<{ functionName: string, args: any[] }> = [] + const client = createClientFromDump(store, { + onMiss: (functionName, args) => { + misses.push({ functionName, args }) + }, + }) + + await expect(client.add(1, 2)).resolves.toBe(3) + expect(misses).toHaveLength(0) + + await expect(client.add(3, 4)).resolves.toBe(0) // Returns fallback + expect(misses).toHaveLength(1) + expect(misses[0]).toEqual({ functionName: 'add', args: [3, 4] }) + }) + + it('should throw error for non-existent function', async () => { + const add = defineRpcFunction({ + name: 'add', + dump: { + inputs: [[1, 2]], + }, + handler: (a: number, b: number) => a + b, + }) + + const store = await dumpFunctions([add]) + const client = createClientFromDump(store) + + expect(() => (client as any).subtract(1, 2)).toThrow('[devtools-rpc] Function "subtract" not found in dump store') + }) + + it('should skip functions without dumps during collection', async () => { + const withDump = defineRpcFunction({ + name: 'withDump', + dump: { + inputs: [[1]], + }, + handler: (x: number) => x * 2, + }) + + const withoutDump = defineRpcFunction({ + name: 'withoutDump', + handler: (x: number) => x * 3, + }) + + const store = await dumpFunctions([withDump, withoutDump]) + + expect(Object.keys(store.definitions).length).toBe(1) + expect('withDump' in store.definitions).toBe(true) + expect('withoutDump' in store.definitions).toBe(false) + }) + + it('should handle async handlers', async () => { + const fetchData = defineRpcFunction({ + name: 'fetchData', + dump: { + inputs: [['user1'], ['user2']], + }, + handler: async (id: string) => { + await new Promise(resolve => setTimeout(resolve, 10)) + return { id, data: `Data for ${id}` } + }, + }) + + const store = await dumpFunctions([fetchData]) + + // Get all records for 'fetchData' function + const fetchDataRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('fetchData---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(fetchDataRecords.length).toBe(2) + expect(fetchDataRecords[0]?.output).toEqual({ id: 'user1', data: 'Data for user1' }) + expect(fetchDataRecords[1]?.output).toEqual({ id: 'user2', data: 'Data for user2' }) + }) + + it('should preserve metadata in dumps', async () => { + const getUser = defineRpcFunction({ + name: 'getUser', + type: 'query', + args: [v.string()], + returns: v.object({ id: v.string(), name: v.string() }), + dump: { + inputs: [['user1']], + }, + handler: (id: string) => ({ id, name: `User ${id}` }), + }) + + const store = await dumpFunctions([getUser]) + const userDefinition = store.definitions.getUser + + expect(userDefinition!.name).toBe('getUser') + expect(userDefinition!.type).toBe('query') + }) + + it('should support dump as a getter function', async () => { + const defineWithContext = createDefineWrapperWithContext<{ multiplier: number, values: number[] }>() + + const multiply = defineWithContext({ + name: 'multiply', + handler: (x: number) => x * 10, + dump: (context, _handler) => ({ + inputs: context.values.map(v => [v * context.multiplier]), + fallback: 0, + }), + }) + + const store = await dumpFunctions([multiply], { multiplier: 2, values: [1, 2, 3] }) + + // Get all records for 'multiply' function (excluding fallback) + const multiplyRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('multiply---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(multiplyRecords.length).toBe(3) + expect(multiplyRecords[0]).toMatchObject({ inputs: [2], output: 20 }) + expect(multiplyRecords[1]).toMatchObject({ inputs: [4], output: 40 }) + expect(multiplyRecords[2]).toMatchObject({ inputs: [6], output: 60 }) + + const client = createClientFromDump(store) + await expect(client.multiply(2)).resolves.toBe(20) + await expect(client.multiply(4)).resolves.toBe(40) + await expect(client.multiply(100)).resolves.toBe(0) // Fallback + }) + + it('should support async dump getter function', async () => { + const defineWithContext = createDefineWrapperWithContext<{ getUserIds: () => Promise }>() + + const getUser = defineWithContext({ + name: 'getUser', + handler: (id: string) => ({ id, name: `User ${id}` }), + dump: async (context, _handler) => { + const userIds = await context.getUserIds() + return { + inputs: userIds.map(id => [id]), + } + }, + }) + + const store = await dumpFunctions([getUser], { + getUserIds: async () => ['user1', 'user2'], + }) + + // Get all records for 'getUser' function + const userRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('getUser---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(userRecords.length).toBe(2) + expect(userRecords[0]).toMatchObject({ inputs: ['user1'], output: { id: 'user1', name: 'User user1' } }) + expect(userRecords[1]).toMatchObject({ inputs: ['user2'], output: { id: 'user2', name: 'User user2' } }) + }) + + it('should snapshot the store structure', async () => { + const add = defineRpcFunction({ + name: 'add', + args: [v.number(), v.number()], + returns: v.number(), + dump: { + inputs: [[1, 2], [3, 4]], + fallback: 0, + }, + handler: (a: number, b: number) => a + b, + }) + + const greet = defineRpcFunction({ + name: 'greet', + dump: { + inputs: [['Alice'], ['Bob']], + }, + handler: (name: string) => `Hello, ${name}!`, + }) + + const store = await dumpFunctions([add, greet]) + expect(store).toMatchSnapshot() + }) + + describe('dump snapshots', () => { + it('should snapshot dump with errors', async () => { + const divide = defineRpcFunction({ + name: 'divide', + dump: { + inputs: [[10, 2], [10, 0], [20, 4]], + }, + handler: (a: number, b: number) => { + if (b === 0) + throw new Error('Division by zero') + return a / b + }, + }) + + const store = await dumpFunctions([divide]) + expect(store).toMatchSnapshot() + }) + + it('should snapshot dump with pre-computed records', async () => { + const multiply = defineRpcFunction({ + name: 'multiply', + handler: (a: number, b: number) => a * b, + dump: { + records: [ + { inputs: [2, 3], output: 6 }, + { inputs: [4, 5], output: 20 }, + { inputs: [10, 0], output: 0 }, + ], + }, + }) + + const store = await dumpFunctions([multiply]) + expect(store).toMatchSnapshot() + }) + + it('should snapshot dump with mixed inputs and records', async () => { + const add = defineRpcFunction({ + name: 'add', + handler: (a: number, b: number) => a + b, + dump: { + inputs: [[1, 2], [3, 4]], + records: [ + { inputs: [10, 20], output: 30 }, + { inputs: [100, 200], output: 300 }, + ], + }, + }) + + const store = await dumpFunctions([add]) + expect(store).toMatchSnapshot() + }) + + it('should snapshot dump with context-dependent functions', async () => { + const defineWithContext = createDefineWrapperWithContext<{ env: 'dev' | 'prod' }>() + + const getConfig = defineWithContext({ + name: 'getConfig', + setup: (context) => { + return { + handler: (_key: string) => { + const configs = { + dev: { apiUrl: 'http://localhost:3000', debug: true }, + prod: { apiUrl: 'https://api.example.com', debug: false }, + } + return configs[context.env] + }, + dump: { + inputs: [['apiUrl']] as [string][], + }, + } + }, + }) + + const store = await dumpFunctions([getConfig], { env: 'dev' }) + expect(store).toMatchSnapshot() + }) + + it('should snapshot dump with fallback values', async () => { + const greet = defineRpcFunction({ + name: 'greet', + dump: { + inputs: [['Alice'], ['Bob']], + fallback: 'Hello, stranger!', + }, + handler: (name: string) => `Hello, ${name}!`, + }) + + const store = await dumpFunctions([greet]) + expect(store).toMatchSnapshot() + }) + + it('should snapshot dump with static functions', async () => { + const getVersion = defineRpcFunction({ + name: 'getVersion', + type: 'static', + handler: () => '1.0.0', + }) + + const getConfig = defineRpcFunction({ + name: 'getConfig', + type: 'static', + handler: () => ({ + apiUrl: 'https://api.example.com', + version: 'v1', + features: ['auth', 'cache'], + }), + }) + + const store = await dumpFunctions([getVersion, getConfig]) + expect(store).toMatchSnapshot() + }) + + it('should snapshot comprehensive dump with multiple scenarios', async () => { + const divide = defineRpcFunction({ + name: 'divide', + dump: { + inputs: [[10, 2], [10, 0]], + }, + handler: (a: number, b: number) => { + if (b === 0) + throw new Error('Division by zero') + return a / b + }, + }) + + const multiply = defineRpcFunction({ + name: 'multiply', + handler: (a: number, b: number) => a * b, + dump: { + records: [ + { inputs: [2, 3], output: 6 }, + ], + }, + }) + + const add = defineRpcFunction({ + name: 'add', + handler: (a: number, b: number) => a + b, + dump: { + inputs: [[1, 2]], + records: [ + { inputs: [10, 20], output: 30 }, + ], + fallback: 0, + }, + }) + + const getConfig = defineRpcFunction({ + name: 'getConfig', + type: 'static', + handler: () => ({ version: '1.0.0' }), + }) + + const store = await dumpFunctions([divide, multiply, add, getConfig]) + expect(store).toMatchSnapshot() + }) + }) + + it('should throw error if action type function has dump', async () => { + const sendEmail = defineRpcFunction({ + name: 'sendEmail', + type: 'action', + dump: { + inputs: [['test@example.com']], + }, + handler: (email: string) => `Sent to ${email}`, + }) + + await expect(dumpFunctions([sendEmail])).rejects.toThrow( + '[devtools-rpc] Function "sendEmail" with type "action" cannot have dump configuration', + ) + }) + + it('should throw error if event type function has dump', async () => { + const notifyUser = defineRpcFunction({ + name: 'notifyUser', + type: 'event', + dump: { + inputs: [['user1']], + }, + handler: (userId: string) => `Notified ${userId}`, + }) + + await expect(dumpFunctions([notifyUser])).rejects.toThrow( + '[devtools-rpc] Function "notifyUser" with type "event" cannot have dump configuration', + ) + }) + + it('should allow query type function with dump', async () => { + const getUser = defineRpcFunction({ + name: 'getUser', + type: 'query', + dump: { + inputs: [['user1']], + }, + handler: (id: string) => ({ id, name: `User ${id}` }), + }) + + const store = await dumpFunctions([getUser]) + expect(store.definitions.getUser).toBeDefined() + }) + + it('should allow static type function with dump', async () => { + const getConfig = defineRpcFunction({ + name: 'getConfig', + type: 'static', + dump: { + inputs: [[]], + }, + handler: () => ({ apiUrl: 'https://api.example.com' }), + }) + + const store = await dumpFunctions([getConfig]) + expect(store.definitions.getConfig).toBeDefined() + }) + + it('should allow function without type (defaults to query) with dump', async () => { + const getData = defineRpcFunction({ + name: 'getData', + dump: { + inputs: [[]], + }, + handler: () => 'data', + }) + + const store = await dumpFunctions([getData]) + expect(store.definitions.getData).toBeDefined() + }) + + it('should automatically dump static functions without explicit dump config', async () => { + const getConfig = defineRpcFunction({ + name: 'getConfig', + type: 'static', + handler: () => ({ apiUrl: 'https://api.example.com', version: 'v1' }), + }) + + const store = await dumpFunctions([getConfig]) + + // Should have definition + expect(store.definitions.getConfig).toBeDefined() + expect(store.definitions.getConfig!.type).toBe('static') + + // Should have one record with empty inputs + const configRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('getConfig---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(configRecords.length).toBe(1) + expect(configRecords[0]!.inputs).toEqual([]) + expect(configRecords[0]!.output).toEqual({ apiUrl: 'https://api.example.com', version: 'v1' }) + }) + + it('should use client with static function that has default dump', async () => { + const getVersion = defineRpcFunction({ + name: 'getVersion', + type: 'static', + handler: () => '1.0.0', + }) + + const store = await dumpFunctions([getVersion]) + const client = createClientFromDump(store) + + await expect(client.getVersion()).resolves.toBe('1.0.0') + }) + + it('should respect explicit dump config over default for static functions', async () => { + const getConfigForEnv = defineRpcFunction({ + name: 'getConfigForEnv', + type: 'static', + dump: { + inputs: [['dev'], ['prod']], + }, + handler: (env: string) => ({ env, apiUrl: `https://${env}.api.example.com` }), + }) + + const store = await dumpFunctions([getConfigForEnv]) + + // Should have two records (for dev and prod), not the default empty args + const configRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('getConfigForEnv---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(configRecords.length).toBe(2) + expect(configRecords.some(r => r.inputs[0] === 'dev')).toBe(true) + expect(configRecords.some(r => r.inputs[0] === 'prod')).toBe(true) + }) + + it('should support pre-computed records in dump', async () => { + const multiply = defineRpcFunction({ + name: 'multiply', + handler: (a: number, b: number) => a * b, + dump: { + records: [ + { inputs: [2, 3], output: 6 }, + { inputs: [4, 5], output: 20 }, + { inputs: [10, 0], output: 0 }, + ], + }, + }) + + const store = await dumpFunctions([multiply]) + + const multiplyRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('multiply---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(multiplyRecords.length).toBe(3) + expect(multiplyRecords[0]).toMatchObject({ inputs: [2, 3], output: 6 }) + expect(multiplyRecords[1]).toMatchObject({ inputs: [4, 5], output: 20 }) + expect(multiplyRecords[2]).toMatchObject({ inputs: [10, 0], output: 0 }) + + // Verify client works with pre-computed records + const client = createClientFromDump(store) + await expect(client.multiply(2, 3)).resolves.toBe(6) + await expect(client.multiply(4, 5)).resolves.toBe(20) + await expect(client.multiply(10, 0)).resolves.toBe(0) + }) + + it('should support mixing inputs and records', async () => { + const add = defineRpcFunction({ + name: 'add', + handler: (a: number, b: number) => a + b, + dump: { + inputs: [[1, 2], [3, 4]], + records: [ + { inputs: [10, 20], output: 30 }, + ], + }, + }) + + const store = await dumpFunctions([add]) + + const addRecords = Object.entries(store.records) + .filter(([key]) => key.startsWith('add---') && !key.endsWith('---fallback')) + .map(([, record]) => record as RpcDumpRecord) + + expect(addRecords.length).toBe(3) + + const client = createClientFromDump(store) + await expect(client.add(1, 2)).resolves.toBe(3) + await expect(client.add(3, 4)).resolves.toBe(7) + await expect(client.add(10, 20)).resolves.toBe(30) + }) + + it('should support error records', async () => { + const divide = defineRpcFunction({ + name: 'divide', + handler: (a: number, b: number) => a / b, + dump: { + records: [ + { inputs: [10, 2], output: 5 }, + { + inputs: [10, 0], + error: { + message: 'Cannot divide by zero', + name: 'Error', + }, + }, + ], + }, + }) + + const store = await dumpFunctions([divide]) + const client = createClientFromDump(store) + + await expect(client.divide(10, 2)).resolves.toBe(5) + await expect(client.divide(10, 0)).rejects.toThrow('Cannot divide by zero') + }) + + it('should support parallel execution', async () => { + const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + + const slowAdd = defineRpcFunction({ + name: 'slowAdd', + handler: async (a: number, b: number) => { + await delay(10) + return a + b + }, + dump: { + inputs: [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]], + }, + }) + + const startTime = Date.now() + const store = await dumpFunctions([slowAdd], undefined, { concurrency: true }) + const parallelTime = Date.now() - startTime + + // Verify all results are correct + const records = Object.entries(store.records) + .filter(([key]) => key.startsWith('slowAdd---')) + .map(([, record]) => record as RpcDumpRecord) + + expect(records.length).toBe(5) + expect(records[0]).toMatchObject({ inputs: [1, 2], output: 3 }) + expect(records[1]).toMatchObject({ inputs: [3, 4], output: 7 }) + expect(records[2]).toMatchObject({ inputs: [5, 6], output: 11 }) + + // Parallel execution should be faster than sequential (roughly) + // 5 operations * 10ms = 50ms sequential, but parallel should be ~10-20ms + expect(parallelTime).toBeLessThan(40) + }) + + it('should respect concurrency limit', async () => { + let maxConcurrent = 0 + let currentConcurrent = 0 + + const trackedAdd = defineRpcFunction({ + name: 'trackedAdd', + handler: async (a: number, b: number) => { + currentConcurrent++ + maxConcurrent = Math.max(maxConcurrent, currentConcurrent) + await new Promise(resolve => setTimeout(resolve, 10)) + currentConcurrent-- + return a + b + }, + dump: { + inputs: [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]], + }, + }) + + await dumpFunctions([trackedAdd], undefined, { + concurrency: 2, + }) + + // With concurrency limit of 2, max concurrent should never exceed 2 + expect(maxConcurrent).toBeLessThanOrEqual(2) + expect(maxConcurrent).toBeGreaterThan(0) + }) +}) diff --git a/packages/rpc/src/dumps.ts b/packages/rpc/src/dumps.ts new file mode 100644 index 00000000..472fded7 --- /dev/null +++ b/packages/rpc/src/dumps.ts @@ -0,0 +1,270 @@ +import type { + BirpcReturn, + RpcDefinitionsToFunctions, + RpcDumpClientOptions, + RpcDumpCollectionOptions, + RpcDumpDefinition, + RpcDumpStore, + RpcFunctionDefinitionAny, +} from './types' +import { hash } from 'ohash' +import pLimit from 'p-limit' +import { validateDefinitions } from './validation' + +function getDumpRecordKey(functionName: string, args: any[]): string { + const argsHash = hash(args) + return `${functionName}---${argsHash}` +} + +function getDumpFallbackKey(functionName: string): string { + return `${functionName}---fallback` +} + +async function resolveGetter(valueOrGetter: T | (() => Promise)): Promise { + return typeof valueOrGetter === 'function' + ? await (valueOrGetter as () => Promise)() + : valueOrGetter +} + +/** + * Collects pre-computed dumps by executing functions with their defined input combinations. + * Static functions without dump config automatically get `{ inputs: [[]] }`. + * + * @example + * ```ts + * const store = await dumpFunctions([greet], context, { concurrency: 10 }) + * ``` + */ +export async function dumpFunctions< + T extends readonly RpcFunctionDefinitionAny[], +>( + definitions: T, + context?: any, + options: RpcDumpCollectionOptions = {}, +): Promise>> { + validateDefinitions(definitions) + const concurrency = options.concurrency === true + ? 5 + : options.concurrency === false || options.concurrency == null + ? 1 + : options.concurrency + + const store: RpcDumpStore = { + definitions: {}, + records: {}, + } + + // #region Definition resolution + interface TaskResolution { + handler: (...args: any[]) => any + dump: RpcDumpDefinition + definition: RpcFunctionDefinitionAny + } + + const tasksResolutions: (() => Promise)[] = definitions.map(definition => async () => { + if (definition.type === 'event' || definition.type === 'action') { + return undefined + } + + // Fresh setup results for each context to avoid caching issues + const setupResult = definition.setup + ? await Promise.resolve(definition.setup(context)) + : {} + + const handler = setupResult.handler || definition.handler + if (!handler) { + throw new Error(`[devtools-rpc] Either handler or setup function must be provided for RPC function "${definition.name}"`) + } + + let dump = setupResult.dump ?? definition.dump + if (!dump && definition.type === 'static') { + dump = { inputs: [[]] } + } + + if (!dump) { + return undefined + } + + if (typeof dump === 'function') { + dump = await Promise.resolve(dump(context, handler)) + } + + // Only add to definitions if it has a dump + store.definitions[definition.name] = { + name: definition.name, + type: definition.type, + } + + return { + handler, + dump, + definition, + } + }) + + let functionsToDump: TaskResolution[] = [] + if (concurrency <= 1) { + for (const task of tasksResolutions) { + const resolution = await task() + if (resolution) { + functionsToDump.push(resolution) + } + } + } + else { + const limit = pLimit(concurrency) + functionsToDump = (await Promise.all(tasksResolutions.map(task => limit(task)))).filter(x => !!x) + } + // #endregion + + // #region Dump execution + const dumpTasks: Array<() => Promise> = [] + for (const { definition, handler, dump } of functionsToDump) { + const { inputs, records, fallback } = dump + + // Add pre-defined records + if (records) { + for (const record of records) { + const recordKey = getDumpRecordKey(definition.name, record.inputs) + store.records[recordKey] = record + } + } + + // Add fallback record + if ('fallback' in dump) { + const fallbackKey = getDumpFallbackKey(definition.name) + store.records[fallbackKey] = { + inputs: [], + output: fallback, + } + } + + // Add input records execution tasks + if (inputs) { + for (const input of inputs) { + dumpTasks.push(async () => { + const recordKey = getDumpRecordKey(definition.name, input) + + try { + const output = await Promise.resolve(handler(...input)) + store.records[recordKey] = { + inputs: input, + output, + } + } + catch (error: any) { + store.records[recordKey] = { + inputs: input, + error: { + message: error.message, + name: error.name, + }, + } + } + }) + } + } + } + + if (concurrency <= 1) { + for (const task of dumpTasks) { + await task() + } + } + else { + const limit = pLimit(concurrency) + await Promise.all(dumpTasks.map(task => limit(task))) + } + // #endregion + + return store +} + +/** + * Creates a client that serves pre-computed results from a dump store. + * Uses argument hashing to match calls to stored records. + * + * @example + * ```ts + * const client = createClientFromDump(store) + * await client.greet('Alice') + * ``` + */ +export function createClientFromDump>( + store: RpcDumpStore, + options: RpcDumpClientOptions = {}, +): BirpcReturn { + const { onMiss } = options + + const client = new Proxy({} as T, { + get(_, functionName: string) { + if (!(functionName in store.definitions)) { + throw new Error(`[devtools-rpc] Function "${functionName}" not found in dump store`) + } + + return async (...args: any[]) => { + const recordKey = getDumpRecordKey(functionName, args) + + const recordOrGetter = store.records[recordKey] + + if (recordOrGetter) { + const record = await resolveGetter(recordOrGetter) + + if (record.error) { + const error = new Error(record.error.message) + error.name = record.error.name + throw error + } + + if (typeof record.output === 'function') { + return await record.output() + } + + return record.output + } + + onMiss?.(functionName, args) + + const fallbackKey = getDumpFallbackKey(functionName) + if (fallbackKey in store.records) { + const fallbackOrGetter = store.records[fallbackKey] + + const fallbackRecord = await resolveGetter(fallbackOrGetter) + + if (fallbackRecord && typeof fallbackRecord.output === 'function') { + return await fallbackRecord.output() + } + if (fallbackRecord) + return fallbackRecord.output + } + + throw new Error( + `[devtools-rpc] No dump match for "${functionName}" with args: ${JSON.stringify(args)}`, + ) + } + }, + has(_, functionName: string) { + return functionName in store.definitions + }, + ownKeys() { + return Object.keys(store.definitions) + }, + getOwnPropertyDescriptor(_, functionName: string) { + return functionName in store.definitions + ? { configurable: true, enumerable: true, value: undefined } + : undefined + }, + }) + + return client as any as BirpcReturn +} + +/** + * Filters function definitions to only those with dump definitions. + * Note: Only checks the definition itself, not setup results. + */ +export function getDefinitionsWithDumps( + definitions: T, +): RpcFunctionDefinitionAny[] { + return definitions.filter(def => def.dump !== undefined) +} diff --git a/packages/rpc/src/handler.ts b/packages/rpc/src/handler.ts new file mode 100644 index 00000000..aec8f196 --- /dev/null +++ b/packages/rpc/src/handler.ts @@ -0,0 +1,47 @@ +import type { RpcFunctionDefinition, RpcFunctionSetupResult, RpcFunctionType } from './types' + +export async function getRpcResolvedSetupResult< + NAME extends string, + TYPE extends RpcFunctionType, + ARGS extends any[], + RETURN = void, + CONTEXT = undefined, +>( + definition: RpcFunctionDefinition, + context: CONTEXT, +): Promise> { + if (definition.__resolved) { + return definition.__resolved + } + if (!definition.setup) { + return {} + } + definition.__promise ??= Promise.resolve(definition.setup(context)) + .then((r) => { + definition.__resolved = r + definition.__promise = undefined + return r + }) + const result = definition.__resolved ??= await definition.__promise + return result +} + +export async function getRpcHandler< + NAME extends string, + TYPE extends RpcFunctionType, + ARGS extends any[], + RETURN = void, + CONTEXT = undefined, +>( + definition: RpcFunctionDefinition, + context: CONTEXT, +): Promise<(...args: ARGS) => RETURN> { + if (definition.handler) { + return definition.handler + } + const result = await getRpcResolvedSetupResult(definition, context) + if (!result.handler) { + throw new Error(`[devtools-rpc] Either handler or setup function must be provided for RPC function "${definition.name}"`) + } + return result.handler +} diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts index 7636a1fa..ef021396 100644 --- a/packages/rpc/src/index.ts +++ b/packages/rpc/src/index.ts @@ -1,2 +1,7 @@ -export * from './client' -export * from './server' +export * from './cache' +export * from './collector' +export * from './define' +export * from './dumps' +export * from './handler' +export * from './types' +export * from './validation' diff --git a/packages/rpc/src/index.test.ts b/packages/rpc/src/presets/index.test.ts similarity index 93% rename from packages/rpc/src/index.test.ts rename to packages/rpc/src/presets/index.test.ts index 85056464..6f18ad50 100644 --- a/packages/rpc/src/index.test.ts +++ b/packages/rpc/src/presets/index.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { WebSocket } from 'ws' -import { createRpcClient, createRpcServer } from '.' -import { createWsRpcPreset as createWsRpcClientPreset } from './presets/ws/client' -import { createWsRpcPreset as createWsRpcServerPreset } from './presets/ws/server' +import { createRpcClient } from '../client' +import { createRpcServer } from '../server' +import { createWsRpcPreset as createWsRpcClientPreset } from './ws/client' +import { createWsRpcPreset as createWsRpcServerPreset } from './ws/server' vi.stubGlobal('WebSocket', WebSocket) diff --git a/packages/rpc/src/types.test.ts b/packages/rpc/src/types.test.ts new file mode 100644 index 00000000..f1329b86 --- /dev/null +++ b/packages/rpc/src/types.test.ts @@ -0,0 +1,129 @@ +/* eslint-disable unused-imports/no-unused-vars */ +import type { + RpcDefinitionsToFunctions, + RpcFunctionDefinitionToFunction, +} from '../src' +import type { AssertEqual } from '../src/utils' +import * as v from 'valibot' +import { describe, it } from 'vitest' +import { defineRpcFunction } from '../src' + +describe('rpcFunctionDefinitionToFunction', () => { + it('should infer types from generic parameters when no schemas', () => { + const fn = defineRpcFunction({ + name: 'noSchema', + handler: (a: string, b: number) => { + return a.length + b + }, + }) + + type Result = RpcFunctionDefinitionToFunction + type _Test = AssertEqual number> + }) + + it('should infer types from schemas when provided', () => { + const fn = defineRpcFunction({ + name: 'withSchema', + args: [v.string(), v.number()], + returns: v.boolean(), + handler: (a, b) => { + return a.length > b + }, + }) + + type Result = RpcFunctionDefinitionToFunction + type _Test = AssertEqual boolean> + }) + + it('should infer void return from void schema', () => { + const fn = defineRpcFunction({ + name: 'voidReturn', + args: [v.string()], + returns: v.void(), + handler: (_a) => {}, + }) + + type Result = RpcFunctionDefinitionToFunction + type _Test = AssertEqual void> + }) + + it('should infer empty args from empty schema', () => { + const fn = defineRpcFunction({ + name: 'noArgs', + args: [], + returns: v.number(), + handler: () => 42, + }) + + type Result = RpcFunctionDefinitionToFunction + type _Test = AssertEqual number> + }) + + it('should work with setup function instead of handler', () => { + const fn = defineRpcFunction({ + name: 'withSetup', + args: [v.object({ id: v.string() })], + returns: v.array(v.string()), + setup: () => ({ + handler: (input) => { + return [input.id] + }, + }), + }) + + type Result = RpcFunctionDefinitionToFunction + type _Test = AssertEqual string[]> + }) +}) + +describe('rpcDefinitionsToFunctions', () => { + it('should map definitions to functions correctly', () => { + const fn1 = defineRpcFunction({ + name: 'getUser', + args: [v.string()], + returns: v.object({ name: v.string() }), + handler: id => ({ name: `User ${id}` }), + }) + + const fn2 = defineRpcFunction({ + name: 'add', + handler: (a: number, b: number) => a + b, + }) + + const definitions = [fn1, fn2] as const + + type Result = RpcDefinitionsToFunctions + type _Test = AssertEqual< + Result, + { + getUser: (arg_0: string) => { name: string } + add: (a: number, b: number) => number + } + > + }) + + it('should handle mixed definitions with and without schemas', () => { + const withSchema = defineRpcFunction({ + name: 'withSchema', + args: [v.boolean()], + returns: v.string(), + handler: flag => (flag ? 'yes' : 'no'), + }) + + const withoutSchema = defineRpcFunction({ + name: 'withoutSchema', + handler: (items: string[]) => items.length, + }) + + const definitions = [withSchema, withoutSchema] as const + + type Result = RpcDefinitionsToFunctions + type _Test = AssertEqual< + Result, + { + withSchema: (arg_0: boolean) => string + withoutSchema: (items: string[]) => number + } + > + }) +}) diff --git a/packages/rpc/src/types.ts b/packages/rpc/src/types.ts new file mode 100644 index 00000000..7727bdf8 --- /dev/null +++ b/packages/rpc/src/types.ts @@ -0,0 +1,222 @@ +import type { GenericSchema } from 'valibot' +import type { InferArgsType, InferReturnType } from './utils' + +export type { BirpcFn, BirpcReturn } from 'birpc' + +export type Thenable = T | Promise + +export type EntriesToObject = { + [K in T[number] as K[0]]: K[1] +} + +/** + * Type of the RPC function, + * - static: A function that returns a static data, no arguments (can be cached and dumped) + * - action: A function that performs an action (no data returned) + * - event: A function that emits an event (no data returned), and does not wait for a response + * - query: A function that queries a resource + * + * By default, the function is a query function. + */ +export type RpcFunctionType = 'static' | 'action' | 'event' | 'query' + +/** + * Manages dynamic function registration and provides a type-safe proxy for accessing functions. + */ +export interface RpcFunctionsCollector { + /** User-provided context passed to setup functions */ + context: SetupContext + /** Type-safe proxy for calling registered functions */ + readonly functions: LocalFunctions + /** Map of registered function definitions keyed by function name */ + readonly definitions: Map> + /** Register a new function definition */ + register: (fn: RpcFunctionDefinitionAnyWithContext) => void + /** Update an existing function definition */ + update: (fn: RpcFunctionDefinitionAnyWithContext) => void + /** Subscribe to function changes, returns unsubscribe function */ + onChanged: (fn: (id?: string) => void) => (() => void) +} + +/** + * Result returned by a function's setup method. + */ +export interface RpcFunctionSetupResult< + ARGS extends any[], + RETURN = void, +> { + /** Function handler */ + handler?: (...args: ARGS) => RETURN + /** Optional dump definition (overrides definition-level dump) */ + dump?: RpcDumpDefinition +} + +/** Valibot schema array for validating function arguments */ +export type RpcArgsSchema = readonly GenericSchema[] +/** Valibot schema for validating function return value */ +export type RpcReturnSchema = GenericSchema + +/** + * Single record in a dump store with pre-computed results. + */ +export interface RpcDumpRecord { + /** Function arguments */ + inputs: ARGS + /** Result (value or lazy function) */ + output?: RETURN + /** Error if execution failed */ + error?: { + /** Error message */ + message: string + /** Error type name (e.g., "Error", "TypeError") */ + name: string + } +} + +/** + * Defines argument combinations to pre-compute for a function. + */ +export interface RpcDumpDefinition { + /** Argument combinations to pre-compute by executing handler */ + inputs?: ARGS[] + /** Pre-computed records to use directly (bypasses handler execution) */ + records?: RpcDumpRecord[] + /** Fallback value when no match found */ + fallback?: RETURN +} + +/** + * Dynamically generates dump definitions based on context. + */ +export type RpcDumpGetter + = (context: CONTEXT, handler: (...args: ARGS) => RETURN) => Thenable> + +/** + * Dump configuration (static object or dynamic function). + */ +export type RpcDump + = | RpcDumpDefinition + | RpcDumpGetter + +/** + * Base function definition metadata. + */ +export interface RpcFunctionDefinitionBase { + /** Function name (unique identifier) */ + name: string + /** Function type (static, action, event, or query) */ + type?: RpcFunctionType +} + +/** + * Dump store containing pre-computed results. + * Flat structure for serialization and efficient lookups. + */ +export interface RpcDumpStore { + /** Function definitions keyed by name */ + definitions: Record + /** Records keyed by '---' or '---fallback' */ + records: Record Promise)> + /** @internal */ + _functions?: T +} + +/** + * Dump client options. + */ +export interface RpcDumpClientOptions { + /** Called when arguments don't match any pre-computed entry */ + onMiss?: (functionName: string, args: any[]) => void +} + +/** + * Options for collecting dumps. + */ +export interface RpcDumpCollectionOptions { + /** + * Concurrency control for parallel execution. + * - `false` or `undefined`: sequential execution (default) + * - `true`: parallel execution with concurrency limit of 5 + * - `number`: parallel execution with specified concurrency limit + */ + concurrency?: boolean | number | null +} + +/** + * RPC function definition with optional dump support. + */ +export type RpcFunctionDefinition< + NAME extends string, + TYPE extends RpcFunctionType = 'query', + ARGS extends any[] = [], + RETURN = void, + AS extends RpcArgsSchema | undefined = undefined, + RS extends RpcReturnSchema | undefined = undefined, + CONTEXT = undefined, +> + = [AS, RS] extends [undefined, undefined] + ? { + /** Function name (unique identifier) */ + name: NAME + /** Function type (static, action, event, or query) */ + type?: TYPE + /** Whether the function results should be cached */ + cacheable?: boolean + /** Valibot schema array for validating function arguments */ + args?: AS + /** Valibot schema for validating function return value */ + returns?: RS + /** Setup function called with context to initialize handler and dump */ + setup?: (context: CONTEXT) => Thenable> + /** Function implementation (required if setup doesn't provide one) */ + handler?: (...args: ARGS) => RETURN + /** Dump definition (setup dump takes priority) */ + dump?: RpcDump + __resolved?: RpcFunctionSetupResult + __promise?: Thenable> + } + : { + /** Function name (unique identifier) */ + name: NAME + /** Function type (static, action, event, or query) */ + type?: TYPE + /** Whether the function results should be cached */ + cacheable?: boolean + /** Valibot schema array for validating function arguments */ + args: AS + /** Valibot schema for validating function return value */ + returns: RS + /** Setup function called with context to initialize handler and dump */ + setup?: (context: CONTEXT) => Thenable, InferReturnType>> + /** Function implementation (required if setup doesn't provide one) */ + handler?: (...args: InferArgsType) => InferReturnType + /** Dump definition (setup dump takes priority) */ + dump?: RpcDump, InferReturnType, CONTEXT> + __resolved?: RpcFunctionSetupResult, InferReturnType> + __promise?: Thenable, InferReturnType>> + } + +export type RpcFunctionDefinitionToFunction + = T extends { args: infer AS, returns: infer RS } + ? AS extends RpcArgsSchema + ? RS extends RpcReturnSchema + ? (...args: InferArgsType) => InferReturnType + : never + : never + : T extends RpcFunctionDefinition + ? (...args: ARGS) => RETURN + : never + +export type RpcFunctionDefinitionAny = RpcFunctionDefinition +export type RpcFunctionDefinitionAnyWithContext = RpcFunctionDefinition + +export type RpcDefinitionsToFunctions = EntriesToObject<{ + [K in keyof T]: [T[K]['name'], RpcFunctionDefinitionToFunction] +}> + +export type RpcDefinitionsFilter< + T extends readonly RpcFunctionDefinitionAny[], + Type extends RpcFunctionType, +> = { + [K in keyof T]: T[K] extends { type: Type } ? T[K] : never +} diff --git a/packages/rpc/src/utils.ts b/packages/rpc/src/utils.ts new file mode 100644 index 00000000..18e3fb46 --- /dev/null +++ b/packages/rpc/src/utils.ts @@ -0,0 +1,24 @@ +import type { GenericSchema, InferInput } from 'valibot' +import type { RpcArgsSchema, RpcReturnSchema } from './types' + +/** Type-level assertion that two types are equal */ +export type AssertEqual + = (() => T extends X ? 1 : 2) extends + (() => T extends Y ? 1 : 2) ? true : never + +/** Infers TypeScript tuple type from Valibot schema array */ +export type InferArgsType + = S extends readonly [] ? [] + : S extends readonly [infer H, ...infer T] + ? H extends GenericSchema + ? T extends readonly GenericSchema[] + ? [InferInput, ...InferArgsType] + : never + : never + : never + +/** Infers TypeScript return type from Valibot return schema */ +export type InferReturnType + = S extends RpcReturnSchema + ? InferInput + : void diff --git a/packages/rpc/src/validation.ts b/packages/rpc/src/validation.ts new file mode 100644 index 00000000..bdc5b9d3 --- /dev/null +++ b/packages/rpc/src/validation.ts @@ -0,0 +1,28 @@ +import type { RpcFunctionDefinitionAny } from './types' + +/** + * Validates RPC function definitions. + * Action and event functions cannot have dumps (side effects should not be cached). + * + * @throws {Error} If an action or event function has a dump configuration + */ +export function validateDefinitions(definitions: readonly RpcFunctionDefinitionAny[]): void { + for (const definition of definitions) { + const type = definition.type || 'query' + + if ((type === 'action' || type === 'event') && definition.dump) { + throw new Error( + `[devtools-rpc] Function "${definition.name}" with type "${type}" cannot have dump configuration. Only "static" and "query" types support dumps.`, + ) + } + } +} + +/** + * Validates a single RPC function definition. + * + * @throws {Error} If an action or event function has a dump configuration + */ +export function validateDefinition(definition: RpcFunctionDefinitionAny): void { + validateDefinitions([definition]) +} diff --git a/packages/rpc/test/collector/__snapshots__/collector.test.ts.snap b/packages/rpc/test/collector/__snapshots__/collector.test.ts.snap new file mode 100644 index 00000000..d615ad26 --- /dev/null +++ b/packages/rpc/test/collector/__snapshots__/collector.test.ts.snap @@ -0,0 +1,36 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`collector > get schema 2`] = ` +{ + "args": [ + { + "async": false, + "expects": "number", + "kind": "schema", + "message": undefined, + "reference": [Function], + "type": "number", + "~run": [Function], + "~standard": { + "validate": [Function], + "vendor": "valibot", + "version": 1, + }, + }, + ], + "returns": { + "async": false, + "expects": "void", + "kind": "schema", + "message": undefined, + "reference": [Function], + "type": "void", + "~run": [Function], + "~standard": { + "validate": [Function], + "vendor": "valibot", + "version": 1, + }, + }, +} +`; diff --git a/packages/rpc/test/collector/alice.ts b/packages/rpc/test/collector/alice.ts new file mode 100644 index 00000000..f7004c69 --- /dev/null +++ b/packages/rpc/test/collector/alice.ts @@ -0,0 +1,87 @@ +import type { RpcDefinitionsToFunctions } from '../../src' +import type { AliceFunctions } from './shared-types' +import * as v from 'valibot' +import { createDefineWrapperWithContext, RpcFunctionsCollectorBase } from '../../src' + +interface AliceContext { + name: 'alice' + balance: number + apples: number +} + +const aliceContext: AliceContext = { + name: 'alice', + balance: 101, + apples: 5, +} + +export const defineAliceFunction = createDefineWrapperWithContext() + +const getBalance = defineAliceFunction({ + name: 'getBalance', + type: 'static', + setup: async (context) => { + return { + handler: () => { + return context.balance + }, + } + }, +}) + +const buyApples = defineAliceFunction({ + name: 'buyApples', + type: 'action', + args: [v.number()], + returns: v.void(), + setup: async (context) => { + return { + handler: (count: number) => { + const earnings = count * 2 + if (context.apples >= count) { + context.balance += earnings + context.apples -= count + } + else { + throw new Error('Insufficient apples') + } + }, + } + }, +}) + +const getAppleCount = defineAliceFunction({ + name: 'getAppleCount', + type: 'query', + setup: async (context) => { + return { + handler: () => { + return context.apples + }, + } + }, +}) + +const hi = defineAliceFunction({ + name: 'hi', + type: 'static', + handler: () => { + return 'hi' + }, +}) + +export const functionDefs = [ + getBalance, + buyApples, + getAppleCount, + hi, +] as const + +declare module './shared-types' { + interface AliceFunctions extends RpcDefinitionsToFunctions { } +} + +export const aliceCollector = new RpcFunctionsCollectorBase(aliceContext) +for (const dep of functionDefs) { + aliceCollector.register(dep) +} diff --git a/packages/rpc/test/collector/bob.ts b/packages/rpc/test/collector/bob.ts new file mode 100644 index 00000000..0dc03212 --- /dev/null +++ b/packages/rpc/test/collector/bob.ts @@ -0,0 +1,59 @@ +import type { RpcDefinitionsToFunctions } from '../../src' +import type { BobFunctions } from './shared-types' +import { createDefineWrapperWithContext, RpcFunctionsCollectorBase } from '../../src' + +interface BobContext { + name: 'bob' + money: number +} + +const bobContext: BobContext = { + name: 'bob', + money: 50, +} + +export const defineBobFunction = createDefineWrapperWithContext() + +const getMoney = defineBobFunction({ + name: 'getMoney', + type: 'static', + cacheable: true, + setup: async (context) => { + return { + handler: () => { + return context.money + }, + } + }, +}) + +const takeMoney = defineBobFunction({ + name: 'takeMoney', + type: 'action', + setup: async (context) => { + return { + handler: (amount: number) => { + if (context.money >= amount) { + context.money -= amount + } + else { + throw new Error('Insufficient money') + } + }, + } + }, +}) + +export const functionDefs = [ + getMoney, + takeMoney, +] as const + +declare module './shared-types' { + interface BobFunctions extends RpcDefinitionsToFunctions { } +} + +export const bobCollector = new RpcFunctionsCollectorBase(bobContext) +for (const dep of functionDefs) { + bobCollector.register(dep) +} diff --git a/packages/rpc/test/collector/collector.test.ts b/packages/rpc/test/collector/collector.test.ts new file mode 100644 index 00000000..80e527e3 --- /dev/null +++ b/packages/rpc/test/collector/collector.test.ts @@ -0,0 +1,120 @@ +import type { AliceFunctions, BobFunctions } from './shared-types' +import { createBirpc } from 'birpc' +import * as v from 'valibot' +import { describe, expect, it } from 'vitest' +import { defineRpcFunction } from '../../src' +import { aliceCollector } from './alice' +import { bobCollector } from './bob' + +describe('collector', () => { + const messageChannel = new MessageChannel() + const bobToAlice = createBirpc( + bobCollector.functions, + { + post: (msg) => { + messageChannel.port2.postMessage(msg) + }, + on: (cb) => { + messageChannel.port2.addEventListener('message', event => cb(event.data)) + }, + }, + ) + const aliceToBob = createBirpc( + aliceCollector.functions, + { + post: (msg) => { + messageChannel.port1.postMessage(msg) + }, + on: (cb) => { + messageChannel.port1.addEventListener('message', (event) => { + cb(event.data) + }) + }, + }, + ) + + it('register', async () => { + expect(bobCollector.functions).toMatchInlineSnapshot(` + { + "getMoney": Promise {}, + "takeMoney": Promise {}, + } + `) + expect(aliceCollector.functions).toMatchInlineSnapshot(` + { + "buyApples": Promise {}, + "getAppleCount": Promise {}, + "getBalance": Promise {}, + "hi": Promise {}, + } + `) + }) + + it('calling', async () => { + expect(await bobToAlice.hi()).toBe('hi') + + expect(await aliceToBob.getMoney()).toBe(50) + expect(await bobToAlice.getBalance()).toBe(101) + + expect(await bobToAlice.getAppleCount()).toBe(5) + + await bobToAlice.buyApples(3) + + expect(await bobToAlice.getAppleCount()).toBe(2) + expect(await bobToAlice.getBalance()).toBe(107) + }) + + it('error', async () => { + await expect(() => bobToAlice.buyApples(3)) + .rejects + .toThrowErrorMatchingInlineSnapshot(`[Error: Insufficient apples]`) + + // @ts-expect-error missing types + await expect(() => aliceToBob.foo()) + .rejects + .toThrowErrorMatchingInlineSnapshot(`[Error: [birpc] function "foo" not found]`) + }) + + it('direct calling', async () => { + expect(bobCollector.getHandler('getMoney')).toBeDefined() + expect( + await aliceCollector + .getHandler('getBalance') + .then(handler => handler()), + ) + .toBe(107) + }) + + it('get schema', async () => { + expect(aliceCollector.getSchema('getAppleCount')).toMatchInlineSnapshot(` + { + "args": undefined, + "returns": undefined, + } + `) + + expect(aliceCollector.getSchema('buyApples')).toMatchSnapshot() + }) + + it('throws type error when schema mismatch handler type', async () => { + defineRpcFunction({ + name: 'test', + args: [v.string()], + returns: v.void(), + // @ts-expect-error setup handler type mismatch + setup: () => { + return { + handler: (_count: number) => { }, + } + }, + }) + + defineRpcFunction({ + name: 'test', + args: [v.string()], + returns: v.void(), + // @ts-expect-error handler type mismatch + handler: (_count: number) => { }, + }) + }) +}) diff --git a/packages/rpc/test/collector/shared-types.ts b/packages/rpc/test/collector/shared-types.ts new file mode 100644 index 00000000..0e074d5d --- /dev/null +++ b/packages/rpc/test/collector/shared-types.ts @@ -0,0 +1,6 @@ +export interface AliceFunctions { + +} +export interface BobFunctions { + +} diff --git a/packages/rpc/tsdown.config.ts b/packages/rpc/tsdown.config.ts index 7792a188..4830f294 100644 --- a/packages/rpc/tsdown.config.ts +++ b/packages/rpc/tsdown.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from 'tsdown' export default defineConfig({ entry: { 'index': 'src/index.ts', + 'client': 'src/client.ts', + 'server': 'src/server.ts', 'presets/ws/client': 'src/presets/ws/client.ts', 'presets/ws/server': 'src/presets/ws/server.ts', 'presets/index': 'src/presets/index.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f77b8a6e..f48d2e60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,9 +46,6 @@ catalogs: birpc: specifier: ^4.0.0 version: 4.0.0 - birpc-x: - specifier: 0.0.9 - version: 0.0.9 cac: specifier: ^6.7.14 version: 6.7.14 @@ -115,6 +112,9 @@ catalogs: unstorage: specifier: ^1.17.4 version: 1.17.4 + valibot: + specifier: ^1.2.0 + version: 1.2.0 webext-bridge: specifier: ^6.0.1 version: 6.0.1 @@ -554,9 +554,6 @@ importers: birpc: specifier: catalog:deps version: 4.0.0 - birpc-x: - specifier: catalog:deps - version: 0.0.9(typescript@5.9.3) cac: specifier: catalog:deps version: 6.7.14 @@ -642,9 +639,6 @@ importers: birpc: specifier: catalog:deps version: 4.0.0 - birpc-x: - specifier: catalog:deps - version: 0.0.9(typescript@5.9.3) immer: specifier: catalog:deps version: 11.1.3 @@ -827,9 +821,18 @@ importers: birpc: specifier: catalog:deps version: 4.0.0 + ohash: + specifier: catalog:deps + version: 2.0.11 + p-limit: + specifier: catalog:deps + version: 7.3.0 structured-clone-es: specifier: catalog:deps version: 1.0.0 + valibot: + specifier: catalog:deps + version: 1.2.0(typescript@5.9.3) devDependencies: tsdown: specifier: catalog:build @@ -3671,9 +3674,6 @@ packages: bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - birpc-x@0.0.9: - resolution: {integrity: sha512-ngEZ5Eet2nI4YeAUGcZh/gncvuHjBmBZ1mvsb7N7uq8Ik2mPpYRkdwvZcXTECooqILX7k1YprZGg//ZRwBxw8g==} - birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} @@ -10631,16 +10631,6 @@ snapshots: dependencies: file-uri-to-path: 1.0.0 - birpc-x@0.0.9(typescript@5.9.3): - dependencies: - birpc: 4.0.0 - ohash: 2.0.11 - p-limit: 7.3.0 - structured-clone-es: 1.0.0 - valibot: 1.2.0(typescript@5.9.3) - transitivePeerDependencies: - - typescript - birpc@2.9.0: {} birpc@4.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 677229c8..c62d517c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -34,7 +34,6 @@ catalogs: '@rolldown/debug': ^1.0.0-rc.2 ansis: ^4.2.0 birpc: ^4.0.0 - birpc-x: 0.0.9 cac: ^6.7.14 diff: ^8.0.3 get-port-please: ^3.2.0 @@ -57,6 +56,7 @@ catalogs: tinyglobby: ^0.2.15 unconfig: ^7.4.2 unstorage: ^1.17.4 + valibot: ^1.2.0 webext-bridge: ^6.0.1 ws: ^8.19.0 devtools: diff --git a/test/exports/@vitejs/devtools-rpc.yaml b/test/exports/@vitejs/devtools-rpc.yaml index 06854876..841632fb 100644 --- a/test/exports/@vitejs/devtools-rpc.yaml +++ b/test/exports/@vitejs/devtools-rpc.yaml @@ -1,6 +1,17 @@ .: + createClientFromDump: function + createDefineWrapperWithContext: function + defineRpcFunction: function + dumpFunctions: function + getDefinitionsWithDumps: function + getRpcHandler: function + getRpcResolvedSetupResult: function + RpcCacheManager: function + RpcFunctionsCollectorBase: function + validateDefinition: function + validateDefinitions: function +./client: createRpcClient: function - createRpcServer: function ./presets: defineRpcClientPreset: function defineRpcServerPreset: function @@ -8,3 +19,5 @@ createWsRpcPreset: function ./presets/ws/server: createWsRpcPreset: function +./server: + createRpcServer: function diff --git a/tsconfig.base.json b/tsconfig.base.json index dcc496e4..4448572d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -18,6 +18,12 @@ "@vitejs/devtools-rpc/presets": [ "./packages/rpc/src/presets/index.ts" ], + "@vitejs/devtools-rpc/client": [ + "./packages/rpc/src/client.ts" + ], + "@vitejs/devtools-rpc/server": [ + "./packages/rpc/src/server.ts" + ], "@vitejs/devtools-rpc": [ "./packages/rpc/src" ],