← 中文资源

简体中文版本

构建 JSON-RPC 2.0 服务端

基于 HTTP 的 JSON-RPC 2.0 服务:分发方法、支持批量请求、返回规范错误并按方法校验参数。

阅读英文原文

项目目标

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 }]);
});