项目目标
JSON-RPC 规范小而精确,非常适合用来讲解协议合规。本项目注册方法、分发单个与批量请求,并返回合规客户端可据以行动的错误,全部经由一个 HTTP 端点。分发器是纯函数并正确处理每条 id 规则。
核心特性
- 按名称注册方法并带参数校验
- 正确分发单个请求与批量数组
- 为解析、方法、参数错误返回规范错误码
- 对无 id 的通知不产生响应
架构与分层
注册表把方法名映射到带校验的处理器;分发器把请求对象转为响应对象;批量处理器对数组映射分发器并丢弃通知结果;HTTP 层解析请求体并约束 JSON-RPC 内容类型。分发器是纯函数,不持有传输关切。
实施阶段
六个阶段为:方法注册表、分发器、批处理、示例方法、HTTP 传输、分发与批量测试。通知规则在批处理层处理最为恰当。
测试与验收
用 vitest 运行。分发测试覆盖有效调用、未知方法、非法参数与抛异常的处理器;批处理测试断言调用与通知混合时只返回调用响应并保留 id;HTTP 测试提交批量并断言响应体与状态码。
延伸方向
逐方法模式、流式响应与方法鉴权都是注册表外围的增量。分发与传输无关,因此同一注册表可支撑 WebSocket 端点或 CLI。
本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。
代码示例
export function dispatch(registry, request) {
if (typeof request !== 'object' || request?.jsonrpc !== '2.0' || typeof request.method !== 'string') {
return errorResponse(request?.id ?? null, -32600, 'Invalid Request');
}
const handler = registry.get(request.method);
if (!handler) return errorResponse(request.id ?? null, -32601, 'Method not found');
const params = request.params ?? {};
const check = handler.validate ? handler.validate(params) : { ok: true };
if (!check.ok) return errorResponse(request.id ?? null, -32602, check.message);
return handler.invoke(params).then(
(result) => ({ jsonrpc: '2.0', id: request.id ?? null, result }),
(err) => errorResponse(request.id ?? null, -32000, err.message),
);
}
mkdir jsonrpc-server && cd jsonrpc-server
npm init -y
npm install -D vitest@^2.1.0
{
"name": "jsonrpc-server",
"type": "module",
"scripts": {
"start": "node src/server.js",
"test": "vitest run"
}
}
export function createRegistry() {
const methods = new Map();
return {
register(name, handler, validate) {
if (methods.has(name)) throw new Error('duplicate method: ' + name);
methods.set(name, { invoke: handler, validate });
return this;
},
get(name) { return methods.get(name); },
names() { return [...methods.keys()]; },
};
}
function errorResponse(id, code, message, data) {
return { jsonrpc: '2.0', id, error: { code, message, ...(data ? { data } : {}) } };
}
export async function dispatch(registry, request) {
if (typeof request !== 'object' || request?.jsonrpc !== '2.0' || typeof request.method !== 'string') {
return errorResponse(request?.id ?? null, -32600, 'Invalid Request');
}
const handler = registry.get(request.method);
if (!handler) return errorResponse(request.id ?? null, -32601, 'Method not found');
const params = request.params ?? {};
if (handler.validate) {
const check = handler.validate(params);
if (!check.ok) return errorResponse(request.id ?? null, -32602, 'Invalid params');
}
try {
return { jsonrpc: '2.0', id: request.id ?? null, result: await handler.invoke(params) };
} catch (err) {
return errorResponse(request.id ?? null, -32000, err.message);
}
}
import { dispatch } from './dispatcher.js';
export async function handleBatch(registry, payload) {
if (Array.isArray(payload)) {
if (payload.length === 0) return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' } };
const responses = await Promise.all(payload.map((req) => dispatch(registry, req)));
const kept = responses.filter((_r, i) => payload[i].id !== undefined && payload[i].id !== null);
return kept.length ? kept : null;
}
const response = await dispatch(registry, payload);
return payload.id === undefined ? null : response;
}
export function registerMethods(registry) {
registry.register('add', async ({ a, b }) => Number(a) + Number(b), (p) =>
typeof p.a === 'number' && typeof p.b === 'number' ? { ok: true } : { ok: false, message: 'numbers required' });
registry.register('echo', async (params) => params);
registry.register('fail', async () => { throw new Error('intentional failure'); });
return registry;
}
import { createServer } from 'node:http';
import { createRegistry } from './registry.js';
import { registerMethods } from './methods.js';
import { handleBatch } from './batch.js';
const registry = registerMethods(createRegistry());
async function readJson(req) {
let body = '';
for await (const chunk of req) body += chunk;
return body ? JSON.parse(body) : null;
}
const server = createServer(async (req, res) => {
if (req.method !== 'POST') { res.writeHead(405); return res.end(JSON.stringify({ error: 'method_not_allowed' })); }
let payload;
try { payload = await readJson(req); }
catch { res.writeHead(400, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })); }
const result = await handleBatch(registry, payload);
if (result === null) { res.writeHead(204); return res.end(); }
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(result));
});
server.listen(process.env.PORT ?? 3000);
export { server };
import { createRegistry } from '../src/registry.js';
import { handleBatch } from '../src/batch.js';
import { registerMethods } from '../src/methods.js';
const registry = registerMethods(createRegistry());
test('batch keeps responses for calls and drops notifications', async () => {
const payload = [
{ jsonrpc: '2.0', id: 1, method: 'add', params: { a: 1, b: 2 } },
{ jsonrpc: '2.0', method: 'echo', params: { hi: true } },
];
const result = await handleBatch(registry, payload);
expect(result).toEqual([{ jsonrpc: '2.0', id: 1, result: 3 }]);
});