A tool for processing JSON-RPC requests and responses.
yarn add @metamask/json-rpc-engine
or
npm install @metamask/json-rpc-engine
Tip
For the legacy JsonRpcEngine, see its readme.
For how to migrate from the legacy JsonRpcEngine to JsonRpcEngineV2, see Migrating from JsonRpcEngine.
import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2';
import type {
Json,
JsonRpcMiddleware,
MiddlewareContext,
} from '@metamask/json-rpc-engine/v2';
type Middleware = JsonRpcMiddleware<
JsonRpcRequest,
Json,
MiddlewareContext<{ hello: string }>
>;
// Engines are instantiated using the `create()` factory method as opposed to
// the constructor, which is private.
const engine = JsonRpcEngineV2.create<Middleware>({
middleware: [
({ request, next, context }) => {
if (request.method === 'hello') {
context.set('hello', 'world');
return next();
}
return null;
},
({ context }) => context.assertGet('hello'),
],
});Requests are handled asynchronously, stepping down the middleware stack until complete.
const request = { id: '1', jsonrpc: '2.0', method: 'hello' };
try {
const result = await engine.handle(request);
// Do something with the result
} catch (error) {
// Handle the error
}Alternatively, pass the engine to a JsonRpcServer, which coerces raw request
objects into well-formed requests, and handles error serialization:
const server = new JsonRpcServer({ engine, onError });
const request = { id: '1', jsonrpc: '2.0', method: 'hello' };
// server.handle() never throws
const response = await server.handle(request);
if ('result' in response) {
// Handle result
} else {
// Handle error
}
const notification = { jsonrpc: '2.0', method: 'hello' };
// Always returns undefined for notifications
await server.handle(notification);Use asLegacyMiddleware() to convert a JsonRpcEngineV2 or one or more V2 middleware into a legacy middleware.
In keeping with the conventions of the legacy engine, non-JSON-RPC string properties of the context will be
copied over to the request once the V2 engine is done with the request. Note that only string keys of
the context will be copied over.
import {
asLegacyMiddleware,
JsonRpcEngineV2,
} from '@metamask/json-rpc-engine/v2';
import { JsonRpcEngine } from '@metamask/json-rpc-engine';
const legacyEngine = new JsonRpcEngine();
const v2Engine = JsonRpcEngineV2.create({
middleware: [
// ...
],
});
legacyEngine.push(asLegacyMiddleware(v2Engine));import {
asLegacyMiddleware,
type JsonRpcMiddleware,
} from '@metamask/json-rpc-engine/v2';
import { JsonRpcEngine } from '@metamask/json-rpc-engine';
// Convert a single V2 middleware
const middleware1: JsonRpcMiddleware<JsonRpcRequest> = ({ request }) => {
/* ... */
};
const legacyEngine = new JsonRpcEngine();
legacyEngine.push(asLegacyMiddleware(middleware1));
// Convert multiple V2 middlewares at once
const middleware2: JsonRpcMiddleware<JsonRpcRequest> = ({ context, next }) => {
/* ... */
};
const legacyEngine2 = new JsonRpcEngine();
legacyEngine2.push(asLegacyMiddleware(middleware1, middleware2));Middleware functions can be sync or async.
They receive a MiddlewareParams object containing:
request- The JSON-RPC request or notification (readonly)
context- An append-only
Mapfor passing data between middleware
- An append-only
next- Function that calls the next middleware in the stack and returns its result (if any)
Here's a basic example:
const engine = JsonRpcEngineV2.create({
middleware: [
({ next, context }) => {
context.set('foo', 'bar');
// Proceed to the next middleware and return its result
return next();
},
async ({ request, context }) => {
await doSomething(request, context.get('foo'));
// Return a result wihout calling next() to end the request
return 42;
},
],
});In practice, middleware functions are often defined apart from the engine in which
they are used. Middleware defined in this manner must use the JsonRpcMiddleware type:
export const permissionMiddleware: JsonRpcMiddleware<
JsonRpcRequest,
Json, // The result
MiddlewareContext<{ user: User; permissions: Permissions }>
> = async ({ request, context, next }) => {
const user = context.assertGet('user');
const permissions = await getUserPermissions(user.id);
context.set('permissions', permissions);
return next();
};Middleware can specify a return type, however next() always returns the widest possible
type based on the type of the request. See Requests vs. notifications
for more details.
Creating a useful JsonRpcEngineV2 requires composing differently typed middleware together.
See Engine composition for how to
accomplish this in the same or a set of composed engines.
JSON-RPC requests come in two flavors:
- Requests, i.e. request objects with an
id - Notifications, i.e. request objects without an
id
next() returns Json for requests, void for notifications, and Json | void if the type of the request
object is not known.
For requests, one of the engine's middleware must "end" the request by returning a non-undefined result, or .handle()
will throw an error:
const engine = JsonRpcEngineV2.create({
middleware: [
() => {
if (Math.random() > 0.5) {
return 42;
}
return undefined;
},
],
});
const request = { jsonrpc: '2.0', id: '1', method: 'hello' };
try {
const result = await engine.handle(request);
console.log(result); // 42
} catch (error) {
console.error(error); // Nothing ended request: { ... }
}For notifications, on the other hand, one of the engine's middleware must return undefined to end the request,
and any non-undefined return values will cause an error to be thrown:
const notification = { jsonrpc: '2.0', method: 'hello' };
try {
const result = await engine.handle(notification);
console.log(result); // undefined
} catch (error) {
console.error(error); // Result returned for notification: { ... }
}If your middleware may be passed both requests and notifications,
use the isRequest or isNotification utilities to determine what to do:
Note
Middleware that handle both requests and notifications—i.e. the JsonRpcCall type—
must ensure that their return values are valid for incoming requests at runtime.
There is no compile time type error if such a middleware returns e.g. a string
for a notification.
import {
isRequest,
isNotification,
JsonRpcEngineV2,
} from '@metamask/json-rpc-engine/v2';
const engine = JsonRpcEngineV2.create({
middleware: [
async ({ request, next }) => {
if (isRequest(request) && request.method === 'everything') {
return 42;
}
return next();
},
({ request }) => {
if (isNotification(request)) {
console.log(`Received notification: ${request.method}`);
return undefined;
}
return null;
},
],
});The request object is immutable.
Attempting to directly modify it will throw an error.
Middleware can modify the method and params properties
by passing a new request object to next():
const engine = JsonRpcEngineV2.create({
middleware: [
({ request, next }) => {
// Modify the request for subsequent middleware
// The new request object will be deeply frozen
return next({
...request,
method: 'modified_method',
params: [1, 2, 3],
});
},
({ request }) => {
// This middleware receives the modified request
return request.params[0];
},
],
});Modifying the jsonrpc or id properties is not allowed, and will cause
an error:
const engine = JsonRpcEngineV2.create({
middleware: [
({ request, next }) => {
return next({
...request,
// Modifying either property will cause an error
jsonrpc: '3.0',
id: 'foo',
});
},
() => 42,
],
});
// Error: Middleware attempted to modify readonly property...
await engine.handle(anyRequest);Middleware can observe the result by awaiting next():
const engine = JsonRpcEngineV2.create({
middleware: [
async ({ request, next }) => {
const startTime = Date.now();
const result = await next();
const duration = Date.now() - startTime;
// Log the request duration
console.log(
`Request ${request.method} producing ${result} took ${duration}ms`,
);
// By returning `undefined`, the result will be forwarded unmodified to earlier
// middleware.
},
({ request }) => {
return 'Hello, World!';
},
],
});Like the request, the result is also immutable.
Middleware can update the result by returning a new one.
const engine = JsonRpcEngineV2.create({
middleware: [
async ({ request, next }) => {
const result = await next();
// Add metadata to the result
if (result && typeof result === 'object') {
// The new result will also be deeply frozen
return {
...result,
metadata: {
processedAt: new Date().toISOString(),
requestId: request.id,
},
};
}
// Returning the unmodified result is equivalent to returning `undefined`
return result;
},
({ request }) => {
// Initial result
return { message: 'Hello, World!' };
},
],
});
const result = await engine.handle({
id: '1',
jsonrpc: '2.0',
method: 'hello',
});
console.log(result);
// {
// message: 'Hello, World!',
// metadata: {
// processedAt: '2024-01-01T12:00:00.000Z',
// requestId: 1
// }
// }Use the context to share data between middleware:
const engine = JsonRpcEngineV2.create({
middleware: [
async ({ context, next }) => {
context.set('user', { id: '123', name: 'Alice' });
return next();
},
async ({ context, next }) => {
// context.assertGet() throws if the value does not exist
const user = context.assertGet('user') as { id: string; name: string };
context.set('permissions', await getUserPermissions(user.id));
return next();
},
({ context }) => {
const user = context.get('user');
const permissions = context.get('permissions');
return { user, permissions };
},
],
});The context supports PropertyKey keys, i.e. strings, numbers, and symbols.
To prevent accidental naming collisions, existing keys must be deleted before they can be
overwritten via set().
Context values are not frozen, and objects can be mutated as normal:
const engine = JsonRpcEngineV2.create({
middleware: [
async ({ context, next }) => {
context.set('user', { id: '123', name: 'Alice' });
return next();
},
async ({ context, next }) => {
const user = context.assertGet<{ id: string; name: string }>('user');
user.name = 'Bob';
return next();
},
// ...
],
});You can pass a MiddlewareContext instance directly to handle():
const context = new MiddlewareContext();
context.set('foo', 'bar');
const result = await engine.handle(
{ id: '1', jsonrpc: '2.0', method: 'hello' },
{ context },
);
console.log(result); // 'bar'You can also pass a plain object as a shorthand for a MiddlewareContext instance:
const context = { foo: 'bar' };
const result = await engine.handle(
{ id: '1', jsonrpc: '2.0', method: 'hello' },
{ context },
);
console.log(result); // 'bar'This works the same way for JsonRpcServer.handle().
The context exposes a generic parameter KeyValues, which determines the keys and values
a context instance supports:
const context = new MiddlewareContext();
context.set('foo', 'bar');
context.get('foo'); // 'bar'
context.get('fizz'); // undefinedBy default, KeyValues is Record<PropertyKey, unknown>. However, any object type can be
specified, effectively turning the context into a strongly typed Map:
const context = new MiddlewareContext<{ foo: string }>([['foo', 'bar']]);
context.get('foo'); // 'bar'
context.get('fizz'); // Type errorThe context is itself exposed as the third generic parameter of the JsonRpcMiddleware type.
See Instrumenting middleware pipelines for how to
compose different context types together.
Errors in middleware are propagated up the call stack:
const engine = JsonRpcEngineV2.create({
middleware: [
({ next }) => {
return next();
},
({ request, next }) => {
if (request.method === 'restricted') {
throw new Error('Method not allowed');
}
return 'Success';
},
],
});
try {
await engine.handle({ id: '1', jsonrpc: '2.0', method: 'restricted' });
} catch (error) {
console.error('Request failed:', error.message);
}If your middleware awaits next(), it can handle errors using try/catch:
const engine = JsonRpcEngineV2.create({
middleware: [
({ request, next }) => {
try {
return await next();
} catch (error) {
console.error(`Request ${request.method} errored:`, error);
return 42;
}
},
({ request }) => {
if (!isValid(request)) {
throw new Error('Invalid request');
}
},
],
});
const result = await engine.handle({
id: '1',
jsonrpc: '2.0',
method: 'hello',
});
console.log('Result:', result);
// Request hello errored: Error: Invalid request
// Result: 42The engine throws JsonRpcEngineError values when its invariants are violated, e.g. a middleware returns
a result value for a notification.
If you want to reliably detect these cases, use JsonRpcEngineError.isInstance(error), which works across
versions of this package in the same realm.
As discussed in the Middleware section, middleware are often defined apart from the engine in which they are used. To be used within the same engine, a set of middleware must have compatible types. Specifically, all middleware must:
- Handle either
JsonRpcRequest,JsonRpcNotification, or both (i.e.JsonRpcCall)- It is okay to mix
JsonRpcCallmiddleware with eitherJsonRpcRequestorJsonRpcNotificationmiddleware, as long as the latter two are not mixed together.
- It is okay to mix
- Return valid results for the overall request type
- Specify mutually inclusive context types
- The context types may be the same, partially intersecting, or completely disjoint so long as they are not mutually exclusive.
For example, the following middleware are compatible:
const middleware1: JsonRpcMiddleware<
JsonRpcRequest,
Json,
MiddlewareContext<{ foo: string }>
> = /* ... */;
const middleware2: JsonRpcMiddleware<
JsonRpcRequest,
Json,
MiddlewareContext<{ bar: string }>
> = /* ... */;
const middleware3: JsonRpcMiddleware<
JsonRpcRequest,
{ foo: string; bar: string },
MiddlewareContext<{ foo: string; bar: string; baz: number }>
> = /* ... */;
// ✅ OK
const engine = JsonRpcEngineV2.create<Middleware>({
middleware: [middleware1, middleware2, middleware3],
});The following middleware are incompatible due to mismatched request types:
Warning
Providing JsonRpcRequest- and JsonRpcNotification-only middleware to the same engine is
generally unsound and should be avoided. However, doing so will not cause a type error,
and it is the programmer's responsibility to prevent it from happening.
const middleware1: JsonRpcMiddleware<JsonRpcNotification> = /* ... */;
const middleware2: JsonRpcMiddleware<JsonRpcRequest> = /* ... */;
// ⚠️ Attempting to call engine.handle() will NOT cause a type error, but it
// may cause errors at runtime and should be avoided.
const engine = JsonRpcEngineV2.create<Middleware>({
middleware: [middleware1, middleware2],
});Finally, these middleware are incompatible due to mismatched context types:
const middleware1: JsonRpcMiddleware<
JsonRpcRequest,
Json,
MiddlewareContext<{ foo: string }>
> = /* ... */;
const middleware2: JsonRpcMiddleware<
JsonRpcRequest,
Json,
MiddlewareContext<{ foo: number }>
> = /* ... */;
// ❌ The type of the engine is `never`; accessing any property will cause a type error
const engine = JsonRpcEngineV2.create<Middleware>({
middleware: [middleware1, middleware2],
});Engines can be nested by converting them to middleware using asMiddleware():
const subEngine = JsonRpcEngineV2.create({
middleware: [
({ request }) => {
return 'Sub-engine result';
},
],
});
const mainEngine = JsonRpcEngineV2.create({
middleware: [
subEngine.asMiddleware(),
({ request, next }) => {
const subResult = await next();
return `Main engine processed: ${subResult}`;
},
],
});Engines used as middleware may return undefined for requests, but only when
used as middleware:
const loggingEngine = JsonRpcEngineV2.create({
middleware: [
({ request, next }) => {
console.log('Observed request:', request.method);
},
],
});
const mainEngine = JsonRpcEngineV2.create({
middleware: [
loggingEngine.asMiddleware(),
({ request }) => {
return 'success';
},
],
});
const request = { id: '1', jsonrpc: '2.0', method: 'hello' };
const result = await mainEngine.handle(request);
console.log('Result:', result);
// Observed request: hello
// Result: success
// ATTN: This will throw "Nothing ended request"
const result2 = await loggingEngine.handle(request);You can also compose different engines together by calling handle(request, context)
on a different engine in a middleware. Keep in mind that, unlike when using asMiddleware(),
these "sub"-engines must return results for requests.
This method of composition can be useful to instrument request- and notification-only middleware pipelines:
const requestEngine = JsonRpcEngineV2.create({
middleware: [
/* Request-only middleware */
],
});
const notificationEngine = JsonRpcEngineV2.create({
middleware: [
/* Notification-only middleware */
],
});
const orchestratorEngine = JsonRpcEngineV2.create({
middleware: [
({ request, context }) =>
isRequest(request)
? requestEngine.handle(request, { context })
: notificationEngine.handle(request as JsonRpcNotification, {
context,
}),
],
});The JsonRpcServer wraps a JsonRpcEngineV2 to provide JSON-RPC 2.0 compliance and error handling. It coerces raw request objects into well-formed requests and handles error serialization.
import { JsonRpcEngineV2, JsonRpcServer } from '@metamask/json-rpc-engine/v2';
const engine = new JsonRpcEngine({ middleware });
const server = new JsonRpcServer({
engine,
// onError receives the raw error, before it is coerced into a JSON-RPC error.
onError: (error) => console.error('Server error:', error),
});
// server.handle() never throws - all errors are handled by onError
const response = await server.handle({
id: '1',
jsonrpc: '2.0',
method: 'hello',
});
if ('result' in response) {
// Handle successful response
} else {
// Handle error response
}
// Notifications always return undefined
const notification = { jsonrpc: '2.0', method: 'hello' };
await server.handle(notification); // Returns undefinedThe server accepts any object with a method property, coercing it into a request or notification
depending on the presence or absence of the id property, respectively.
Except for the id, all present JSON-RPC 2.0 fields are validated for spec conformance.
The id is replaced during request processing with an internal, trusted value, although the
original id is attached to the response before it is returned.
Response objects are returned for requests, and contain
the result in case of success and error in case of failure.
undefined is always returned for notifications.
Errors thrown by the underlying engine are always passed to onError unmodified.
If the request is not a notification, the error is subsequently serialized and attached
to the response object via the error property.
Warning
It is possible to construct a JsonRpcServer the only accepts either requests or notifications,
but not both. If you do so, it is your responsibility to ensure that the server is only used with the
appropriate request objects. JsonRpcServer.handle() will not type error at compile time if you attempt to pass
it an unsupported request object.
Migrating from the legacy JsonRpcEngine to JsonRpcEngineV2 is generally straightforward.
For an example, see MetaMask/core#7065.
There are a couple of pitfalls to watch out for:
The legacy JsonRpcEngine allowed non-JSON-RPC string properties to be attached to the request object.
JsonRpcEngineV2 does not allow this, and instead you must use the context object to pass data between middleware.
While it's easy to migrate a middleware function body to use the context object, injected dependencies
of the middleware function may need to be updated.
For example if you have a legacy middleware implementation like this:
const createFooMiddleware =
(processFoo: (req: JsonRpcRequest) => string) => (req, res, next, end) => {
if (req.method === 'foo') {
const fooResult = processFoo(req); // May expect non-JSON-RPC properties on the request object!
res.result = fooResult;
end();
} else {
next();
}
};processFoo may expect non-JSON-RPC properties on the request object. To fully migrate the middleware, you need to
investigate the implementation of processFoo and potentially update it to accept a context object.
In the legacy JsonRpcEngine, request and response objects are mutable and shared between all middleware.
In JsonRpcEngineV2, response objects are not visible to middleware, and request objects are deeply frozen.
If injected dependencies mutate the request object, it will cause an error.
For example, if you have a legacy middleware implementation like this:
const createBarMiddleware =
(processBar: (req: JsonRpcRequest) => string) => (req, _res, next, _end) => {
if (req.method === 'bar') {
processBar(req); // May mutate the request object!
}
next();
};processBar may mutate the request object. To fully migrate the middleware, you need to
investigate the implementation of processBar and update it to not directly mutate the request object.
See Request modification for how to modify the request object in JsonRpcEngineV2.
This package is part of a monorepo. Instructions for contributing can be found in the monorepo README.