Build a JSON-RPC 2.0 Server
A JSON-RPC 2.0 server over HTTP that dispatches methods, supports batch requests, returns spec-compliant errors, and validates parameters per method.
Project goal
Build a JSON-RPC 2.0 server that registers named methods, handles single and batch requests, returns spec-compliant success and error objects, and validates parameters before dispatch.
Final features
- Register methods by name with per-method parameter validation.
- Dispatch single requests and batch arrays with correct id handling.
- Return spec-compliant error codes for parse, method, and parameter errors.
- Omit responses for notifications that carry no id.
- Expose an HTTP endpoint that accepts JSON-RPC over POST.
Architecture
A registry maps method names to handlers with validators; a dispatcher turns a request object into a response object; a batch handler maps the dispatcher over an array and drops notification results; and the HTTP layer parses the body and enforces the JSON-RPC content type. The dispatcher is pure and holds no transport concerns.
Prerequisites
- Node.js 20 LTS or later
- Understanding of HTTP POST and JSON parsing
- Familiarity with the JSON-RPC 2.0 request shape
Dependencies
- node >=20 — HTTP server and JSON parsing
- vitest ^2.1.0 — Dispatcher and batch tests
Directory structure
jsonrpc-server/
├── src/
│ ├── registry.js
│ ├── dispatcher.js
│ ├── batch.js
│ ├── server.js
│ └── methods.js
├── test/
│ ├── dispatcher.test.js
│ └── batch.test.js
└── package.json
Phases
1. Method registry
Store methods by name with an optional validator and a handler that returns a promise.
Files: src/registry.js
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()]; },
};
}Expected behavior: Registering a method makes it retrievable, a duplicate name throws, and names lists every registered method.
2. Dispatcher
Turn a request into a response, handling invalid requests, unknown methods, parameter errors, and thrown errors.
Files: src/dispatcher.js
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);
}
}Expected behavior: A valid call returns a result with the same id, an unknown method returns -32601, and a thrown error returns -32000.
3. Batch handling
Map the dispatcher over a batch and drop responses for notifications that have no id.
Files: src/batch.js
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;
}Expected behavior: A batch of two calls and one notification returns two responses, a full-notification batch returns null, and an empty array returns an invalid request error.
4. Sample methods
Implement a couple of methods with validators to exercise the registry and error paths.
Files: src/methods.js
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;
}Expected behavior: add returns the sum for numeric params and invalid params otherwise, echo returns its input, and fail surfaces a -32000 error.
5. HTTP transport
Accept JSON-RPC over POST, parse the body, enforce the content type, and return the batch result.
Files: src/server.js
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 };Expected behavior: A POST with a valid request returns 200 and a JSON-RPC response, a malformed body returns -32700, and a notification-only batch returns 204.
6. Dispatcher and batch tests
Cover every error code and the notification rules, then fix anything the spec requires that the tests miss.
Files: test/batch.test.js
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 }]);
});Expected behavior: The batch suite passes, a notification produces no response, and an unknown method in a batch yields error -32601 with its id preserved.
Testing
Run npx vitest. Dispatcher tests cover a valid call, an unknown method, invalid params, and a handler that throws. Batch tests assert that a mix of calls and notifications returns only the call responses and preserves ids. An HTTP test posts a batch and asserts the JSON body and status code.
Troubleshooting
A notification gets a response anyway
Check for a missing id at the batch layer and drop the response; dispatch always returns a response object, so the notification rule belongs one level up.
Batch responses come back out of order
Preserve the request order in the response array even though calls run concurrently; clients match by id, but the spec expects positional correspondence with the requests.
A parse error returns a 200 with an empty body
Return -32700 with id null and a JSON content type; a parse error is still a JSON-RPC response, not an empty success.
Deployment
Mount the endpoint behind a reverse proxy and add authentication at the transport layer. The registry can be extended per deployment so the same server exposes different method sets for different callers.
Known limitations
- Transport-level authentication and rate limiting are out of scope and must be added in front.
- Streaming and server-pushed requests are not implemented; this server answers one-shot calls only.
Project overview
JSON-RPC has a small, precise specification, which makes it a perfect fit for a project that teaches protocol compliance. Every error code and every id rule is documented and testable.
The server registers methods, dispatches single and batch requests, and returns errors that a compliant client can act on, all behind one HTTP endpoint.
The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.
The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.
It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.
The dispatcher is a pure function from request to response, and it gets every id rule right because the transport is out of the picture:
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),
);
}
Notification handling only requires the batch layer to drop responses whose request had no id, which is why it belongs one level up.
Setup
Zero runtime dependencies keeps the protocol logic pure JavaScript that any reader can follow.
Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.
Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.
| Dependency | Version | Why it is here |
|---|---|---|
| node | >=20 | HTTP server and JSON parsing |
| vitest | ^2.1.0 | Dispatcher and batch tests |
Create the project and install:
mkdir jsonrpc-server && cd jsonrpc-server
npm init -y
npm install -D vitest@^2.1.0
Configure the server and tests in package.json:
{
"name": "jsonrpc-server",
"type": "module",
"scripts": {
"start": "node src/server.js",
"test": "vitest run"
}
}
Register sample methods in one module so the dispatcher tests can use the same registry the server loads.
Phase 1: Method registry
Store methods by name with an optional validator and a handler that returns a promise. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.
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()]; },
};
}
Acceptance for this phase: Registering a method makes it retrievable, a duplicate name throws, and names lists every registered method.
Phase 2: Dispatcher
Turn a request into a response, handling invalid requests, unknown methods, parameter errors, and thrown errors. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.
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);
}
}
Acceptance for this phase: A valid call returns a result with the same id, an unknown method returns -32601, and a thrown error returns -32000.
Phase 3: Batch handling
Map the dispatcher over a batch and drop responses for notifications that have no id. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.
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;
}
Acceptance for this phase: A batch of two calls and one notification returns two responses, a full-notification batch returns null, and an empty array returns an invalid request error.
Phase 4: Sample methods
Implement a couple of methods with validators to exercise the registry and error paths. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.
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;
}
Acceptance for this phase: add returns the sum for numeric params and invalid params otherwise, echo returns its input, and fail surfaces a -32000 error.
Phase 5: HTTP transport
Accept JSON-RPC over POST, parse the body, enforce the content type, and return the batch result. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.
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 };
Acceptance for this phase: A POST with a valid request returns 200 and a JSON-RPC response, a malformed body returns -32700, and a notification-only batch returns 204.
Phase 6: Dispatcher and batch tests
Cover every error code and the notification rules, then fix anything the spec requires that the tests miss. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.
The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.
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 }]);
});
Acceptance for this phase: The batch suite passes, a notification produces no response, and an unknown method in a batch yields error -32601 with its id preserved.
Design trade-offs
Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.
Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.
Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.
Testing strategy
Run npx vitest. Dispatcher tests cover a valid call, an unknown method, invalid params, and a handler that throws. Batch tests assert that a mix of calls and notifications returns only the call responses and preserves ids. An HTTP test posts a batch and asserts the JSON body and status code.
Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.
Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.
Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.
The notification test is the sharpest one in the spec: a notification must produce no response at all, and getting it wrong breaks clients that count replies.
Extending the project
Typed schemas per method, streaming responses, and method authorisation are additions around the registry. Because dispatch is transport-agnostic, the same registry can back a WebSocket endpoint or a CLI.
The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.