danscan/zod-jsonrpc(新しいタブで開きます)
主要言語: TypeScript
Create type-safe JSON-RPC API clients and servers in JS/TS using any transport.
- star 数
- 23
- watcher 数
- 1
- fork 数
- 0
- issue 数
- 0
- 最終更新
- 2025/07/22
主要言語: TypeScript
Create type-safe JSON-RPC API clients and servers in JS/TS using any transport.
Create type-safe JSON-RPC API clients and servers in JS/TS using any transport.
@standard-schema/spec to support Zod, Effect Schema, Valibot, ArkType, and morezod-jsonrpc uses @standard-schema/spec for schema validation, supporting any library that implements the Standard Schema specification:
bun add @danscan/zod-jsonrpc yarn add @danscan/zod-jsonrpc npm add @danscan/zod-jsonrpc
zod-jsonrpc makes it easy to create type-safe JSON-RPC servers and clients.
import { createServer, createClient, method } from '@danscan/zod-jsonrpc'; import { z } from 'zod'; // Define methods const greet = method({ paramsSchema: z.object({ name: z.string() }), resultSchema: z.string(), }, ({ name }) => `Hello, ${name}!`); // Create server and client const server = createServer({ greet }); const client = createClient({ greet }, async (request) => { // Your transport layer here: fetch, WebSocket, etc. return server.request(request); }); // Make type-safe calls const greeting = await client.greet({ name: 'World' }); console.log(greeting); // "Hello, World!"
Methods are the core building blocks of your JSON-RPC API. They define the input and output schemas for a given method, and the handler function that will be called when the method is invoked.
const greet = method({ paramsSchema: z.object({ name: z.string() }), resultSchema: z.string(), }, ({ name }) => `Hello, ${name}!`);
You can also define methods with a paramsSchema and a resultSchema only, and provide a handler function later:
// Import this in your client and server export const greet = method({ paramsSchema: z.object({ name: z.string() }), resultSchema: z.string(), }); // In your server implementation: import { greet } from './methods'; const server = createServer({ greet: greet.implement(({ name }) => `Hello, ${name}!`), });
Start by creating a server with your methods:
import { createServer, method, JSONRPCError } from '@danscan/zod-jsonrpc'; import { z } from 'zod'; const server = createServer({ add: method({ paramsSchema: z.object({ a: z.number(), b: z.number() }), resultSchema: z.number(), }, ({ a, b }) => a + b), divide: method({ paramsSchema: z.object({ dividend: z.number(), divisor: z.number() }), resultSchema: z.number(), }, ({ dividend, divisor }) => { if (divisor === 0) { throw new JSONRPCError.InvalidParams({ message: 'Cannot divide by zero' }); } return dividend / divisor; }), });
The server automatically handles single requests, batch requests, and notifications:
// Single request const result = await server.request({ id: 1, method: 'add', params: { a: 5, b: 3 }, jsonrpc: '2.0', }); // { id: 1, result: 8, jsonrpc: '2.0' } // Batch request const results = await server.request([ { id: 1, method: 'add', params: { a: 5, b: 3 }, jsonrpc: '2.0' }, { id: 2, method: 'divide', params: { dividend: 10, divisor: 2 }, jsonrpc: '2.0' }, ]); // [{ id: 1, result: 8, jsonrpc: '2.0' }, { id: 2, result: 5, jsonrpc: '2.0' }]
Servers automatically convert any thrown error into proper JSON-RPC responses:
const server = createServer({ validateAge: method({ paramsSchema: z.object({ age: z.number() }), resultSchema: z.boolean(), }, ({ age }) => { if (age < 0) { // You can easily construct and throw a JSONRPC-specific error (ParseError, InvalidRequest, MethodNotFound, InvalidParams, InternalError) throw JSONRPCError.InvalidParams({ message: 'Age cannot be negative', data: { age } }); } if (age >= 150) { // Any other kind of thrown error becomes a JSONRPCError.InternalError throw new Error(`Please don't use Bryan Johnson's age`); } return true; }), });
Errors are handled individually in batch requests, so one failure doesn't affect others:
const results = await server.request([ { id: 1, method: 'validateAge', params: { age: 25 }, jsonrpc: '2.0' }, { id: 2, method: 'validateAge', params: { age: -5 }, jsonrpc: '2.0' }, { id: 3, method: 'validateAge', params: { age: 200 }, jsonrpc: '2.0' }, ]); // [ // { id: 1, result: true, jsonrpc: '2.0' }, // { id: 2, error: { code: -32602, message: 'Invalid params: Age cannot be negative' }, jsonrpc: '2.0' }, // { id: 3, error: { code: -32603, message: 'Internal error', data: { message: 'Please don\'t use Bryan Johnson\'s age' } }, jsonrpc: '2.0' } // ]
Build type-safe clients that validate requests and responses:
import { createClient, method } from '@danscan/zod-jsonrpc'; // Define your transport const sendRequest = async (request) => { const response = await fetch('/api/jsonrpc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), }); return response.json(); }; // Create client with method definitions const client = createClient({ add: method({ paramsSchema: z.object({ a: z.number(), b: z.number() }), resultSchema: z.number(), }), }, sendRequest); // Make calls with full type safety const sum = await client.add({ a: 5, b: 3 }); // number
Clients provide a convenient API for batch requests with named results:
const results = await client.batch((ctx) => ({ // Name each call in the batch so you can easily access the results by name // All calls are executed in parallel, so you can use the results as they come in sum: ctx.add({ a: 5, b: 3 }), product: ctx.multiply({ a: 5, b: 3 }), quotient: ctx.divide({ dividend: 10, divisor: 2 }), })); // Handle results with type-safe error checking if (results.sum.ok) { console.log('Sum:', results.sum.value); // number } else { console.error('Sum failed:', results.sum.error.message); } // Process all results Object.entries(results).forEach(([operation, result]) => { if (result.ok) { console.log(`${operation}:`, result.value); } else { console.error(`${operation} failed:`, result.error.message); } });
Schemas that change your data in any way should usually only be applied once. If you provide a transforming schema in a method on both your client and server, you should use a raw client to avoid double transformation.
// This method is provided to the client and server const normalize = method({ paramsSchema: z.string().transform(s => s.toUpperCase()), resultSchema: z.string().transform(s => `Result: ${s}`), }, (input) => input); const server = createServer({ normalize }); // If you create your client manually, you can use the `raw` method to create a raw client // This way, the schema transformation is only applied on the server const client = createClient({ normalize }, sendRequest); const rawClient = client.raw(); // When generating a client from a server, it will be a raw client by default const client = server.createClient(sendRequest);
If you are only using zod-jsonrpc for the client, your schema transformations will only be applied once, so you don't need to use a raw client.
const client = createClient({ normalize }, sendRequest); const result = await client.normalize('hello'); // "Result: HELLO"
Client has methods that return a new client with different validation modes:
client.raw(): Skip all schema parsing on the client, delegating to the serverclient.rawParams(): Skip only parameter validation on the clientclient.rawResults(): Skip only result validation on the clientclient.validating(): Re-enable all validation on the clientconst client = createClient({ add }, sendRequest); // Skip validation of both params and results const rawClient = client.raw(); const result = await rawClient.add({ a: 5, b: 3 }); // No client-side validation // Skip only params validation (results still validated) const rawParamsClient = client.rawParams(); // Skip only result validation (params still validated) const rawResultsClient = client.rawResults(); // Re-enable params and results validation const validatingClient = rawClient.validating(); // For example, to disable validation on one request: client.raw().add({ a: 5, b: 3 });
Integrate with any HTTP framework or transport:
const jsonRpcServer = createServer({ add, divide }); // Bun Bun.serve({ fetch: async (req) => { const request = await req.json(); const response = await jsonRpcServer.request(request); return Response.json(response); } }); // Next.js App Router export async function POST(request: Request) { const jsonRpcRequest = await request.json(); const jsonRpcResponse = await jsonRpcServer.request(jsonRpcRequest); return Response.json(jsonRpcResponse); } // Express.js app.post('/jsonrpc', async (req, res) => { const response = await jsonRpcServer.request(req.body); res.json(response); });
For larger applications, separate method definitions from implementations:
src/
api/
methods/ # Shared method definitions
user.ts
auth.ts
server.ts # Server implementation
client.ts # Client setup
Define methods separately to share between client and server:
// api/methods/user.ts export const getUser = method({ paramsSchema: z.object({ id: z.string().uuid() }), resultSchema: z.object({ id: z.string().uuid(), name: z.string(), email: z.string().email(), }), }); // api/server.ts import * as methods from './methods'; export const server = createServer({ getUser: methods.getUser.implement(async ({ id }) => { return await getUserFromDatabase(id); }), }); // api/client.ts import * as methods from './methods'; export const client = createClient({ getUser: methods.getUser, }, sendRequest);
This structure enables you to share method definitions, maintain type safety across your entire API, and version your API independently of implementation details.