diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index a16cd0d14f..f5a52c63e6 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -287,12 +287,6 @@ "packages/gas-fee-controller/src/determineGasFeeCalculations.ts": { "jsdoc/tag-lines": 4 }, - "packages/json-rpc-engine/src/JsonRpcEngine.test.ts": { - "jest/no-conditional-in-test": 2 - }, - "packages/json-rpc-engine/src/JsonRpcEngine.ts": { - "@typescript-eslint/prefer-promise-reject-errors": 2 - }, "packages/json-rpc-middleware-stream/src/index.test.ts": { "@typescript-eslint/prefer-promise-reject-errors": 3, "no-empty-function": 1 diff --git a/packages/json-rpc-engine/CHANGELOG.md b/packages/json-rpc-engine/CHANGELOG.md index 0dcb5f6833..1eb794a0c9 100644 --- a/packages/json-rpc-engine/CHANGELOG.md +++ b/packages/json-rpc-engine/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `JsonRpcEngineV2` ([#6176](https://github.com/MetaMask/core/pull/6176)) + - This is a complete rewrite of `JsonRpcEngine`, intended to replace the original implementation. + See the readme for details. + ## [10.1.1] ### Changed @@ -20,6 +26,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) - Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) +### Deprecated + +- `JsonRpcEngine` and related types ([#6176](https://github.com/MetaMask/core/pull/6176)) + - To be replaced by `JsonRpcEngineV2`. + ## [10.0.3] ### Changed diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md index f4ce817972..1113ede22d 100644 --- a/packages/json-rpc-engine/README.md +++ b/packages/json-rpc-engine/README.md @@ -12,193 +12,716 @@ or ## Usage -```js -const { JsonRpcEngine } = require('@metamask/json-rpc-engine'); +> [!NOTE] +> For the legacy `JsonRpcEngine`, see [its readme](./src/README.md). + +```ts +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: [ + ({ 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 engine = new JsonRpcEngine(); +```ts +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 +} ``` -Build a stack of JSON-RPC processors by pushing middleware to the engine. +Alternatively, pass the engine to a `JsonRpcServer`, which coerces raw request +objects into well-formed requests, and handles error serialization: -```js -engine.push(function (req, res, next, end) { - res.result = 42; - end(); -}); +```ts +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); ``` -Requests are handled asynchronously, stepping down the stack until complete. +### Legacy compatibility + +Use the `asLegacyMiddleware` function to use a `JsonRpcEngineV2` as a +middleware in a legacy `JsonRpcEngine`: + +```ts +import { + asLegacyMiddleware, + JsonRpcEngineV2, +} from '@metamask/json-rpc-engine/v2'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; -```js -const request = { id: 1, jsonrpc: '2.0', method: 'hello' }; +const legacyEngine = new JsonRpcEngine(); -engine.handle(request, function (err, response) { - // Do something with response.result, or handle response.error +const v2Engine = JsonRpcEngineV2.create({ + middleware: [ + // ... + ], }); -// There is also a Promise signature -const response = await engine.handle(request); +legacyEngine.push(asLegacyMiddleware(v2Engine)); ``` -Middleware have direct access to the request and response objects. -They can let processing continue down the stack with `next()`, or complete the request with `end()`. +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._ + +### Middleware + +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 `Map` for passing data between middleware +- `next` + - Function that calls the next middleware in the stack and returns its result (if any) + +Here's a basic example: + +```ts +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: + +```ts +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](#requests-vs-notifications) +for more details. + +Creating a useful `JsonRpcEngineV2` requires composing differently typed middleware together. +See [Engine composition](#engine-composition) for how to +accomplish this in the same or a set of composed engines. + +### Requests vs. notifications + +JSON-RPC requests come in two flavors: + +- [Requests](https://www.jsonrpc.org/specification#request_object), i.e. request objects _with_ an `id` +- [Notifications](https://www.jsonrpc.org/specification#notification), i.e. request objects _without_ an `id` -```js -engine.push(function (req, res, next, end) { - if (req.skipCache) return next(); - res.result = getResultFromCache(req); - end(); +`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: + +```ts +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: { ... } +} ``` -By passing a _return handler_ to the `next` function, you can get a peek at the result before it returns. +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: + +```ts +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: { ... } +} +``` -```js -engine.push(function (req, res, next, end) { - next(function (cb) { - insertIntoCache(res, cb); - }); +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. + +```ts +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; + }, + ], }); ``` -If you specify a `notificationHandler` when constructing the engine, JSON-RPC notifications passed to `handle()` will be handed off directly to this function without touching the middleware stack: +### Request modification + +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()`: + +```ts +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]; + }, + ], +}); +``` -```js -const engine = new JsonRpcEngine({ notificationHandler }); +Modifying the `jsonrpc` or `id` properties is not allowed, and will cause +an error: + +```ts +const engine = JsonRpcEngineV2.create({ + middleware: [ + ({ request, next }) => { + return next({ + ...request, + // Modifying either property will cause an error + jsonrpc: '3.0', + id: 'foo', + }); + }, + () => 42, + ], +}); -// A notification is defined as a JSON-RPC request without an `id` property. -const notification = { jsonrpc: '2.0', method: 'hello' }; +// Error: Middleware attempted to modify readonly property... +await engine.handle(anyRequest); +``` -const response = await engine.handle(notification); -console.log(typeof response); // 'undefined' +### Result handling + +Middleware can observe the result by awaiting `next()`: + +```ts +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!'; + }, + ], +}); ``` -Engines can be nested by converting them to middleware using `JsonRpcEngine.asMiddleware()`: +Like the `request`, the `result` is also immutable. +Middleware can update the result by returning a new one. + +```ts +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!' }; + }, + ], +}); -```js -const engine = new JsonRpcEngine(); -const subengine = new JsonRpcEngine(); -engine.push(subengine.asMiddleware()); +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 +// } +// } ``` -### `async` Middleware +### The `MiddlewareContext` + +Use the `context` to share data between middleware: + +```ts +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 }; + }, + ], +}); +``` -If you require your middleware function to be `async`, use `createAsyncMiddleware`: +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: + +```ts +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(); + }, + // ... + ], +}); +``` -```js -const { createAsyncMiddleware } = require('@metamask/json-rpc-engine'); +#### Constraining context keys and values -let engine = new RpcEngine(); -engine.push( - createAsyncMiddleware(async (req, res, next) => { - res.result = 42; - next(); - }), -); +The context exposes a generic parameter `KeyValues`, which determines the keys and values +a context instance supports: + +```ts +const context = new MiddlewareContext(); +context.set('foo', 'bar'); +context.get('foo'); // 'bar' +context.get('fizz'); // undefined ``` -`async` middleware do not take an `end` callback. -Instead, the request ends if the middleware returns without calling `next()`: +By default, `KeyValues` is `Record`. However, any object type can be +specified, effectively turning the context into a strongly typed `Map`: -```js -engine.push( - createAsyncMiddleware(async (req, res, next) => { - res.result = 42; - /* The request will end when this returns */ - }), -); +```ts +const context = new MiddlewareContext<{ foo: string }>([['foo', 'bar']]); +context.get('foo'); // 'bar' +context.get('fizz'); // Type error ``` -The `next` callback of `async` middleware also don't take return handlers. -Instead, you can `await next()`. -When the execution of the middleware resumes, you can work with the response again. +The context is itself exposed as the third generic parameter of the `JsonRpcMiddleware` type. +See [Instrumenting middleware pipelines](#instrumenting-middleware-pipelines) for how to +compose different context types together. + +### Error handling + +Errors in middleware are propagated up the call stack: + +```ts +const engine = JsonRpcEngineV2.create({ + middleware: [ + ({ next }) => { + return next(); + }, + ({ request, next }) => { + if (request.method === 'restricted') { + throw new Error('Method not allowed'); + } + return 'Success'; + }, + ], +}); -```js -engine.push( - createAsyncMiddleware(async (req, res, next) => { - res.result = 42; - await next(); - /* Your return handler logic goes here */ - addToMetrics(res); - }), -); +try { + await engine.handle({ id: '1', jsonrpc: '2.0', method: 'restricted' }); +} catch (error) { + console.error('Request failed:', error.message); +} ``` -You can freely mix callback-based and `async` middleware: +If your middleware awaits `next()`, it can handle errors using `try`/`catch`: + +```ts +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'); + } + }, + ], +}); -```js -engine.push(function (req, res, next, end) { - if (!isCached(req)) { - return next((cb) => { - insertIntoCache(res, cb); - }); - } - res.result = getResultFromCache(req); - end(); +const result = await engine.handle({ + id: '1', + jsonrpc: '2.0', + method: 'hello', }); +console.log('Result:', result); +// Request hello errored: Error: Invalid request +// Result: 42 +``` -engine.push( - createAsyncMiddleware(async (req, res, next) => { - res.result = 42; - await next(); - addToMetrics(res); - }), -); +#### Internal errors + +The 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. + +### Engine composition + +#### Instrumenting middleware pipelines + +As discussed in the [Middleware](#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 `JsonRpcCall` middleware with either `JsonRpcRequest` or `JsonRpcNotification` + middleware, as long as the latter two are not mixed together. +- 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: + +```ts +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: [middleware1, middleware2, middleware3], +}); ``` -### Teardown +The following middleware are incompatible due to mismatched request types: -If your middleware has teardown to perform, you can assign a method `destroy()` to your middleware function(s), -and calling `JsonRpcEngine.destroy()` will call this method on each middleware that has it. -A destroyed engine can no longer be used. +> [!WARNING] +> Providing `JsonRpcRequest`- and `JsonRpcNotification`-only middleware to the same engine is +> 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. -```js -const middleware = (req, res, next, end) => { - /* do something */ -}; -middleware.destroy = () => { - /* perform teardown */ -}; +```ts +const middleware1: JsonRpcMiddleware = /* ... */; + +const middleware2: JsonRpcMiddleware = /* ... */; + +// ⚠️ 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: [middleware1, middleware2], +}); +``` + +Finally, these middleware are incompatible due to mismatched context types: + +```ts +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: [middleware1, middleware2], +}); +``` + +#### `asMiddleware()` + +Engines can be nested by converting them to middleware using `asMiddleware()`: + +```ts +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 engine = new JsonRpcEngine(); -engine.push(middleware); +```ts +const loggingEngine = JsonRpcEngineV2.create({ + middleware: [ + ({ request, next }) => { + console.log('Observed request:', request.method); + }, + ], +}); -/* perform work */ +const mainEngine = JsonRpcEngineV2.create({ + middleware: [ + loggingEngine.asMiddleware(), + ({ request }) => { + return 'success'; + }, + ], +}); -// This will call middleware.destroy() and destroy the engine itself. -engine.destroy(); +const request = { id: '1', jsonrpc: '2.0', method: 'hello' }; +const result = await mainEngine.handle(request); +console.log('Result:', result); +// Observed request: hello +// Result: success -// Calling any public method on the middleware other than `destroy()` itself -// will throw an error. -engine.handle(req); +// ATTN: This will throw "Nothing ended request" +const result2 = await loggingEngine.handle(request); ``` -### Gotchas +#### Calling `handle()` in a middleware + +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. -Handle errors via `end(err)`, _NOT_ `next(err)`. +This method of composition can be useful to instrument request- and notification-only +middleware pipelines: + +```ts +const requestEngine = JsonRpcEngineV2.create({ + middleware: [ + /* Request-only middleware */ + ], +}); -```js -/* INCORRECT */ -engine.push(function (req, res, next, end) { - next(new Error()); +const notificationEngine = JsonRpcEngineV2.create({ + middleware: [ + /* Notification-only middleware */ + ], }); -/* CORRECT */ -engine.push(function (req, res, next, end) { - end(new Error()); +const orchestratorEngine = JsonRpcEngineV2.create({ + middleware: [ + ({ request, context }) => + isRequest(request) + ? requestEngine.handle(request, { context }) + : notificationEngine.handle(request as JsonRpcNotification, { + context, + }), + ], }); ``` -However, `next()` will detect errors on the response object, and cause -`end(res.error)` to be called. +### `JsonRpcServer` -```js -engine.push(function (req, res, next, end) { - res.error = new Error(); - next(); /* This will cause end(res.error) to be called. */ +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. + +```ts +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 undefined ``` +The 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. + ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index 088e5396a5..114fd7c838 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -27,6 +27,16 @@ "default": "./dist/index.cjs" } }, + "./v2": { + "import": { + "types": "./dist/v2/index.d.mts", + "default": "./dist/v2/index.mjs" + }, + "require": { + "types": "./dist/v2/index.d.cts", + "default": "./dist/v2/index.cjs" + } + }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", @@ -35,7 +45,8 @@ "test": "test" }, "files": [ - "dist/" + "dist/", + "v2.js" ], "scripts": { "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", @@ -58,7 +69,10 @@ "dependencies": { "@metamask/rpc-errors": "^7.0.2", "@metamask/safe-event-emitter": "^3.0.0", - "@metamask/utils": "^11.8.1" + "@metamask/utils": "^11.8.1", + "@types/deep-freeze-strict": "^1.1.0", + "deep-freeze-strict": "^1.1.1", + "klona": "^2.0.6" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", diff --git a/packages/json-rpc-engine/src/JsonRpcEngine.test.ts b/packages/json-rpc-engine/src/JsonRpcEngine.test.ts index 83dd990bef..ff0f0cc845 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngine.test.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngine.test.ts @@ -428,6 +428,8 @@ describe('JsonRpcEngine', () => { const engine = new JsonRpcEngine(); engine.push(function (request, response, _next, end) { + // Separate handling for the 4th request. + // eslint-disable-next-line jest/no-conditional-in-test if (request.id === 4) { delete response.result; response.error = rpcErrors.internal({ message: 'foobar' }); @@ -465,6 +467,8 @@ describe('JsonRpcEngine', () => { const engine = new JsonRpcEngine(); engine.push(function (request, response, _next, end) { + // Separate handling for the 4th request. + // eslint-disable-next-line jest/no-conditional-in-test if (request.id === 4) { delete response.result; response.error = rpcErrors.internal({ message: 'foobar' }); diff --git a/packages/json-rpc-engine/src/JsonRpcEngine.ts b/packages/json-rpc-engine/src/JsonRpcEngine.ts index 3bd6e8b076..2375211cf9 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngine.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngine.ts @@ -15,18 +15,32 @@ import { isJsonRpcRequest, } from '@metamask/utils'; +import { stringify } from './v2/utils'; + export type JsonRpcEngineCallbackError = Error | SerializedJsonRpcError | null; +/** + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + */ export type JsonRpcEngineReturnHandler = ( done: (error?: unknown) => void, ) => void; +/** + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + */ export type JsonRpcEngineNextCallback = ( returnHandlerCallback?: JsonRpcEngineReturnHandler, ) => void; +/** + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + */ export type JsonRpcEngineEndCallback = (error?: unknown) => void; +/** + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + */ export type JsonRpcMiddleware< Params extends JsonRpcParams, Result extends Json, @@ -43,6 +57,9 @@ export type JsonRpcMiddleware< const DESTROYED_ERROR_MESSAGE = 'This engine is destroyed and can no longer be used.'; +/** + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + */ export type JsonRpcNotificationHandler = ( notification: JsonRpcNotification, ) => void | Promise; @@ -62,7 +79,10 @@ type JsonRpcEngineArgs = { /** * A JSON-RPC request and response processor. + * * Give it a stack of middleware, pass it requests, and get back responses. + * + * @deprecated Use `JsonRpcEngineV2` instead. */ export class JsonRpcEngine extends SafeEventEmitter { /** @@ -352,7 +372,7 @@ export class JsonRpcEngine extends SafeEventEmitter { */ // This function is used in tests, so we cannot easily change it to use the // hash syntax. - // eslint-disable-next-line no-restricted-syntax + private async _promiseHandle( request: JsonRpcRequest | JsonRpcNotification, ): Promise { @@ -361,6 +381,8 @@ export class JsonRpcEngine extends SafeEventEmitter { // For notifications, the response will be `undefined`, and any caught // errors are unexpected and should be surfaced to the caller. if (error && res === undefined) { + // We are not going to change this behavior. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors reject(error); } else { // Excepting notifications, there will always be a response, and it will @@ -570,7 +592,7 @@ export class JsonRpcEngine extends SafeEventEmitter { new JsonRpcError( errorCodes.rpc.internal, `JsonRpcEngine: "next" return handlers must be functions. ` + - `Received "${typeof returnHandler}" for request:\n${jsonify( + `Received "${typeof returnHandler}" for request:\n${stringify( request, )}`, { request }, @@ -604,6 +626,8 @@ export class JsonRpcEngine extends SafeEventEmitter { ): Promise { for (const handler of handlers) { await new Promise((resolve, reject) => { + // We are not going to change this behavior. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors handler((error) => (error ? reject(error) : resolve())); }); } @@ -626,7 +650,7 @@ export class JsonRpcEngine extends SafeEventEmitter { if (!hasProperty(response, 'result') && !hasProperty(response, 'error')) { throw new JsonRpcError( errorCodes.rpc.internal, - `JsonRpcEngine: Response has no error or result for request:\n${jsonify( + `JsonRpcEngine: Response has no error or result for request:\n${stringify( request, )}`, { request }, @@ -636,19 +660,9 @@ export class JsonRpcEngine extends SafeEventEmitter { if (!isComplete) { throw new JsonRpcError( errorCodes.rpc.internal, - `JsonRpcEngine: Nothing ended request:\n${jsonify(request)}`, + `JsonRpcEngine: Nothing ended request:\n${stringify(request)}`, { request }, ); } } } - -/** - * JSON-stringifies a request object. - * - * @param request - The request object to JSON-stringify. - * @returns The JSON-stringified request object. - */ -function jsonify(request: JsonRpcRequest): string { - return JSON.stringify(request, null, 2); -} diff --git a/packages/json-rpc-engine/src/README.md b/packages/json-rpc-engine/src/README.md new file mode 100644 index 0000000000..a83af82fbc --- /dev/null +++ b/packages/json-rpc-engine/src/README.md @@ -0,0 +1,217 @@ +# `JsonRpcEngine` (deprecated) + +The deprecated, original `JsonRpcEngine` implementation. + +To be removed once the rest of MetaMask's codebase has been migrated to `JsonRpcEngineV2`. + +## Usage + +```js +const { JsonRpcEngine } = require('@metamask/json-rpc-engine'); + +const engine = new JsonRpcEngine(); +``` + +Build a stack of JSON-RPC processors by pushing middleware to the engine. + +```js +engine.push(function (req, res, next, end) { + res.result = 42; + end(); +}); +``` + +### V2 compatibility + +Use the `asV2Middleware` function to use a `JsonRpcEngine` as a middleware in a +`JsonRpcEngineV2`: + +```ts +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import { asV2Middleware, JsonRpcEngine } from '@metamask/json-rpc-engine'; + +const legacyEngine = new JsonRpcEngine(); +legacyEngine.push(/* ... */); + +const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(legacyEngine)], +}); +``` + +Non-JSON-RPC string properties on the request object will be copied over to the V2 engine's `context` object +once the legacy engine is done with the request, _unless_ they already exist on the `context`, in which case +they will be ignored. + +### Middleware + +Requests are handled asynchronously, stepping down the stack until complete. + +```js +const request = { id: 1, jsonrpc: '2.0', method: 'hello' }; + +engine.handle(request, function (err, response) { + // Do something with response.result, or handle response.error +}); + +// There is also a Promise signature +const response = await engine.handle(request); +``` + +Middleware have direct access to the request and response objects. +They can let processing continue down the stack with `next()`, or complete the request with `end()`. + +```js +engine.push(function (req, res, next, end) { + if (req.skipCache) return next(); + res.result = getResultFromCache(req); + end(); +}); +``` + +By passing a _return handler_ to the `next` function, you can get a peek at the result before it returns. + +```js +engine.push(function (req, res, next, end) { + next(function (cb) { + insertIntoCache(res, cb); + }); +}); +``` + +If you specify a `notificationHandler` when constructing the engine, JSON-RPC notifications passed to `handle()` will be handed off directly to this function without touching the middleware stack: + +```js +const engine = new JsonRpcEngine({ notificationHandler }); + +// A notification is defined as a JSON-RPC request without an `id` property. +const notification = { jsonrpc: '2.0', method: 'hello' }; + +const response = await engine.handle(notification); +console.log(typeof response); // 'undefined' +``` + +Engines can be nested by converting them to middleware using `JsonRpcEngine.asMiddleware()`: + +```js +const engine = new JsonRpcEngine(); +const subengine = new JsonRpcEngine(); +engine.push(subengine.asMiddleware()); +``` + +### `async` Middleware + +If you require your middleware function to be `async`, use `createAsyncMiddleware`: + +```js +const { createAsyncMiddleware } = require('@metamask/json-rpc-engine'); + +let engine = new RpcEngine(); +engine.push( + createAsyncMiddleware(async (req, res, next) => { + res.result = 42; + next(); + }), +); +``` + +`async` middleware do not take an `end` callback. +Instead, the request ends if the middleware returns without calling `next()`: + +```js +engine.push( + createAsyncMiddleware(async (req, res, next) => { + res.result = 42; + /* The request will end when this returns */ + }), +); +``` + +The `next` callback of `async` middleware also don't take return handlers. +Instead, you can `await next()`. +When the execution of the middleware resumes, you can work with the response again. + +```js +engine.push( + createAsyncMiddleware(async (req, res, next) => { + res.result = 42; + await next(); + /* Your return handler logic goes here */ + addToMetrics(res); + }), +); +``` + +You can freely mix callback-based and `async` middleware: + +```js +engine.push(function (req, res, next, end) { + if (!isCached(req)) { + return next((cb) => { + insertIntoCache(res, cb); + }); + } + res.result = getResultFromCache(req); + end(); +}); + +engine.push( + createAsyncMiddleware(async (req, res, next) => { + res.result = 42; + await next(); + addToMetrics(res); + }), +); +``` + +### Teardown + +If your middleware has teardown to perform, you can assign a method `destroy()` to your middleware function(s), +and calling `JsonRpcEngine.destroy()` will call this method on each middleware that has it. +A destroyed engine can no longer be used. + +```js +const middleware = (req, res, next, end) => { + /* do something */ +}; +middleware.destroy = () => { + /* perform teardown */ +}; + +const engine = new JsonRpcEngine(); +engine.push(middleware); + +/* perform work */ + +// This will call middleware.destroy() and destroy the engine itself. +engine.destroy(); + +// Calling any public method on the middleware other than `destroy()` itself +// will throw an error. +engine.handle(req); +``` + +### Gotchas + +Handle errors via `end(err)`, _NOT_ `next(err)`. + +```js +/* INCORRECT */ +engine.push(function (req, res, next, end) { + next(new Error()); +}); + +/* CORRECT */ +engine.push(function (req, res, next, end) { + end(new Error()); +}); +``` + +However, `next()` will detect errors on the response object, and cause +`end(res.error)` to be called. + +```js +engine.push(function (req, res, next, end) { + res.error = new Error(); + next(); /* This will cause end(res.error) to be called. */ +}); +``` diff --git a/packages/json-rpc-engine/src/asV2Middleware.test.ts b/packages/json-rpc-engine/src/asV2Middleware.test.ts new file mode 100644 index 0000000000..81d06190e9 --- /dev/null +++ b/packages/json-rpc-engine/src/asV2Middleware.test.ts @@ -0,0 +1,120 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import { JsonRpcEngine } from '.'; +import { asV2Middleware } from './asV2Middleware'; +import { JsonRpcEngineV2 } from './v2/JsonRpcEngineV2'; +import type { JsonRpcMiddleware as V2Middleware } from './v2/JsonRpcEngineV2'; +import type { MiddlewareContext } from './v2/MiddlewareContext'; +import { + getExtraneousKeys, + makeNullMiddleware, + makeRequest, +} from '../tests/utils'; + +describe('asV2Middleware', () => { + it('converts a legacy engine to a v2 middleware', () => { + const engine = new JsonRpcEngine(); + const middleware = asV2Middleware(engine); + expect(typeof middleware).toBe('function'); + }); + + it('forwards a result to the v2 engine', async () => { + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push((_req, res, _next, end) => { + res.result = null; + end(); + }); + + const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(legacyEngine)], + }); + + const result = await v2Engine.handle(makeRequest()); + expect(result).toBeNull(); + }); + + it('forwards an error to the v2 engine', async () => { + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push((_req, res, _next, end) => { + res.error = rpcErrors.internal('test'); + end(); + }); + + const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(legacyEngine)], + }); + + await expect(v2Engine.handle(makeRequest())).rejects.toThrow( + rpcErrors.internal('test'), + ); + }); + + it('forwards a serialized error to the v2 engine', async () => { + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push((_req, res, _next, end) => { + res.error = { message: 'test', code: 1000 }; + end(); + }); + + const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(legacyEngine)], + }); + + await expect(v2Engine.handle(makeRequest())).rejects.toThrow( + new Error('test'), + ); + }); + + it('allows the v2 engine to continue when not ending the request', async () => { + const legacyEngine = new JsonRpcEngine(); + const legacyMiddleware = jest.fn((_req, _res, next) => { + next(); + }); + legacyEngine.push(legacyMiddleware); + + const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(legacyEngine), makeNullMiddleware()], + }); + + const result = await v2Engine.handle(makeRequest()); + expect(result).toBeNull(); + expect(legacyMiddleware).toHaveBeenCalledTimes(1); + }); + + it('propagates the context to the legacy request and back', async () => { + const observedContextValues: number[] = []; + + const legacyEngine = new JsonRpcEngine(); + const legacyMiddleware = jest.fn((req, _res, next) => { + observedContextValues.push(req.value); + + expect(getExtraneousKeys(req)).toStrictEqual(['value']); + + req.newValue = 2; + next(); + }); + legacyEngine.push(legacyMiddleware); + + type Context = MiddlewareContext>; + const middleware1: V2Middleware = ({ + context, + next, + }) => { + context.set('value', 1); + return next(); + }; + const middleware2: V2Middleware = ({ + context, + }) => { + observedContextValues.push(context.assertGet('newValue')); + return null; + }; + const v2Engine = JsonRpcEngineV2.create({ + middleware: [middleware1, asV2Middleware(legacyEngine), middleware2], + }); + + await v2Engine.handle(makeRequest()); + expect(observedContextValues).toStrictEqual([1, 2]); + }); +}); diff --git a/packages/json-rpc-engine/src/asV2Middleware.ts b/packages/json-rpc-engine/src/asV2Middleware.ts new file mode 100644 index 0000000000..31d514643c --- /dev/null +++ b/packages/json-rpc-engine/src/asV2Middleware.ts @@ -0,0 +1,76 @@ +import { serializeError } from '@metamask/rpc-errors'; +import type { JsonRpcFailure, JsonRpcResponse } from '@metamask/utils'; +import { + hasProperty, + type JsonRpcParams, + type JsonRpcRequest, +} from '@metamask/utils'; + +import type { + JsonRpcEngine, + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, +} from './JsonRpcEngine'; +import { + deepClone, + fromLegacyRequest, + propagateToContext, + propagateToRequest, + unserializeError, +} from './v2/compatibility-utils'; +import type { + // JsonRpcEngineV2 is used in docs. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + JsonRpcEngineV2, + JsonRpcMiddleware, + ResultConstraint, +} from './v2/JsonRpcEngineV2'; + +/** + * Convert a legacy {@link JsonRpcEngine} into a {@link JsonRpcEngineV2} middleware. + * + * @param engine - The legacy engine to convert. + * @returns The {@link JsonRpcEngineV2} middleware. + */ +export function asV2Middleware< + Params extends JsonRpcParams, + Request extends JsonRpcRequest, +>(engine: JsonRpcEngine): JsonRpcMiddleware { + const middleware = engine.asMiddleware(); + return async ({ request, context, next }) => { + const req = deepClone(request) as JsonRpcRequest; + propagateToRequest(req, context); + + const response = await new Promise((resolve) => { + // The result or error property will be set by the legacy engine + // middleware. + const res = { + jsonrpc: '2.0' as const, + id: req.id, + } as JsonRpcResponse; + + const end: JsonRpcEngineEndCallback = (error) => { + if (error !== undefined) { + (res as JsonRpcFailure).error = serializeError(error); + } + resolve(res); + }; + + // We know from the implementation of JsonRpcEngine.asMiddleware() that + // legacyNext will always be passed a callback, so cb can never be + // undefined. + const legacyNext = ((cb: JsonRpcEngineEndCallback) => + cb(end)) as JsonRpcEngineNextCallback; + + middleware(req, res, legacyNext, end); + }); + propagateToContext(req, context); + + if (hasProperty(response, 'error')) { + throw unserializeError(response.error); + } else if (hasProperty(response, 'result')) { + return response.result as ResultConstraint; + } + return next(fromLegacyRequest(req as Request)); + }; +} diff --git a/packages/json-rpc-engine/src/createAsyncMiddleware.test.ts b/packages/json-rpc-engine/src/createAsyncMiddleware.test.ts index 1cd50f2b5b..491fe23879 100644 --- a/packages/json-rpc-engine/src/createAsyncMiddleware.test.ts +++ b/packages/json-rpc-engine/src/createAsyncMiddleware.test.ts @@ -37,7 +37,7 @@ describe('createAsyncMiddleware', () => { await next(); expect(response.result).toBe(1234); // override value - response.result = 42; // eslint-disable-line require-atomic-updates + response.result = 42; }), ); diff --git a/packages/json-rpc-engine/src/createAsyncMiddleware.ts b/packages/json-rpc-engine/src/createAsyncMiddleware.ts index 5ec2b53541..5ca43da4af 100644 --- a/packages/json-rpc-engine/src/createAsyncMiddleware.ts +++ b/packages/json-rpc-engine/src/createAsyncMiddleware.ts @@ -40,6 +40,7 @@ type ReturnHandlerCallback = Parameters[0]; * The return handler will always be called. Its resolution of the promise * enables the control flow described above. * + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. * @param asyncMiddleware - The asynchronous middleware function to wrap. * @returns The wrapped asynchronous middleware function, ready to be consumed * by JsonRpcEngine. diff --git a/packages/json-rpc-engine/src/createScaffoldMiddleware.ts b/packages/json-rpc-engine/src/createScaffoldMiddleware.ts index 04c2a90d58..eac2a66667 100644 --- a/packages/json-rpc-engine/src/createScaffoldMiddleware.ts +++ b/packages/json-rpc-engine/src/createScaffoldMiddleware.ts @@ -13,6 +13,7 @@ type ScaffoldMiddlewareHandler< * object is requested, this middleware will pass it to the corresponding * handler and return the result. * + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. * @param handlers - The RPC method handler functions. * @returns The scaffold middleware function. */ diff --git a/packages/json-rpc-engine/src/idRemapMiddleware.ts b/packages/json-rpc-engine/src/idRemapMiddleware.ts index ebf176f62e..6ec22b81d9 100644 --- a/packages/json-rpc-engine/src/idRemapMiddleware.ts +++ b/packages/json-rpc-engine/src/idRemapMiddleware.ts @@ -10,6 +10,7 @@ import type { JsonRpcMiddleware } from './JsonRpcEngine'; * * If used, should be the first middleware in the stack. * + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. * @returns The ID remap middleware function. */ export function createIdRemapMiddleware(): JsonRpcMiddleware< diff --git a/packages/json-rpc-engine/src/index.test.ts b/packages/json-rpc-engine/src/index.test.ts index 2c72ee9dd4..69151ddb72 100644 --- a/packages/json-rpc-engine/src/index.test.ts +++ b/packages/json-rpc-engine/src/index.test.ts @@ -4,6 +4,7 @@ describe('@metamask/json-rpc-engine', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` Array [ + "asV2Middleware", "createAsyncMiddleware", "createScaffoldMiddleware", "getUniqueId", diff --git a/packages/json-rpc-engine/src/index.ts b/packages/json-rpc-engine/src/index.ts index 14fb822b6b..57e69b8d09 100644 --- a/packages/json-rpc-engine/src/index.ts +++ b/packages/json-rpc-engine/src/index.ts @@ -1,3 +1,4 @@ +export { asV2Middleware } from './asV2Middleware'; export type { AsyncJsonRpcEngineNextCallback, AsyncJsonrpcMiddleware, diff --git a/packages/json-rpc-engine/src/mergeMiddleware.ts b/packages/json-rpc-engine/src/mergeMiddleware.ts index ba4efbe0b1..ab39e5b293 100644 --- a/packages/json-rpc-engine/src/mergeMiddleware.ts +++ b/packages/json-rpc-engine/src/mergeMiddleware.ts @@ -6,6 +6,7 @@ import { JsonRpcEngine } from './JsonRpcEngine'; /** * Takes a stack of middleware and joins them into a single middleware function. * + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. * @param middlewareStack - The middleware stack to merge. * @returns The merged middleware function. */ diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts new file mode 100644 index 0000000000..45e30a6afb --- /dev/null +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts @@ -0,0 +1,1337 @@ +/* eslint-disable n/callback-return */ // next() is not a Node.js callback. +import type { Json, JsonRpcId } from '@metamask/utils'; +import { createDeferredPromise } from '@metamask/utils'; + +import type { JsonRpcMiddleware, ResultConstraint } from './JsonRpcEngineV2'; +import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; +import type { EmptyContext } from './MiddlewareContext'; +import { MiddlewareContext } from './MiddlewareContext'; +import { + isRequest, + JsonRpcEngineError, + stringify, + type JsonRpcCall, + type JsonRpcNotification, + type JsonRpcRequest, +} from './utils'; +import { + makeNotification, + makeNotificationMiddleware, + makeNullMiddleware, + makeRequest, + makeRequestMiddleware, +} from '../../tests/utils'; + +const jsonrpc = '2.0' as const; + +describe('JsonRpcEngineV2', () => { + describe('create', () => { + it('throws if the middleware array is empty', () => { + expect(() => JsonRpcEngineV2.create({ middleware: [] })).toThrow( + new JsonRpcEngineError('Middleware array cannot be empty'), + ); + }); + + it('type errors if passed middleware with incompatible context types', async () => { + const middleware1: JsonRpcMiddleware< + JsonRpcCall, + ResultConstraint, + MiddlewareContext<{ foo: string }> + > = ({ next, context }) => { + context.set('foo', 'bar'); + return next(); + }; + + const middleware2: JsonRpcMiddleware< + JsonRpcCall, + ResultConstraint, + MiddlewareContext<{ foo: number }> + > = ({ context }) => context.assertGet('foo'); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2], + }); + + // @ts-expect-error - The engine is `InvalidEngine`. + expect(await engine.handle(makeRequest())).toBe('bar'); + }); + + // Keeping this here for documentation purposes. + // eslint-disable-next-line jest/no-disabled-tests + it.skip('type errors if passed middleware with incompatible request types', async () => { + const middleware1: JsonRpcMiddleware = ({ next }) => + next(); + const middleware2: JsonRpcMiddleware = () => { + return 'foo'; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2], + }); + + // TODO: We want this to cause a type error, but it's unclear if it can be + // made to work due to the difficulty (impossibility?) of distinguishing + // between these two cases: + // - JsonRpcMiddleware | JsonRpcMiddleware (invalid) + // - JsonRpcMiddleware | JsonRpcMiddleware (valid) + expect(await engine.handle(makeRequest() as JsonRpcRequest)).toBe('foo'); + }); + }); + + describe('handle', () => { + describe('notifications', () => { + it('passes the notification through a middleware', async () => { + const middleware: JsonRpcMiddleware = jest.fn(); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await engine.handle(notification); + + expect(middleware).toHaveBeenCalledTimes(1); + expect(middleware).toHaveBeenCalledWith({ + request: notification, + context: expect.any(Map), + next: expect.any(Function), + }); + }); + + it('returns no result', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [jest.fn()], + }); + const notification = { jsonrpc, method: 'test_request' }; + + const result = await engine.handle(notification); + + expect(result).toBeUndefined(); + }); + + it('returns no result, with multiple middleware', async () => { + const middleware1 = jest.fn(({ next }) => next()); + const middleware2 = jest.fn(); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2], + }); + const notification = { jsonrpc, method: 'test_request' }; + + const result = await engine.handle(notification); + + expect(result).toBeUndefined(); + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); + }); + + it('throws if a middleware throws', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(() => { + throw new Error('test'); + }), + ], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new Error('test'), + ); + }); + + it('throws if a middleware throws, with multiple middleware', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(({ next }) => next()), + jest.fn(() => { + throw new Error('test'); + }), + ], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new Error('test'), + ); + }); + + it('throws if a result is returned, from the first middleware', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [() => 'foo'], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new JsonRpcEngineError( + `Result returned for notification: ${stringify(notification)}`, + ), + ); + }); + + it('throws if a result is returned, from a later middleware', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ next }) => { + await next(); + return undefined; + }, + makeNullMiddleware(), + ], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new JsonRpcEngineError( + `Result returned for notification: ${stringify(notification)}`, + ), + ); + }); + + it('throws if a middleware calls next() multiple times', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(async ({ next }) => { + await next(); + await next(); + }), + jest.fn(), + ], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new JsonRpcEngineError( + `Middleware attempted to call next() multiple times for request: ${stringify(notification)}`, + ), + ); + }); + }); + + describe('requests', () => { + it('returns a result from the middleware', async () => { + const middleware = jest.fn(() => null); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware], + }); + const request = makeRequest(); + + const result = await engine.handle(request); + + expect(result).toBeNull(); + expect(middleware).toHaveBeenCalledTimes(1); + expect(middleware).toHaveBeenCalledWith({ + request, + context: expect.any(Map), + next: expect.any(Function), + }); + }); + + it('returns a result from the middleware, with multiple middleware', async () => { + const middleware1: JsonRpcMiddleware = jest.fn(({ next }) => next()); + const middleware2: JsonRpcMiddleware = jest.fn(() => null); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2], + }); + const request = makeRequest(); + + const result = await engine.handle(request); + + expect(result).toBeNull(); + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware1).toHaveBeenCalledWith({ + request, + context: expect.any(Map), + next: expect.any(Function), + }); + expect(middleware2).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledWith({ + request, + context: expect.any(Map), + next: expect.any(Function), + }); + }); + + it('throws if a middleware throws', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(() => { + throw new Error('test'); + }), + ], + }); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new Error('test'), + ); + }); + + it('throws if a middleware throws, with multiple middleware', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(({ next }) => next()), + jest.fn(() => { + throw new Error('test'); + }), + ], + }); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new Error('test'), + ); + }); + + it('throws if no middleware returns a result', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [jest.fn(({ next }) => next()), jest.fn()], + }); + const request = makeRequest(); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new JsonRpcEngineError( + `Nothing ended request: ${stringify(request)}`, + ), + ); + }); + + it('throws if a middleware calls next() multiple times', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(async ({ next }) => { + await next(); + await next(); + }), + makeNullMiddleware(), + ], + }); + const request = makeRequest(); + + await expect(engine.handle(request)).rejects.toThrow( + new JsonRpcEngineError( + `Middleware attempted to call next() multiple times for request: ${stringify(request)}`, + ), + ); + }); + }); + + describe('context', () => { + it('passes the context to the middleware', async () => { + const engine = JsonRpcEngineV2.create< + JsonRpcMiddleware + >({ + middleware: [ + ({ context }) => { + expect(context).toBeInstanceOf(Map); + return null; + }, + ], + }); + + await engine.handle(makeRequest()); + }); + + it('propagates context changes to subsequent middleware', async () => { + type Context = MiddlewareContext<{ foo: string }>; + const middleware1: JsonRpcMiddleware< + JsonRpcCall, + Json | void, + Context + > = async ({ context, next }) => { + context.set('foo', 'bar'); + return next(); + }; + const middleware2: JsonRpcMiddleware< + JsonRpcCall, + string | undefined, + Context + > = ({ context }) => { + return context.get('foo') as string | undefined; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBe('bar'); + }); + + it('accepts an initial context', async () => { + const initialContext = new MiddlewareContext>(); + initialContext.set('foo', 'bar'); + const middleware: JsonRpcMiddleware< + JsonRpcRequest, + string, + MiddlewareContext> + > = ({ context }) => context.assertGet('foo'); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware], + }); + + const result = await engine.handle(makeRequest(), { + context: initialContext, + }); + + expect(result).toBe('bar'); + }); + + it('accepts middleware with different context types', async () => { + const middleware1: JsonRpcMiddleware< + JsonRpcCall, + ResultConstraint, + MiddlewareContext<{ foo: string }> + > = ({ context, next }) => { + context.set('foo', 'bar'); + return next(); + }; + + const middleware2: JsonRpcMiddleware< + JsonRpcCall, + ResultConstraint + > = ({ next }) => next(); + + const middleware3: JsonRpcMiddleware< + JsonRpcCall, + ResultConstraint, + EmptyContext + > = ({ next }) => next(); + + const middleware4: JsonRpcMiddleware< + JsonRpcCall, + string, + MiddlewareContext<{ foo: string; bar: number }> + > = ({ context }) => context.assertGet('foo'); + + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2, middleware3, middleware4], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBe('bar'); + }); + + it('throws if a middleware attempts to modify properties of the context', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + ({ context }) => { + // @ts-expect-error - Destructive testing. + context.set = () => undefined; + }, + ], + }); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new TypeError(`Cannot add property set, object is not extensible`), + ); + }); + }); + + describe('asynchrony', () => { + it('handles asynchronous middleware', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [async () => null], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBeNull(); + }); + + it('handles mixed synchronous and asynchronous middleware', async () => { + type Middleware = JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext> + >; + + const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ context, next }) => { + context.set('foo', [1]); + return next(); + }, + ({ context, next }) => { + const nums = context.assertGet('foo'); + nums.push(2); + return next(); + }, + async ({ context }) => { + const nums = context.assertGet('foo'); + return [...nums, 3]; + }, + ], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toStrictEqual([1, 2, 3]); + }); + }); + + describe('request mutation', () => { + it('propagates new requests to subsequent middleware', async () => { + const observedParams: number[] = []; + let observedMethod: string | undefined; + const middleware1 = jest.fn(({ request, next }) => { + observedParams.push(request.params[0]); + return next({ + ...request, + params: [2], + }); + }); + const middleware2 = jest.fn(({ request, next }) => { + observedParams.push(request.params[0]); + return next({ + ...request, + method: 'test_request_2', + params: [3], + }); + }); + const middleware3 = jest.fn(({ request }) => { + observedParams.push(request.params[0]); + observedMethod = request.method; + return null; + }); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2, middleware3], + }); + const request = makeRequest({ params: [1] }); + + await engine.handle(request); + + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); + expect(middleware3).toHaveBeenCalledTimes(1); + expect(observedMethod).toBe('test_request_2'); + expect(observedParams).toStrictEqual([1, 2, 3]); + }); + + it('throws if directly modifying the request', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(({ request }) => { + // @ts-expect-error - Destructive testing. + request.params = [2]; + }) as JsonRpcMiddleware, + ], + }); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new TypeError( + `Cannot assign to read only property 'params' of object '#'`, + ), + ); + }); + + it('throws if a middleware attempts to modify the request "id" property', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(async ({ request, next }) => { + return await next({ + ...request, + id: '2', + }); + }), + makeNullMiddleware(), + ], + }); + const request = makeRequest(); + + await expect(engine.handle(request)).rejects.toThrow( + new JsonRpcEngineError( + `Middleware attempted to modify readonly property "id" for request: ${stringify(request)}`, + ), + ); + }); + + it('throws if a middleware attempts to modify the request "jsonrpc" property', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ request, next }) => { + return await next({ + ...request, + // @ts-expect-error - Destructive testing. + jsonrpc: '3.0', + }); + }, + makeNullMiddleware(), + ], + }); + const request = makeRequest(); + + await expect(engine.handle(request)).rejects.toThrow( + new JsonRpcEngineError( + `Middleware attempted to modify readonly property "jsonrpc" for request: ${stringify(request)}`, + ), + ); + }); + }); + + describe('result handling', () => { + it('updates the result after next() is called', async () => { + const engine = JsonRpcEngineV2.create< + JsonRpcMiddleware + >({ + middleware: [ + async ({ next }) => { + const result = (await next()) as number; + return result + 1; + }, + () => 1, + ], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBe(2); + }); + + it('updates an undefined result with a new value', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ next }) => { + await next(); + return null; + }, + makeNotificationMiddleware(), + ], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBeNull(); + }); + + it('returning undefined propagates previously defined result', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(async ({ next }) => { + await next(); + }), + makeNullMiddleware(), + ], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBeNull(); + }); + + it('catches errors thrown by later middleware', async () => { + let observedError: Error | undefined; + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(async ({ next }) => { + try { + return await next(); + } catch (error) { + observedError = error as Error; + return null; + } + }), + jest.fn(() => { + throw new Error('test'); + }), + ], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBeNull(); + expect(observedError).toStrictEqual(new Error('test')); + }); + + it('handles returned results in reverse middleware order', async () => { + const returnHandlerResults: number[] = []; + const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ next }) => { + await next(); + returnHandlerResults.push(1); + }, + async ({ next }) => { + await next(); + returnHandlerResults.push(2); + }, + async ({ next }) => { + await next(); + returnHandlerResults.push(3); + }, + makeNullMiddleware(), + ], + }); + + await engine.handle(makeRequest()); + + expect(returnHandlerResults).toStrictEqual([3, 2, 1]); + }); + + it('throws if directly modifying the result', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ next }) => { + const result = (await next()) as { foo: string }; + result.foo = 'baz'; + return result; + }, + () => ({ foo: 'bar' }), + ], + }); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new TypeError( + `Cannot assign to read only property 'foo' of object '#'`, + ), + ); + }); + }); + + describe('parallel requests', () => { + /** + * A "counter" latch that releases when a target count is reached. + * + * @param target - The target count to reach. + * @returns A counter latch. + */ + const makeCounterLatch = (target: number) => { + let count = 0; + const { promise: countdownPromise, resolve: release } = + createDeferredPromise(); + + return { + increment: () => { + count += 1; + if (count === target) { + release(); + } + }, + waitAll: () => countdownPromise, + }; + }; + + /** + * A queue for processing a target number of requests in arbitrary order. + * + * @param size - The size of the queue. + * @returns An "arbitrary" queue. + */ + const makeArbitraryQueue = (size: number) => { + let count = 0; + const queue: { resolve: () => void }[] = new Array(size); + const { promise: gate, resolve: openGate } = createDeferredPromise(); + + const enqueue = async (id: number): Promise => { + const { promise, resolve } = createDeferredPromise(); + queue[id] = { resolve }; + count += 1; + + if (count === size) { + openGate(); + } + return gate.then(() => promise); + }; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const dequeue = (id: number): void => queue[id]!.resolve(); + return { enqueue, dequeue, filled: () => gate }; + }; + + it('processes requests in parallel with isolated contexts', async () => { + const N = 32; + const { promise: gate, resolve: openGate } = createDeferredPromise(); + const latch = makeCounterLatch(N); + + let inFlight = 0; + let maxInFlight = 0; + + type Context = MiddlewareContext<{ id: JsonRpcId }>; + const inflightMiddleware: JsonRpcMiddleware< + JsonRpcRequest, + Json, + Context + > = async ({ context, next, request }) => { + // eslint-disable-next-line jest/no-conditional-in-test + context.set('id', context.get('id') ?? request.id); + + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + latch.increment(); + + await gate; + + inFlight -= 1; + return next(); + }; + const resultMiddleware: JsonRpcMiddleware< + JsonRpcRequest, + string, + Context + > = ({ context, request }) => { + return `result:${request.id}:${context.assertGet('id')}`; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [inflightMiddleware, resultMiddleware], + }); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Jest blows up here, but there's no error at dev time. + const requests: JsonRpcRequest[] = Array.from({ length: N }, (_, i) => + makeRequest({ + id: `${i}`, + }), + ); + + const resultPromises = requests.map((request) => + engine.handle(request), + ); + + await latch.waitAll(); + expect(inFlight).toBe(N); + openGate(); + + const results = await Promise.all(resultPromises); + expect(results).toStrictEqual( + requests.map((request) => `result:${request.id}:${request.id}`), + ); + expect(inFlight).toBe(0); + expect(maxInFlight).toBe(N); + }); + + it('eagerly processes requests in parallel, i.e. without queueing them', async () => { + const queue = makeArbitraryQueue(3); + const middleware: JsonRpcMiddleware< + JsonRpcRequest & { id: number } + > = async ({ request }) => { + await queue.enqueue(request.id); + return null; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [middleware], + }); + + const p0 = engine.handle(makeRequest({ id: 0 })); + const p1 = engine.handle(makeRequest({ id: 1 })); + const p2 = engine.handle(makeRequest({ id: 2 })); + + await queue.filled(); + + queue.dequeue(2); + expect(await p2).toBeNull(); + queue.dequeue(0); + expect(await p0).toBeNull(); + queue.dequeue(1); + expect(await p1).toBeNull(); + }); + }); + }); + + describe('composition', () => { + describe('asMiddleware', () => { + it('ends a request if it returns a value', async () => { + const engine1 = JsonRpcEngineV2.create({ + middleware: [makeNullMiddleware()], + }); + const engine2 = JsonRpcEngineV2.create({ + middleware: [engine1.asMiddleware(), jest.fn(() => 'foo')], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBeNull(); + }); + + it('permits returning undefined if a later middleware ends the request', async () => { + const engine1 = JsonRpcEngineV2.create({ + middleware: [makeNotificationMiddleware()], + }); + const engine2 = JsonRpcEngineV2.create({ + middleware: [engine1.asMiddleware(), makeNullMiddleware()], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBeNull(); + }); + + it('composes nested engines', async () => { + const middleware1 = jest.fn(async ({ next }) => next()); + const middleware2 = jest.fn(async ({ next }) => next()); + const engine1 = JsonRpcEngineV2.create({ + middleware: [middleware1], + }); + const engine2 = JsonRpcEngineV2.create({ + middleware: [engine1.asMiddleware(), middleware2], + }); + const engine3 = JsonRpcEngineV2.create({ + middleware: [engine2.asMiddleware(), () => null], + }); + + const result = await engine3.handle(makeRequest()); + + expect(result).toBeNull(); + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); + }); + + it('propagates request mutation', async () => { + const engine1 = JsonRpcEngineV2.create({ + middleware: [ + ({ request, next }) => { + return next({ + ...request, + params: [2], + }); + }, + ({ request, next }) => { + return next({ + ...request, + method: 'test_request_2', + params: [(request.params as [number])[0] * 2], + }); + }, + ], + }); + + let observedMethod: string | undefined; + const engine2 = JsonRpcEngineV2.create({ + middleware: [ + engine1.asMiddleware(), + ({ request }) => { + observedMethod = request.method; + return (request.params as [number])[0] * 2; + }, + ], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBe(8); + expect(observedMethod).toBe('test_request_2'); + }); + + it('propagates context changes', async () => { + const engine1 = JsonRpcEngineV2.create({ + middleware: [ + async ({ context, next }) => { + const nums = context.assertGet('foo') as [number]; + nums[0] *= 2; + return next(); + }, + ], + }); + + const engine2 = JsonRpcEngineV2.create({ + middleware: [ + async ({ context, next }) => { + context.set('foo', [2]); + return next(); + }, + engine1.asMiddleware(), + async ({ context }) => { + const nums = context.assertGet('foo') as [number]; + return nums[0] * 2; + }, + ], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBe(8); + }); + + it('observes results in expected order', async () => { + const returnHandlerResults: string[] = []; + const engine1 = JsonRpcEngineV2.create({ + middleware: [ + async ({ next }) => { + await next(); + returnHandlerResults.push('1:a'); + }, + async ({ next }) => { + await next(); + returnHandlerResults.push('1:b'); + }, + ], + }); + + const engine2 = JsonRpcEngineV2.create({ + middleware: [ + engine1.asMiddleware(), + async ({ next }) => { + await next(); + returnHandlerResults.push('2:a'); + }, + async ({ next }) => { + await next(); + returnHandlerResults.push('2:b'); + }, + () => null, + ], + }); + + await engine2.handle(makeRequest()); + + // Order of result handling is reversed _within_ engines, but not + // _between_ engines. + expect(returnHandlerResults).toStrictEqual([ + '1:b', + '1:a', + '2:b', + '2:a', + ]); + }); + }); + + describe('middleware with engine.handle()', () => { + it('composes nested engines', async () => { + const earlierMiddleware = jest.fn(async ({ next }) => next()); + + const engine1Middleware: JsonRpcMiddleware = () => null; + const engine1 = JsonRpcEngineV2.create({ + middleware: [engine1Middleware], + }); + + const engine1ProxyMiddleware: JsonRpcMiddleware< + JsonRpcRequest + > = async ({ request }) => { + return engine1.handle(request); + }; + const laterMiddleware: JsonRpcMiddleware = jest.fn( + () => 'foo', + ); + const engine2 = JsonRpcEngineV2.create({ + middleware: [ + earlierMiddleware, + engine1ProxyMiddleware, + laterMiddleware, + ], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBeNull(); + expect(earlierMiddleware).toHaveBeenCalledTimes(1); + expect(laterMiddleware).not.toHaveBeenCalled(); + }); + + it('does not propagate request mutation', async () => { + // Unlike asMiddleware(), although the inner engine mutates request, + // those mutations do not propagate when using engine.handle(). + const engine1 = JsonRpcEngineV2.create({ + middleware: [ + ({ request, next }) => { + return next({ + ...request, + params: [2], + }); + }, + ({ request, next }) => { + return next({ + ...request, + method: 'test_request_2', + params: [(request.params as [number])[0] * 2], + }); + }, + makeNullMiddleware(), + ], + }); + + let observedMethod: string | undefined; + const observedMethodMiddleware: JsonRpcMiddleware< + JsonRpcRequest, + number + > = ({ request }) => { + observedMethod = request.method; + return (request.params as [number])[0] * 2; + }; + const engine2 = JsonRpcEngineV2.create({ + middleware: [ + async ({ request, next, context }) => { + await engine1.handle(request, { context }); + return next(); + }, + observedMethodMiddleware, + ], + }); + + const result = await engine2.handle(makeRequest({ params: [1] })); + + // Since inner-engine mutations do not affect the outer request, + // the outer middleware sees the original method and params. + expect(result).toBe(2); + expect(observedMethod).toBe('test_request'); + }); + + it('propagates context changes', async () => { + const engine1 = JsonRpcEngineV2.create({ + middleware: [ + async ({ context }) => { + const nums = context.assertGet('foo') as [number]; + nums[0] *= 2; + return null; + }, + ], + }); + + const engine2 = JsonRpcEngineV2.create({ + middleware: [ + async ({ context, next }) => { + context.set('foo', [2]); + return next(); + }, + async ({ request, next, context }) => { + await engine1.handle(request, { context }); + return next(); + }, + async ({ context }) => { + const nums = context.assertGet('foo') as [number]; + return nums[0] * 2; + }, + ], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBe(8); + }); + + it('observes results in expected order', async () => { + const returnHandlerResults: string[] = []; + const engine1 = JsonRpcEngineV2.create({ + middleware: [ + async ({ next }) => { + await next(); + returnHandlerResults.push('1:a'); + }, + async ({ next }) => { + await next(); + returnHandlerResults.push('1:b'); + }, + makeNullMiddleware(), + ], + }); + + const engine2 = JsonRpcEngineV2.create({ + middleware: [ + async ({ request, next, context }) => { + await engine1.handle(request as JsonRpcRequest, { context }); + return next(); + }, + async ({ next }) => { + await next(); + returnHandlerResults.push('2:a'); + }, + async ({ next }) => { + await next(); + returnHandlerResults.push('2:b'); + }, + makeNullMiddleware(), + ], + }); + + await engine2.handle(makeRequest()); + + // Inner engine return handlers run before outer engine return handlers + // since engine1.handle() completes before engine2 continues. + expect(returnHandlerResults).toStrictEqual([ + '1:b', + '1:a', + '2:b', + '2:a', + ]); + }); + + it('throws if the inner engine throws', async () => { + const engine1 = JsonRpcEngineV2.create({ + middleware: [ + () => { + throw new Error('test'); + }, + ], + }); + + const engine2 = JsonRpcEngineV2.create({ + middleware: [ + async ({ request }) => { + await engine1.handle(request as JsonRpcRequest); + return null; + }, + ], + }); + + await expect(engine2.handle(makeRequest())).rejects.toThrow( + new Error('test'), + ); + }); + }); + + describe('request- and notification-only engines', () => { + it('constructs a request-only engine', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [makeRequestMiddleware()], + }); + + expect(await engine.handle(makeRequest())).toBeNull(); + // @ts-expect-error - Valid at runtime, but should cause a type error + expect(await engine.handle(makeRequest() as JsonRpcCall)).toBeNull(); + // @ts-expect-error - Invalid at runtime and should cause a type error + await expect(engine.handle(makeNotification())).rejects.toThrow( + new JsonRpcEngineError( + `Result returned for notification: ${stringify(makeNotification())}`, + ), + ); + }); + + it('constructs a notification-only engine', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [makeNotificationMiddleware()], + }); + + expect(await engine.handle(makeNotification())).toBeUndefined(); + await expect( + // @ts-expect-error - Invalid at runtime and should cause a type error + engine.handle({ id: '1', jsonrpc, method: 'test_request' }), + ).rejects.toThrow( + new JsonRpcEngineError( + `Nothing ended request: ${stringify({ id: '1', jsonrpc, method: 'test_request' })}`, + ), + ); + await expect( + // @ts-expect-error - Invalid at runtime and should cause a type error + engine.handle(makeRequest() as JsonRpcRequest), + ).rejects.toThrow( + new JsonRpcEngineError( + `Nothing ended request: ${stringify(makeRequest())}`, + ), + ); + }); + + it('constructs a mixed engine', async () => { + const mixedMiddleware: JsonRpcMiddleware = ({ request }) => { + // eslint-disable-next-line jest/no-conditional-in-test + return isRequest(request) ? null : undefined; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [mixedMiddleware], + }); + + expect(await engine.handle(makeRequest())).toBeNull(); + expect(await engine.handle(makeNotification())).toBeUndefined(); + expect(await engine.handle(makeRequest() as JsonRpcCall)).toBeNull(); + }); + + it('composes a pipeline of request- and notification-only engines', async () => { + const requestEngine = JsonRpcEngineV2.create({ + middleware: [makeRequestMiddleware()], + }); + + const notificationEngine = JsonRpcEngineV2.create({ + middleware: [makeNotificationMiddleware()], + }); + + const orchestratorEngine = JsonRpcEngineV2.create({ + middleware: [ + ({ request, context }) => + // eslint-disable-next-line jest/no-conditional-in-test + isRequest(request) + ? requestEngine.handle(request, { context }) + : notificationEngine.handle(request as JsonRpcNotification, { + context, + }), + ], + }); + + const result1 = await orchestratorEngine.handle(makeRequest()); + const result2 = await orchestratorEngine.handle(makeNotification()); + + expect(result1).toBeNull(); + expect(result2).toBeUndefined(); + }); + }); + }); + + describe('destroy', () => { + it('calls the destroy method of any middleware that has one', async () => { + const middleware = { + destroy: jest.fn(), + }; + const engine = JsonRpcEngineV2.create({ + middleware: [middleware as unknown as JsonRpcMiddleware], + }); + + await engine.destroy(); + + expect(middleware.destroy).toHaveBeenCalledTimes(1); + }); + + it('is idempotent', async () => { + const middleware = { + destroy: jest.fn(), + }; + + const engine = JsonRpcEngineV2.create({ + middleware: [middleware as unknown as JsonRpcMiddleware], + }); + + await engine.destroy(); + await engine.destroy(); + + expect(middleware.destroy).toHaveBeenCalledTimes(1); + }); + + it('causes handle() to throw after destroying the engine', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [makeNullMiddleware()], + }); + + await engine.destroy(); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new JsonRpcEngineError('Engine is destroyed'), + ); + }); + + it('causes asMiddleware() to throw after destroying the engine', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [makeNullMiddleware()], + }); + await engine.destroy(); + + expect(() => engine.asMiddleware()).toThrow( + new JsonRpcEngineError('Engine is destroyed'), + ); + }); + + it('rejects if a middleware throws when destroying', async () => { + const middleware = { + destroy: jest.fn(() => { + throw new Error('test'); + }), + }; + const engine = JsonRpcEngineV2.create({ + middleware: [middleware as unknown as JsonRpcMiddleware], + }); + + await expect(engine.destroy()).rejects.toThrow(new Error('test')); + }); + + it('calls the destroy() method of each middleware even if one throws', async () => { + const middleware1 = { + destroy: jest.fn(() => { + throw new Error('test'); + }), + }; + const middleware2 = { + destroy: jest.fn(), + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + middleware1, + middleware2, + ] as unknown as JsonRpcMiddleware[], + }); + + await expect(engine.destroy()).rejects.toThrow(new Error('test')); + + expect(middleware1.destroy).toHaveBeenCalledTimes(1); + expect(middleware2.destroy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts new file mode 100644 index 0000000000..4f55df4a38 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -0,0 +1,472 @@ +import { + type Json, + type JsonRpcRequest, + type JsonRpcNotification, + type NonEmptyArray, + hasProperty, +} from '@metamask/utils'; +import deepFreeze from 'deep-freeze-strict'; + +import type { ContextConstraint, MergeContexts } from './MiddlewareContext'; +import { MiddlewareContext } from './MiddlewareContext'; +import { + isNotification, + isRequest, + JsonRpcEngineError, + stringify, +} from './utils'; +import type { JsonRpcCall } from './utils'; + +// Helper to forbid `id` on notifications +type WithoutId = Request & { id?: never }; + +// Helper to enable JsonRpcCall overload of handle() +type MixedParam = [ + Extract, +] extends [never] + ? never + : [Extract] extends [never] + ? never + : + | Extract + | WithoutId>; + +export type ResultConstraint = + Request extends JsonRpcRequest ? Json : void; + +export type Next = ( + request?: Readonly, +) => Promise> | undefined>; + +export type MiddlewareParams< + Request extends JsonRpcCall, + Context extends MiddlewareContext, +> = { + request: Readonly; + context: Context; + next: Next; +}; + +export type JsonRpcMiddleware< + Request extends JsonRpcCall = JsonRpcCall, + Result extends ResultConstraint = ResultConstraint, + Context extends ContextConstraint = MiddlewareContext, +> = ( + params: MiddlewareParams, +) => Readonly | undefined | Promise | undefined>; + +type RequestState = { + request: Request; + result: Readonly> | undefined; +}; + +type HandleOptions = { + context?: Context; +}; + +type ConstructorOptions< + Request extends JsonRpcCall, + Context extends MiddlewareContext, +> = { + middleware: NonEmptyArray< + JsonRpcMiddleware, Context> + >; +}; + +type RequestOf = + Middleware extends JsonRpcMiddleware< + infer Request, + ResultConstraint, + // Non-polluting `any` constraint. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + > + ? Request + : never; + +type ContextOf = + // Non-polluting `any` constraint. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Middleware extends JsonRpcMiddleware, infer C> + ? C + : never; + +type MergedContextOf< + // Non-polluting `any` constraint. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Middleware extends JsonRpcMiddleware, +> = MergeContexts>; + +const INVALID_ENGINE = Symbol('Invalid engine'); + +/** + * An internal type for invalid engines that explains why the engine is invalid. + * + * @template Message - The message explaining why the engine is invalid. + */ +type InvalidEngine = { [INVALID_ENGINE]: Message }; + +/** + * A JSON-RPC request and response processor. + * + * Give it a stack of middleware, pass it requests, and get back responses. + * + * #### Requests vs. notifications + * + * JSON-RPC requests come in two flavors: + * + * - [Requests](https://www.jsonrpc.org/specification#request_object), i.e. request objects _with_ an `id` + * - [Notifications](https://www.jsonrpc.org/specification#notification), i.e. request objects _without_ an `id` + * + * For requests, one of the engine's middleware must "end" the request by returning a non-`undefined` result, + * or {@link handle} will throw an error: + * + * 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: + * + * @template Request - The type of request to handle. + * @template Result - The type of result to return. + * + * @example + * ```ts + * const engine = JsonRpcEngineV2.create({ + * middleware, + * }); + * + * try { + * const result = await engine.handle(request); + * // Handle result + * } catch (error) { + * // Handle error + * } + * ``` + */ +export class JsonRpcEngineV2< + Request extends JsonRpcCall = JsonRpcCall, + Context extends ContextConstraint = MiddlewareContext, +> { + #middleware: Readonly< + NonEmptyArray< + JsonRpcMiddleware, Context> + > + >; + + #isDestroyed = false; + + // See .create() for why this is private. + private constructor({ middleware }: ConstructorOptions) { + this.#middleware = [...middleware]; + } + + // We use a static factory method in order to construct a supertype of all middleware contexts, + // which enables us to instantiate an engine despite different middleware expecting different + // context types. + /** + * Create a new JSON-RPC engine. + * + * @throws If the middleware array is empty. + * @param options - The options for the engine. + * @param options.middleware - The middleware to use. + * @returns The JSON-RPC engine. + */ + static create< + Middleware extends JsonRpcMiddleware< + // Non-polluting `any` constraint. + /* eslint-disable @typescript-eslint/no-explicit-any */ + any, + ResultConstraint, + any + /* eslint-enable @typescript-eslint/no-explicit-any */ + > = JsonRpcMiddleware, + >({ middleware }: { middleware: Middleware[] }) { + // We can't use NonEmptyArray for the params because it ruins type inference. + if (middleware.length === 0) { + throw new JsonRpcEngineError('Middleware array cannot be empty'); + } + + type MergedContext = MergedContextOf; + type InputRequest = RequestOf; + const mw = middleware as unknown as NonEmptyArray< + JsonRpcMiddleware< + InputRequest, + ResultConstraint, + MergedContext + > + >; + return new JsonRpcEngineV2({ + middleware: mw, + }) as MergedContext extends never + ? InvalidEngine<'Some middleware have incompatible context types'> + : JsonRpcEngineV2; + } + + /** + * Handle a JSON-RPC request. + * + * @param request - The JSON-RPC request to handle. + * @param options - The options for the handle operation. + * @param options.context - The context to pass to the middleware. + * @returns The JSON-RPC response. + */ + async handle( + request: Extract extends never + ? never + : Extract, + options?: HandleOptions, + ): Promise< + Extract extends never + ? never + : ResultConstraint + >; + + /** + * Handle a JSON-RPC notification. Notifications do not return a result. + * + * @param notification - The JSON-RPC notification to handle. + * @param options - The options for the handle operation. + * @param options.context - The context to pass to the middleware. + */ + async handle( + notification: Extract extends never + ? never + : WithoutId>, + options?: HandleOptions, + ): Promise< + Extract extends never + ? never + : ResultConstraint + >; + + /** + * Handle a JSON-RPC call, i.e. request or notification. Requests return a + * result, notifications do not. + * + * @param call - The JSON-RPC call to handle. + * @param options - The options for the handle operation. + * @param options.context - The context to pass to the middleware. + * @returns The JSON-RPC response, or `undefined` if the call is a notification. + */ + async handle( + call: MixedParam, + options?: HandleOptions, + ): Promise | void>; + + async handle( + request: Request, + { context }: HandleOptions = {}, + ): Promise> | void> { + const isReq = isRequest(request); + const { result } = await this.#handle(request, context); + + if (isReq && result === undefined) { + throw new JsonRpcEngineError( + `Nothing ended request: ${stringify(request)}`, + ); + } + return result; + } + + /** + * Handle a JSON-RPC request. Throws if a middleware performs an invalid + * operation. Permits returning an `undefined` result. + * + * @param originalRequest - The JSON-RPC request to handle. + * @param context - The context to pass to the middleware. + * @returns The result from the middleware. + */ + async #handle( + originalRequest: Request, + context: Context = new MiddlewareContext() as Context, + ): Promise> { + this.#assertIsNotDestroyed(); + + deepFreeze(originalRequest); + + const state: RequestState = { + request: originalRequest, + result: undefined, + }; + const middlewareIterator = this.#makeMiddlewareIterator(); + const firstMiddleware = middlewareIterator.next().value; + + const makeNext = this.#makeNextFactory(middlewareIterator, state, context); + + const result = await firstMiddleware({ + request: originalRequest, + context, + next: makeNext(), + }); + this.#updateResult(result, state); + + return state; + } + + /** + * Create a factory of `next()` functions for use with a particular request. + * The factory is recursive, and a new `next()` is created for each middleware + * invocation. + * + * @param middlewareIterator - The iterator of middleware for the current + * request. + * @param state - The current values of the request and result. + * @param context - The context to pass to the middleware. + * @returns The `next()` function factory. + */ + #makeNextFactory( + middlewareIterator: Iterator< + JsonRpcMiddleware, Context> + >, + state: RequestState, + context: Context, + ): () => Next { + const makeNext = (): Next => { + let wasCalled = false; + + const next = async ( + request: Request = state.request, + ): Promise> | undefined> => { + if (wasCalled) { + throw new JsonRpcEngineError( + `Middleware attempted to call next() multiple times for request: ${stringify(request)}`, + ); + } + wasCalled = true; + + if (request !== state.request) { + this.#assertValidNextRequest(state.request, request); + state.request = deepFreeze(request); + } + + const { value: nextMiddleware, done } = middlewareIterator.next(); + if (done) { + // This will cause the last middleware to return `undefined`. See the class + // JSDoc or package README for more details. + return undefined; + } + + const result = await nextMiddleware({ + request, + context, + next: makeNext(), + }); + this.#updateResult(result, state); + + return state.result; + }; + return next; + }; + + return makeNext; + } + + #makeMiddlewareIterator(): Iterator< + JsonRpcMiddleware, Context> + > { + return this.#middleware[Symbol.iterator](); + } + + /** + * Validate the result from a middleware and, if it's a new value, update the + * current result. + * + * @param result - The result from the middleware. + * @param state - The current values of the request and result. + */ + #updateResult( + result: + | Readonly> + | ResultConstraint + | void, + state: RequestState, + ): void { + if (isNotification(state.request) && result !== undefined) { + throw new JsonRpcEngineError( + `Result returned for notification: ${stringify(state.request)}`, + ); + } + + if (result !== undefined && result !== state.result) { + if (typeof result === 'object' && result !== null) { + deepFreeze(result); + } + state.result = result; + } + } + + /** + * Assert that a request modified by a middleware is valid. + * + * @param currentRequest - The current request. + * @param nextRequest - The next request. + */ + #assertValidNextRequest(currentRequest: Request, nextRequest: Request): void { + if (nextRequest.jsonrpc !== currentRequest.jsonrpc) { + throw new JsonRpcEngineError( + `Middleware attempted to modify readonly property "jsonrpc" for request: ${stringify(currentRequest)}`, + ); + } + if ( + hasProperty(nextRequest, 'id') !== hasProperty(currentRequest, 'id') || + // @ts-expect-error - "id" does not exist on notifications, but we can still + // check the value of the property at runtime. + nextRequest.id !== currentRequest.id + ) { + throw new JsonRpcEngineError( + `Middleware attempted to modify readonly property "id" for request: ${stringify(currentRequest)}`, + ); + } + } + + /** + * Convert the engine into a JSON-RPC middleware. + * + * @returns The JSON-RPC middleware. + */ + asMiddleware(): JsonRpcMiddleware< + Request, + ResultConstraint, + Context + > { + this.#assertIsNotDestroyed(); + + return async ({ request, context, next }) => { + const { result, request: finalRequest } = await this.#handle( + request, + context, + ); + return result === undefined ? await next(finalRequest) : result; + }; + } + + /** + * Destroy the engine. Calls the `destroy()` method of any middleware that has + * one. Attempting to use the engine after destroying it will throw an error. + */ + async destroy(): Promise { + if (this.#isDestroyed) { + return; + } + this.#isDestroyed = true; + + const destructionPromise = Promise.all( + this.#middleware.map(async (middleware) => { + if ( + // Intentionally using `in` to walk the prototype chain. + 'destroy' in middleware && + typeof middleware.destroy === 'function' + ) { + return middleware.destroy(); + } + return undefined; + }), + ); + this.#middleware = [] as never; + await destructionPromise; + } + + #assertIsNotDestroyed(): void { + if (this.#isDestroyed) { + throw new JsonRpcEngineError('Engine is destroyed'); + } + } +} diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts new file mode 100644 index 0000000000..88d90d2e7d --- /dev/null +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts @@ -0,0 +1,276 @@ +import { rpcErrors } from '@metamask/rpc-errors'; + +import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; +import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; +import { JsonRpcServer } from './JsonRpcServer'; +import { isRequest } from './utils'; + +const jsonrpc = '2.0' as const; + +const makeEngine = () => { + return JsonRpcEngineV2.create({ + middleware: [ + ({ request }) => { + if (request.method !== 'hello') { + throw new Error('Unknown method'); + } + return isRequest(request) ? (request.params ?? null) : undefined; + }, + ], + }); +}; + +describe('JsonRpcServer', () => { + it('can be constructed with an engine', () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: () => undefined, + }); + + expect(server).toBeDefined(); + }); + + it('can be constructed with middleware', () => { + const server = new JsonRpcServer({ + middleware: [() => null], + onError: () => undefined, + }); + + expect(server).toBeDefined(); + }); + + it('handles a request', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: () => undefined, + }); + + const response = await server.handle({ + jsonrpc, + id: 1, + method: 'hello', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + result: null, + }); + }); + + it('handles a request with params', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: () => undefined, + }); + + const response = await server.handle({ + jsonrpc, + id: 1, + method: 'hello', + params: ['world'], + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + result: ['world'], + }); + }); + + it('handles a notification', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: () => undefined, + }); + + const response = await server.handle({ + jsonrpc, + method: 'hello', + }); + + expect(response).toBeUndefined(); + }); + + it('handles a notification with params', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: () => undefined, + }); + + const response = await server.handle({ + jsonrpc, + method: 'hello', + params: { hello: 'world' }, + }); + + expect(response).toBeUndefined(); + }); + + it('returns an error response for a failed request', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: () => undefined, + }); + + const response = await server.handle({ + jsonrpc, + id: 1, + method: 'unknown', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + error: { + code: -32603, + message: 'Unknown method', + data: { cause: expect.any(Object) }, + }, + }); + }); + + it('returns undefined for a failed notification', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: () => undefined, + }); + + const response = await server.handle({ + jsonrpc, + method: 'unknown', + }); + + expect(response).toBeUndefined(); + }); + + it('calls onError for a failed request', async () => { + const onError = jest.fn(); + const server = new JsonRpcServer({ + engine: makeEngine(), + onError, + }); + + await server.handle({ + jsonrpc, + id: 1, + method: 'unknown', + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(new Error('Unknown method')); + }); + + it('returns a failed request when onError is not provided', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + }); + + const response = await server.handle({ + jsonrpc, + id: 1, + method: 'unknown', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + error: { + code: -32603, + message: 'Unknown method', + data: { cause: expect.any(Object) }, + }, + }); + }); + + it('calls onError for a failed notification', async () => { + const onError = jest.fn(); + const server = new JsonRpcServer({ + engine: makeEngine(), + onError, + }); + + await server.handle({ + jsonrpc, + method: 'unknown', + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(new Error('Unknown method')); + }); + + it('accepts requests with malformed jsonrpc', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: () => undefined, + }); + + const response = await server.handle({ + jsonrpc: '1.0', + id: 1, + method: 'hello', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + result: null, + }); + }); + + it.each([undefined, Symbol('test'), null, true, false, {}, []])( + 'accepts requests with malformed ids', + async (id) => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: () => undefined, + }); + + const response = await server.handle({ + jsonrpc, + id, + method: 'hello', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id, + result: null, + }); + }, + ); + + it.each([ + null, + {}, + [], + false, + true, + { method: 'hello', params: 'world' }, + { method: 'hello', params: null }, + { method: 'hello', params: undefined }, + { params: ['world'] }, + { jsonrpc }, + { id: 1 }, + ])( + 'throws if the request is not minimally conformant', + async (malformedRequest) => { + const onError = jest.fn(); + const server = new JsonRpcServer({ + engine: makeEngine(), + onError, + }); + + await server.handle(malformedRequest); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + rpcErrors.invalidRequest({ + data: { + request: malformedRequest, + }, + }), + ); + }, + ); +}); diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts new file mode 100644 index 0000000000..bd10a67bf4 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts @@ -0,0 +1,233 @@ +import { rpcErrors, serializeError } from '@metamask/rpc-errors'; +import type { + JsonRpcNotification, + JsonRpcParams, + JsonRpcRequest, + JsonRpcResponse, + NonEmptyArray, +} from '@metamask/utils'; +import { hasProperty, isObject } from '@metamask/utils'; + +import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; +import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; +import type { JsonRpcCall } from './utils'; +import { getUniqueId } from '../getUniqueId'; + +type OnError = (error: unknown) => void; + +type Options = { + onError?: OnError; +} & ( + | { + engine: JsonRpcEngineV2; + } + | { + middleware: NonEmptyArray; + } +); + +const jsonrpc = '2.0' as const; + +/** + * A JSON-RPC server that handles requests and notifications. + * + * Essentially wraps a {@link JsonRpcEngineV2} in order to create a conformant + * yet permissive JSON-RPC 2.0 server. + * + * @example + * ```ts + * const server = new JsonRpcServer({ + * engine, + * onError, + * }); + * + * const response = await server.handle(request); + * if ('result' in response) { + * // Handle result + * } else { + * // Handle error + * } + * ``` + */ +export class JsonRpcServer { + readonly #engine: JsonRpcEngineV2; + + readonly #onError?: OnError | undefined; + + /** + * Construct a new JSON-RPC server. + * + * @param options - The options for the server. + * @param options.onError - The callback to handle errors thrown by the + * engine. Errors always result in a failed response object, containing a + * JSON-RPC 2.0 serialized version of the original error. If you need to + * access the original error, use the `onError` callback. + * @param options.engine - The engine to use. Mutually exclusive with + * `middleware`. + * @param options.middleware - The middleware to use. Mutually exclusive with + * `engine`. + */ + constructor(options: Options) { + this.#onError = options.onError; + + if (hasProperty(options, 'engine')) { + // @ts-expect-error - hasProperty fails to narrow the type. + this.#engine = options.engine; + } else { + this.#engine = JsonRpcEngineV2.create({ middleware: options.middleware }); + } + } + + /** + * Handle a JSON-RPC request. + * + * This method never throws. For requests, a response is always returned. + * All errors are passed to the engine's `onError` callback. + * + * @param request - The request to handle. + * @returns The JSON-RPC response. + */ + async handle(request: JsonRpcRequest): Promise; + + /** + * Handle a JSON-RPC notification. + * + * This method never throws. For notifications, `undefined` is always returned. + * All errors are passed to the engine's `onError` callback. + * + * @param notification - The notification to handle. + */ + async handle(notification: JsonRpcNotification): Promise; + + /** + * Handle an alleged JSON-RPC request or notification. Permits any plain + * object with `{ method: string }`, so long as any present JSON-RPC 2.0 + * properties are valid. If the object has no `id`, it will be treated as + * a notification and vice versa. + * + * This method never throws. All errors are passed to the engine's + * `onError` callback. A JSON-RPC response is always returned for requests, + * and `undefined` is returned for notifications. + * + * @param rawRequest - The raw request to handle. + * @returns The JSON-RPC response, or `undefined` if the request is a + * notification. + */ + async handle(rawRequest: unknown): Promise; + + async handle(rawRequest: unknown): Promise { + // If rawRequest is not a notification, the originalId will be attached + // to the response. We attach our own, trusted id in #coerceRequest() + // while the request is being handled. + const [originalId, isRequest] = getOriginalId(rawRequest); + + try { + const request = this.#coerceRequest(rawRequest, isRequest); + const result = await this.#engine.handle(request); + + if (result !== undefined) { + return { + jsonrpc, + // @ts-expect-error - Reassign the original id, regardless of its type. + id: originalId, + result, + }; + } + } catch (error) { + this.#onError?.(error); + + if (isRequest) { + return { + jsonrpc, + // @ts-expect-error - Reassign the original id, regardless of its type. + id: originalId, + error: serializeError(error, { + shouldIncludeStack: false, + shouldPreserveMessage: true, + }), + }; + } + } + return undefined; + } + + #coerceRequest(rawRequest: unknown, isRequest: boolean): JsonRpcCall { + if (!isMinimalRequest(rawRequest)) { + throw rpcErrors.invalidRequest({ + data: { + request: rawRequest, + }, + }); + } + + const request: JsonRpcCall = { + jsonrpc, + method: rawRequest.method, + }; + + if (hasProperty(rawRequest, 'params')) { + request.params = rawRequest.params as JsonRpcParams; + } + + if (isRequest) { + (request as JsonRpcRequest).id = getUniqueId(); + } + + return request; + } +} + +/** + * The most minimally conformant request object that we will accept. + */ +type MinimalRequest = { + method: string; + params?: JsonRpcParams; +} & Record; + +/** + * Check if an unvalidated request is a minimal request. + * + * @param rawRequest - The raw request to check. + * @returns `true` if the request is a {@link MinimalRequest}, `false` otherwise. + */ +function isMinimalRequest(rawRequest: unknown): rawRequest is MinimalRequest { + return ( + isObject(rawRequest) && + hasProperty(rawRequest, 'method') && + typeof rawRequest.method === 'string' && + hasValidParams(rawRequest) + ); +} + +/** + * Check if a request has valid params, i.e. an array or object. + * The contents of the params are not inspected. + * + * @param rawRequest - The request to check. + * @returns `true` if the request has valid params, `false` otherwise. + */ +function hasValidParams( + rawRequest: Record, +): rawRequest is { params?: JsonRpcParams } { + if (hasProperty(rawRequest, 'params')) { + return Array.isArray(rawRequest.params) || isObject(rawRequest.params); + } + + return true; +} + +/** + * Get the original id from a request. + * + * @param rawRequest - The request to get the original id from. + * @returns The original id and a boolean indicating if the request is a request + * (as opposed to a notification). + */ +function getOriginalId(rawRequest: unknown): [unknown, boolean] { + if (isObject(rawRequest) && hasProperty(rawRequest, 'id')) { + return [rawRequest.id, true]; + } + + return [undefined, false]; +} diff --git a/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts b/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts new file mode 100644 index 0000000000..8d755ddb48 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts @@ -0,0 +1,66 @@ +import { MiddlewareContext } from './MiddlewareContext'; + +describe('MiddlewareContext', () => { + it('can be constructed with entries', () => { + const symbol = Symbol('test'); + const context = new MiddlewareContext<{ test: string; [symbol]: string }>([ + ['test', 'value'], + [symbol, 'value'], + ]); + expect(context.get('test')).toBe('value'); + expect(context.get(symbol)).toBe('value'); + }); + + it('is frozen', () => { + const context = new MiddlewareContext(); + expect(Object.isFrozen(context)).toBe(true); + }); + + it('type errors and returns undefined when getting unknown keys', () => { + const context = new MiddlewareContext<{ test: string }>(); + // @ts-expect-error - foo is not a valid key + expect(context.get('foo')).toBeUndefined(); + }); + + it('type errors and throws when assertGet:ing unknown keys', () => { + const context = new MiddlewareContext<{ test: string }>(); + // @ts-expect-error - foo is not a valid key + expect(() => context.assertGet('foo')).toThrow( + `Context key "foo" not found`, + ); + }); + + it('type errors when setting unknown keys', () => { + const context = new MiddlewareContext<{ test: string }>(); + // @ts-expect-error - foo is not a valid key + expect(context.set('foo', 'value')).toBe(context); + }); + + it('assertGet throws if the key is not found', () => { + const context = new MiddlewareContext<{ test: string }>(); + expect(() => context.assertGet('test')).toThrow( + `Context key "test" not found`, + ); + }); + + it('assertGet returns the value if the key is found (string)', () => { + const context = new MiddlewareContext<{ test: string }>(); + context.set('test', 'value'); + expect(context.assertGet('test')).toBe('value'); + }); + + it('assertGet returns the value if the key is found (symbol)', () => { + const symbol = Symbol('test'); + const context = new MiddlewareContext<{ [symbol]: string }>(); + context.set(symbol, 'value'); + expect(context.assertGet(symbol)).toBe('value'); + }); + + it('throws if setting an already set key', () => { + const context = new MiddlewareContext<{ test: string }>(); + context.set('test', 'value'); + expect(() => context.set('test', 'value')).toThrow( + `MiddlewareContext key "test" already exists`, + ); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/MiddlewareContext.ts b/packages/json-rpc-engine/src/v2/MiddlewareContext.ts new file mode 100644 index 0000000000..8eb291dcc7 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/MiddlewareContext.ts @@ -0,0 +1,134 @@ +import type { UnionToIntersection } from './utils'; + +/** + * An context object for middleware that attempts to protect against accidental + * modifications. Its interface is frozen. + * + * Map keys may not be directly overridden with {@link set}. Instead, use + * {@link delete} to remove a key and then {@link set} to add a new value. + * + * The override protections are circumvented when using e.g. `Reflect.set`, so + * don't do that. + * + * @template KeyValues - The type of the keys and values in the context. + * @example + * // By default, the context permits any PropertyKey as a key. + * const context = new MiddlewareContext(); + * context.set('foo', 'bar'); + * context.get('foo'); // 'bar' + * context.get('fizz'); // undefined + * @example + * // By specifying an object type, the context permits only the keys of the object. + * type Context = MiddlewareContext<{ foo: string }>; + * const context = new Context([['foo', 'bar']]); + * context.get('foo'); // 'bar' + * context.get('fizz'); // Type error + */ +export class MiddlewareContext< + KeyValues extends Record = Record, +> extends Map { + constructor( + entries?: Iterable, + ) { + super(entries); + Object.freeze(this); + } + + get(key: K): KeyValues[K] | undefined { + return super.get(key) as KeyValues[K] | undefined; + } + + /** + * Get a value from the context. Throws if the key is not found. + * + * @param key - The key to get the value for. + * @returns The value. + */ + assertGet(key: K): KeyValues[K] { + if (!super.has(key)) { + throw new Error(`Context key "${String(key)}" not found`); + } + return super.get(key) as KeyValues[K]; + } + + /** + * Set a value in the context. Throws if the key already exists. + * {@link delete} an existing key before setting it to a new value. + * + * @throws If the key already exists. + * @param key - The key to set the value for. + * @param value - The value to set. + * @returns The context. + */ + set(key: K, value: KeyValues[K]): this { + if (super.has(key)) { + throw new Error(`MiddlewareContext key "${String(key)}" already exists`); + } + super.set(key, value); + return this; + } +} + +/** + * Infer the KeyValues type from a {@link MiddlewareContext}. + */ +type InferKeyValues = T extends MiddlewareContext ? U : never; + +/** + * Simplifies an object type by "merging" its properties. + * + * - Expands intersections into a single object type. + * - Forces mapped/conditional results to resolve into a readable shape. + * - No runtime effect; purely a type-level normalization. + * + * @example + * type A = { a: string } & { b: number }; + * type B = Simplify; // { a: string; b: number } + */ +type Simplify = T extends infer O ? { [K in keyof O]: O[K] } : never; + +/** + * Rejects record types that contain any `never`-valued property. + * + * If any property of `T` resolves to `never`, the result is `never`; otherwise it returns `T` unchanged. + * Useful as a guard to ensure computed/merged record types didn't collapse any fields to `never`. + * + * @example + * type A = ExcludeNever<{ a: string; b: never }>; // never + * type B = ExcludeNever<{ a: string; b: number }>; // { a: string; b: number } + */ +type ExcludeNever> = { + [K in keyof T]-?: [T[K]] extends [never] ? K : never; +}[keyof T] extends never + ? T + : never; + +/** + * Merge a union of {@link MiddlewareContext}s into a single {@link MiddlewareContext} + * supertype. + * + * @param Contexts - The union of {@link MiddlewareContext}s to merge. + * @returns The merged {@link MiddlewareContext} supertype. + * @example + * type A = MiddlewareContext<{ a: string }> | MiddlewareContext<{ b: number }>; + * type B = MergeContexts; // MiddlewareContext<{ a: string, b: number }> + */ +export type MergeContexts = + ExcludeNever< + Simplify>> + > extends never + ? never + : MiddlewareContext< + ExcludeNever>>> + >; + +// Non-polluting `any` constraint. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ContextConstraint = MiddlewareContext; + +/** + * The empty context type, i.e. `MiddlewareContext<{}>`. + */ +// The empty object type is literally an empty object in this context. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type EmptyContext = MiddlewareContext<{}>; diff --git a/packages/json-rpc-engine/src/v2/README.md b/packages/json-rpc-engine/src/v2/README.md new file mode 100644 index 0000000000..ac52988c30 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/README.md @@ -0,0 +1,3 @@ +# `@metamask/json-rpc-engine/v2` + +See the [root readme](../../README.md). diff --git a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts new file mode 100644 index 0000000000..b7968898f6 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts @@ -0,0 +1,193 @@ +import type { + JsonRpcFailure, + JsonRpcRequest, + JsonRpcSuccess, +} from '@metamask/utils'; + +import { asLegacyMiddleware } from './asLegacyMiddleware'; +import type { JsonRpcMiddleware, ResultConstraint } from './JsonRpcEngineV2'; +import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; +import { + getExtraneousKeys, + makeRequest, + makeRequestMiddleware, +} from '../../tests/utils'; +import { JsonRpcEngine } from '../JsonRpcEngine'; + +describe('asLegacyMiddleware', () => { + it('converts a v2 engine to a legacy middleware', () => { + const engine = JsonRpcEngineV2.create({ + middleware: [makeRequestMiddleware()], + }); + const middleware = asLegacyMiddleware(engine); + expect(typeof middleware).toBe('function'); + }); + + it('forwards a result to the legacy engine', async () => { + const v2Engine = JsonRpcEngineV2.create({ + middleware: [makeRequestMiddleware()], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + + expect(response.result).toBeNull(); + }); + + it('forwarded results are not frozen', async () => { + const v2Middleware: JsonRpcMiddleware = () => []; + const v2Engine = JsonRpcEngineV2.create({ + middleware: [v2Middleware], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + + expect(response.result).toStrictEqual([]); + expect(Object.isFrozen(response.result)).toBe(false); + }); + + it('forwards an error to the legacy engine', async () => { + const v2Middleware: JsonRpcMiddleware = () => { + throw new Error('test'); + }; + const v2Engine = JsonRpcEngineV2.create({ + middleware: [v2Middleware], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcFailure; + + expect(response.error).toStrictEqual({ + message: 'test', + code: -32603, + data: { + cause: { + message: 'test', + stack: expect.any(String), + }, + }, + }); + }); + + it('allows the legacy engine to continue when not ending the request', async () => { + const v2Middleware: JsonRpcMiddleware = jest.fn( + ({ next }) => next(), + ); + const v2Engine = JsonRpcEngineV2.create({ + middleware: [v2Middleware], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + legacyEngine.push((_req, res, _next, end) => { + res.result = null; + end(); + }); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + expect(response.result).toBeNull(); + expect(v2Middleware).toHaveBeenCalledTimes(1); + }); + + it('allows the legacy engine to continue when not ending the request (passing through the original request)', async () => { + const v2Middleware: JsonRpcMiddleware = jest.fn( + ({ request, next }) => next(request), + ); + const v2Engine = JsonRpcEngineV2.create({ + middleware: [v2Middleware], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + legacyEngine.push((_req, res, _next, end) => { + res.result = null; + end(); + }); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + expect(response.result).toBeNull(); + expect(v2Middleware).toHaveBeenCalledTimes(1); + }); + + it('propagates request modifications to the legacy engine', async () => { + const v2Engine = JsonRpcEngineV2.create>({ + middleware: [ + ({ request, next }) => next({ ...request, method: 'test_request_2' }), + ], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push((req, _res, next, _end) => { + expect(req.method).toBe('test_request'); + next(); + }); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + legacyEngine.push((req, res, _next, end) => { + expect(req.method).toBe('test_request_2'); + res.result = null; + end(); + }); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + expect(response.result).toBeNull(); + }); + + it('propagates additional request properties to the v2 context and back', async () => { + const observedContextValues: number[] = []; + + const v2Middleware = jest.fn((({ context, next }) => { + observedContextValues.push(context.assertGet('value') as number); + + expect(Array.from(context.keys())).toStrictEqual(['value']); + + context.set('newValue', 2); + return next(); + }) satisfies JsonRpcMiddleware< + JsonRpcRequest, + ResultConstraint + >); + + const v2Engine = JsonRpcEngineV2.create({ + middleware: [v2Middleware], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push((req, _res, next, _end) => { + (req as Record).value = 1; + return next(); + }); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + legacyEngine.push((req, res, _next, end) => { + observedContextValues.push( + (req as Record).newValue as number, + ); + + expect(getExtraneousKeys(req)).toStrictEqual(['value', 'newValue']); + + res.result = null; + end(); + }); + + await legacyEngine.handle(makeRequest()); + expect(observedContextValues).toStrictEqual([1, 2]); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts new file mode 100644 index 0000000000..f5cfe6be40 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts @@ -0,0 +1,52 @@ +import type { JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; + +import { + deepClone, + fromLegacyRequest, + makeContext, + propagateToRequest, +} from './compatibility-utils'; +import type { JsonRpcEngineV2, ResultConstraint } from './JsonRpcEngineV2'; +import { createAsyncMiddleware } from '..'; +import type { JsonRpcMiddleware as LegacyMiddleware } from '..'; + +/** + * Convert a {@link JsonRpcEngineV2} into a legacy middleware. + * + * @param engine - The engine to convert. + * @returns The legacy middleware. + */ +export function asLegacyMiddleware< + Params extends JsonRpcParams, + Request extends JsonRpcRequest, +>( + engine: JsonRpcEngineV2, +): LegacyMiddleware> { + const middleware = engine.asMiddleware(); + return createAsyncMiddleware(async (req, res, next) => { + const request = fromLegacyRequest(req as Request); + const context = makeContext(req); + let modifiedRequest: Request | undefined; + + const result = await middleware({ + request, + context, + next: (finalRequest) => { + modifiedRequest = finalRequest; + return Promise.resolve(undefined); + }, + }); + + if (modifiedRequest !== undefined && modifiedRequest !== request) { + Object.assign(req, deepClone(modifiedRequest)); + } + propagateToRequest(req, context); + + if (result !== undefined) { + // Unclear why the `as unknown` is needed here, but the cast is safe. + res.result = deepClone(result) as unknown as ResultConstraint; + return undefined; + } + return next(); + }); +} diff --git a/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts b/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts new file mode 100644 index 0000000000..9e75753daa --- /dev/null +++ b/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts @@ -0,0 +1,529 @@ +import { JsonRpcError } from '@metamask/rpc-errors'; +import type { Json } from '@metamask/utils'; + +import { + deepClone, + fromLegacyRequest, + makeContext, + propagateToContext, + propagateToRequest, + unserializeError, +} from './compatibility-utils'; +import { MiddlewareContext } from './MiddlewareContext'; +import { stringify } from './utils'; + +const jsonrpc = '2.0' as const; + +describe('compatibility-utils', () => { + describe('deepClone', () => { + it('clones an object', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [], + id: 1, + }; + const clonedRequest = deepClone(request); + + expect(clonedRequest).toStrictEqual(request); + expect(clonedRequest).not.toBe(request); + }); + + it('produces a mutable clone of a frozen object', () => { + const request = Object.freeze({ + jsonrpc, + method: 'test_method' as string, + params: Object.freeze([1, 2, 3]), + id: 1, + }); + + const clonedRequest = deepClone(request); + + expect(clonedRequest).toStrictEqual(request); + expect(clonedRequest).not.toBe(request); + expect(Object.isFrozen(clonedRequest)).toBe(false); + expect(Object.isFrozen(clonedRequest.params)).toBe(false); + + clonedRequest.method = 'modified_method'; + clonedRequest.params[1] = 42; + + expect(request.method).toBe('test_method'); + expect(clonedRequest.params[1]).toBe(42); + }); + + it('ignores symbol properties', () => { + const symbolProp = Symbol('test'); + const request = { + jsonrpc, + method: 'test_method' as string, + params: [1, 2, 3], + id: 1, + [symbolProp]: 'value', + }; + + const clonedRequest = deepClone(request); + // @ts-expect-error - Symbol properties are omitted + expect(clonedRequest[symbolProp]).toBeUndefined(); + }); + }); + + describe('fromLegacyRequest', () => { + it('converts a request, preserving its properties', () => { + const legacyRequest = { + jsonrpc, + method: 'test_method', + params: [1, 2, 3], + id: 42, + }; + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1, 2, 3], + id: 42, + }); + }); + + it('clones params to avoid freezing them as part of the new request object', () => { + const params = [1, { a: 2 }]; + const legacyRequest = { + jsonrpc, + method: 'test_method', + params, + id: 42, + }; + const request = fromLegacyRequest(legacyRequest); + + expect(request.params).toStrictEqual(params); + expect(request.params).not.toBe(params); + expect(request.params?.[1]).not.toBe(params[1]); + }); + + it('handles requests without params', () => { + const legacyRequest = { + jsonrpc, + method: 'test_method', + id: 42, + }; + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + id: 42, + }); + }); + + it('handles requests with undefined params', () => { + const legacyRequest = { + jsonrpc, + method: 'test_method', + id: 42, + params: undefined, + }; + + // @ts-expect-error - Destructive testing + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + id: 42, + }); + }); + + it('handles requests without a jsonrpc property', () => { + const legacyRequest = { + method: 'test_method', + params: [1], + id: 42, + }; + + // @ts-expect-error - Destructive testing + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }); + }); + + it('handles requests with a faulty jsonrpc property', () => { + const legacyRequest = { + jsonrpc: '1.0', + method: 'test_method', + params: [1], + id: 42, + }; + + // @ts-expect-error - Destructive testing + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }); + }); + + it('ignores additional properties on the legacy request', () => { + const legacyRequest = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + anotherProp: { nested: true }, + }; + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }); + }); + }); + + describe('makeContext', () => { + it('creates a middleware context from a valid JSON-RPC request', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }; + const context = makeContext(request); + + expect(context).toBeInstanceOf(MiddlewareContext); + expect(Array.from(context.keys())).toStrictEqual([]); + }); + + it('includes non-JSON-RPC properties from request in context', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + anotherProp: { nested: true }, + }; + const context = makeContext(request); + + expect(Array.from(context.keys())).toStrictEqual([ + 'extraProp', + 'anotherProp', + ]); + }); + }); + + describe('propagateToContext', () => { + it('copies non-JSON-RPC properties from request to context', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + anotherProp: { nested: true }, + }; + const context = new MiddlewareContext(); + + propagateToContext(request, context); + + expect(Array.from(context.keys())).toStrictEqual([ + 'extraProp', + 'anotherProp', + ]); + }); + + it('handles requests with no extra properties', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }; + const context = new MiddlewareContext(); + + propagateToContext(request, context); + + expect(Array.from(context.keys())).toStrictEqual([]); + }); + }); + + describe('propagateToRequest', () => { + it('copies non-JSON-RPC string properties from context to request', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }; + const context = new MiddlewareContext>(); + context.set('extraProp', 'value'); + context.set('anotherProp', { nested: true }); + + propagateToRequest(request, context); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + anotherProp: { nested: true }, + }); + }); + + it('does not copy non-string properties from context to request', () => { + const symbol = Symbol('anotherProp'); + const context = new MiddlewareContext(); + context.set('extraProp', 'value'); + context.set(symbol, { nested: true }); + context.set(42, 'value'); + + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }; + propagateToRequest(request, context); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + }); + expect(symbol in request).toBe(false); + }); + + it('excludes JSON-RPC properties from propagation', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }; + const context = new MiddlewareContext>(); + context.set('jsonrpc', '3.0'); + context.set('method', 'other_method'); + context.set('params', [2]); + context.set('id', 99); + context.set('extraProp', 'value'); + + propagateToRequest(request, context); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + }); + }); + + it('overwrites existing request properties', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + existingKey: 'oldValue', + }; + const context = new MiddlewareContext>(); + context.set('existingKey', 'newValue'); + + propagateToRequest(request, context); + + expect(request.existingKey).toBe('newValue'); + }); + }); + + describe('unserializeError', () => { + // Requires some special handling due to the possible existence or + // non-existence of Error.isError + describe('Error.isError', () => { + const isErrorExists = 'isError' in Error; + let originalIsError: (value: unknown) => boolean; + let isError: jest.Mock; + + beforeAll(() => { + isError = jest.fn(); + // @ts-expect-error - Error type outdated + originalIsError = Error.isError; + // @ts-expect-error - Error type outdated + Error.isError = isError; + }); + + beforeEach(() => { + isError.mockClear(); + }); + + afterAll(() => { + if (isErrorExists) { + // @ts-expect-error - Error type outdated + Error.isError = originalIsError; + } else { + // @ts-expect-error - Error type outdated + delete Error.isError; + } + }); + + it('returns the thrown value when Error.isError is available and returns true', () => { + isError.mockReturnValueOnce(true); + const originalError = new Error('test error'); + + const result = unserializeError(originalError); + expect(result).toBe(originalError); + }); + + it('returns the thrown value when it is instanceof Error', () => { + isError.mockReturnValueOnce(false); + const originalError = new Error('test error'); + + const result = unserializeError(originalError); + expect(result).toBe(originalError); + }); + }); + + it('creates a new Error when thrown value is a string', () => { + const errorMessage = 'test error message'; + const result = unserializeError(errorMessage); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe(errorMessage); + }); + + it.each([42, true, false, null, undefined, Symbol('test')])( + 'creates a new Error with stringified message for non-object values', + (value) => { + const result = unserializeError(value); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe(`Unknown error: ${stringify(value)}`); + }, + ); + + it('creates a JsonRpcError when thrown value is an object with valid integer code', () => { + const thrownValue = { + code: 1234, + message: 'test error message', + cause: new Error('cause'), + data: { foo: 'bar' }, + }; + + const result = unserializeError(thrownValue); + + expect(result).toBeInstanceOf(JsonRpcError); + expect(result).toMatchObject({ + message: 'test error message', + code: 1234, + cause: thrownValue.cause, + data: { foo: 'bar' }, + }); + }); + + it('creates a plain Error when thrown value is an object without code property', () => { + const thrownValue = { + message: 'test error message', + cause: new Error('cause'), + data: { foo: 'bar' }, + }; + + const result = unserializeError(thrownValue); + + expect(result).toBeInstanceOf(Error); + expect(result).not.toBeInstanceOf(JsonRpcError); + expect(result).toStrictEqual( + // @ts-expect-error - Error type outdated + new Error('test error message', { cause: thrownValue.cause }), + ); + }); + + it('creates a plain Error when thrown value has non-integer code', () => { + const thrownValue = { + code: 123.45, + message: 'test error message', + }; + + const result = unserializeError(thrownValue); + + expect(result).toBeInstanceOf(Error); + expect(result).not.toBeInstanceOf(JsonRpcError); + expect(result).toStrictEqual(new Error('test error message')); + }); + + it('preserves stack trace when thrown value has stack property', () => { + const stackTrace = 'Error: test\n at test.js:1:1'; + const thrownValue = { + message: 'test error', + stack: stackTrace, + }; + + const result = unserializeError(thrownValue); + + expect(result).toBeInstanceOf(Error); + expect(result.stack).toBe(stackTrace); + }); + + it('preserves cause and data in JsonRpcError', () => { + const cause = new Error('original cause'); + const data = { custom: 'data' }; + const thrownValue = { + code: 1234, + message: 'test error', + cause, + data, + }; + + const result = unserializeError(thrownValue) as JsonRpcError; + + expect(result.cause).toBe(cause); + expect(result.data).toStrictEqual({ + ...data, + cause, + }); + }); + + it('uses default error message when message property is missing and code is unrecognized', () => { + const thrownValue = { + code: 1234, + }; + + const result = unserializeError(thrownValue); + + expect(result.message).toBe('Unknown error'); + }); + + it('uses default error message when message property is not a string and code is unrecognized', () => { + const thrownValue = { + code: 1234, + message: 42, + }; + + const result = unserializeError(thrownValue); + + expect(result.message).toBe('Unknown error'); + }); + + it('uses correct error message when message property is not a string and code is recognized', () => { + const thrownValue = { + code: -32603, + message: 42, + }; + + const result = unserializeError(thrownValue); + + expect(result.message).toBe('Internal JSON-RPC error.'); + }); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/compatibility-utils.ts b/packages/json-rpc-engine/src/v2/compatibility-utils.ts new file mode 100644 index 0000000000..03257b6a2e --- /dev/null +++ b/packages/json-rpc-engine/src/v2/compatibility-utils.ts @@ -0,0 +1,185 @@ +import { getMessageFromCode, JsonRpcError } from '@metamask/rpc-errors'; +import type { Json } from '@metamask/utils'; +import { hasProperty, isObject } from '@metamask/utils'; +// ATTN: We must NOT use 'klona/full' here because it freezes properties on the clone. +import { klona } from 'klona'; + +import { MiddlewareContext } from './MiddlewareContext'; +import { stringify, type JsonRpcRequest } from './utils'; + +// Legacy engine compatibility utils + +/** + * Create a deep clone of a value as follows: + * - Assumes acyclical objects + * - Does not copy property descriptors (i.e. uses mutable defaults) + * - Ignores non-enumerable properties + * - Ignores getters and setters + * + * @throws If the value is an object with a circular reference. + * @param value - The value to clone. + * @returns The cloned value. + */ +export const deepClone = (value: T): DeepCloned => + klona(value) as DeepCloned; + +// Matching the default implementation of klona, this type: +// - Removes readonly modifiers +// - Excludes non-enumerable / symbol properties +type DeepCloned = T extends readonly (infer U)[] + ? DeepCloned[] + : T extends object + ? { -readonly [K in keyof T & (string | number)]: DeepCloned } + : T; + +/** + * Standard JSON-RPC request properties. + */ +export const requestProps = ['jsonrpc', 'method', 'params', 'id']; + +/** + * Make a JSON-RPC request from a legacy request. Clones the params to avoid + * freezing them, which could cause errors in an involved legacy engine. + * + * @param req - The legacy request to make a request from. + * @returns The JSON-RPC request. + */ +export function fromLegacyRequest( + req: Request, +): Request { + const request = { + jsonrpc: '2.0' as const, + method: req.method, + } as Partial; + request.id = req.id; + if (hasProperty(req, 'params') && req.params !== undefined) { + request.params = deepClone(req.params); + } + return request as Request; +} + +/** + * Make a middleware context from a legacy request by copying over all non-JSON-RPC + * properties from the request to the context object. + * + * @param req - The legacy request to make a context from. + * @returns The middleware context. + */ +export function makeContext>( + req: Request, +): MiddlewareContext { + const context = new MiddlewareContext(); + propagateToContext(req, context); + return context; +} + +/** + * Copies non-JSON-RPC string properties from the request to the context. + * + * For compatibility with our problematic practice of appending non-standard + * fields to requests for inter-middleware communication in the legacy engine. + * + * **ATTN:** Only string properties that do not already exist in the context + * are copied. + * + * @param req - The request to propagate the context from. + * @param context - The context to propagate to. + */ +export function propagateToContext( + req: Record, + context: MiddlewareContext>, +) { + Object.keys(req) + .filter( + (key) => + typeof key === 'string' && + !requestProps.includes(key) && + !context.has(key), + ) + .forEach((key) => { + context.set(key, req[key]); + }); +} + +/** + * Copies non-JSON-RPC string properties from the context to the request. + * + * For compatibility with our problematic practice of appending non-standard + * fields to requests for inter-middleware communication in the legacy engine. + * + * **ATTN:** Only string properties are copied. + * + * @param req - The request to propagate the context to. + * @param context - The context to propagate from. + */ +export function propagateToRequest( + req: Record, + context: MiddlewareContext, +) { + Array.from(context.keys()) + .filter( + ((key) => typeof key === 'string' && !requestProps.includes(key)) as ( + value: unknown, + ) => value is string, + ) + .forEach((key) => { + req[key] = context.get(key); + }); +} + +/** + * Unserialize an error from a thrown value. Creates a {@link JsonRpcError} if + * the thrown value is an object with a `code` property. Otherwise, creates a + * plain {@link Error}. + * + * @param thrown - The thrown value to unserialize. + * @returns The unserialized error. + */ +export function unserializeError(thrown: unknown): Error | JsonRpcError { + // @ts-expect-error - New, but preferred if available. + // See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/isError + if (typeof Error.isError === 'function' && Error.isError(thrown)) { + return thrown as Error; + } + // Unlike Error.isError, instanceof does not work for Errors from other realms. + if (thrown instanceof Error) { + return thrown; + } + if (typeof thrown === 'string') { + return new Error(thrown); + } + if (!isObject(thrown)) { + return new Error(`Unknown error: ${stringify(thrown)}`); + } + + const code = + typeof thrown.code === 'number' && Number.isInteger(thrown.code) + ? thrown.code + : undefined; + + let message = 'Unknown error'; + if (typeof thrown.message === 'string') { + message = thrown.message; + } else if (typeof code === 'number') { + message = getMessageFromCode(code, message); + } + + const { stack, cause, data } = thrown; + + const error = + code === undefined + ? // Jest complains if we use the `@ts-expect-error` directive here. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Our error type is outdated. + new Error(message, { cause }) + : new JsonRpcError(code, message, { + ...(isObject(data) ? data : undefined), + cause, + }); + + if (typeof stack === 'string') { + error.stack = stack; + } + + return error; +} diff --git a/packages/json-rpc-engine/src/v2/index.test.ts b/packages/json-rpc-engine/src/v2/index.test.ts new file mode 100644 index 0000000000..a08a89058b --- /dev/null +++ b/packages/json-rpc-engine/src/v2/index.test.ts @@ -0,0 +1,17 @@ +import * as allExports from '.'; + +describe('@metamask/json-rpc-engine/v2', () => { + it('has expected JavaScript exports', () => { + expect(Object.keys(allExports)).toMatchInlineSnapshot(` + Array [ + "asLegacyMiddleware", + "getUniqueId", + "JsonRpcServer", + "isNotification", + "isRequest", + "JsonRpcEngineError", + "JsonRpcEngineV2", + ] + `); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/index.ts b/packages/json-rpc-engine/src/v2/index.ts new file mode 100644 index 0000000000..eb63fa05b7 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/index.ts @@ -0,0 +1,13 @@ +export { asLegacyMiddleware } from './asLegacyMiddleware'; +export { getUniqueId } from '../getUniqueId'; +export * from './JsonRpcEngineV2'; +export { JsonRpcServer } from './JsonRpcServer'; +export type { MiddlewareContext, EmptyContext } from './MiddlewareContext'; +export { isNotification, isRequest, JsonRpcEngineError } from './utils'; +export type { + Json, + JsonRpcCall, + JsonRpcNotification, + JsonRpcParams, + JsonRpcRequest, +} from './utils'; diff --git a/packages/json-rpc-engine/src/v2/utils.test.ts b/packages/json-rpc-engine/src/v2/utils.test.ts new file mode 100644 index 0000000000..f7fde4f0e0 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/utils.test.ts @@ -0,0 +1,71 @@ +import { + isRequest, + isNotification, + stringify, + JsonRpcEngineError, +} from './utils'; + +const jsonrpc = '2.0' as const; + +describe('utils', () => { + describe('isRequest', () => { + it.each([ + [ + { + jsonrpc, + id: 1, + method: 'eth_getBlockByNumber', + params: ['latest'], + }, + true, + ], + [ + { + jsonrpc, + method: 'eth_getBlockByNumber', + params: ['latest'], + }, + false, + ], + ])('returns $expected for $request', (request, expected) => { + expect(isRequest(request)).toBe(expected); + }); + }); + + describe('isNotification', () => { + it.each([ + [{ jsonrpc, method: 'eth_getBlockByNumber', params: ['latest'] }, true], + [ + { id: 1, jsonrpc, method: 'eth_getBlockByNumber', params: ['latest'] }, + false, + ], + ])('returns $expected for $request', (request, expected) => { + expect(isNotification(request)).toBe(expected); + }); + }); + + describe('stringify', () => { + it('stringifies a JSON object', () => { + expect(stringify({ foo: 'bar' })).toMatchInlineSnapshot(` + "{ + \\"foo\\": \\"bar\\" + }" + `); + }); + }); + + describe('JsonRpcEngineError', () => { + it('creates an error with the correct name', () => { + const error = new JsonRpcEngineError('test'); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('JsonRpcEngineError'); + expect(error.message).toBe('test'); + }); + + it('isInstance checks if a value is a JsonRpcEngineError instance', () => { + const error = new JsonRpcEngineError('test'); + expect(JsonRpcEngineError.isInstance(error)).toBe(true); + expect(JsonRpcEngineError.isInstance(new Error('test'))).toBe(false); + }); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/utils.ts b/packages/json-rpc-engine/src/v2/utils.ts new file mode 100644 index 0000000000..345c9907b1 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/utils.ts @@ -0,0 +1,76 @@ +import { + hasProperty, + type JsonRpcNotification, + type JsonRpcParams, + type JsonRpcRequest, +} from '@metamask/utils'; + +export type { + Json, + JsonRpcParams, + JsonRpcRequest, + JsonRpcNotification, +} from '@metamask/utils'; + +export type JsonRpcCall = + | JsonRpcNotification + | JsonRpcRequest; + +export const isRequest = ( + msg: JsonRpcCall | Readonly>, +): msg is JsonRpcRequest => hasProperty(msg, 'id'); + +export const isNotification = ( + msg: JsonRpcCall, +): msg is JsonRpcNotification => !isRequest(msg); + +/** + * An unholy incantation that converts a union of object types into an + * intersection of object types. + * + * @example + * type A = { a: string } | { b: number }; + * type B = UnionToIntersection; // { a: string } & { b: number } + */ +export type UnionToIntersection = ( + U extends never ? never : (k: U) => void +) extends (k: infer I) => void + ? I + : never; + +/** + * JSON-stringifies a value. + * + * @param value - The value to stringify. + * @returns The stringified value. + */ +export function stringify(value: unknown): string { + return JSON.stringify(value, null, 2); +} + +const JsonRpcEngineErrorSymbol = Symbol.for('JsonRpcEngineError'); + +export class JsonRpcEngineError extends Error { + private readonly [JsonRpcEngineErrorSymbol] = true; + + constructor(message: string) { + super(message); + this.name = 'JsonRpcEngineError'; + } + + /** + * Check if a value is a {@link JsonRpcEngineError} instance. + * Works across different package versions in the same realm. + * + * @param value - The value to check. + * @returns Whether the value is a {@link JsonRpcEngineError} instance. + */ + static isInstance( + value: Value, + ): value is Value & JsonRpcEngineError { + return ( + hasProperty(value, JsonRpcEngineErrorSymbol) && + value[JsonRpcEngineErrorSymbol] === true + ); + } +} diff --git a/packages/json-rpc-engine/tests/utils.ts b/packages/json-rpc-engine/tests/utils.ts new file mode 100644 index 0000000000..2ed886379a --- /dev/null +++ b/packages/json-rpc-engine/tests/utils.ts @@ -0,0 +1,67 @@ +import type { JsonRpcRequest } from '@metamask/utils'; +import type { JsonRpcMiddleware } from 'src/v2/JsonRpcEngineV2'; + +import { requestProps } from '../src/v2/compatibility-utils'; +import type { JsonRpcNotification } from '../src/v2/utils'; + +export const makeRequest = >( + params: Request = {} as Request, +) => + ({ + jsonrpc: '2.0', + id: '1', + method: 'test_request', + params: [], + ...params, + }) as const satisfies JsonRpcRequest; + +export const makeNotification = >( + params: Request = {} as Request, +) => + ({ + jsonrpc: '2.0', + method: 'test_request', + params: [], + ...params, + }) as JsonRpcNotification; + +/** + * Creates a {@link JsonRpcCall} middleware that returns `null`. + * + * @returns The middleware. + */ +export const makeNullMiddleware = (): JsonRpcMiddleware => { + return () => null; +}; + +/** + * Creates a {@link JsonRpcRequest} middleware that returns `null`. + * + * @returns The middleware. + */ +export const makeRequestMiddleware = (): JsonRpcMiddleware => { + return () => null; +}; + +/** + * Creates a {@link JsonRpcNotification} middleware that returns `undefined`. + * + * @returns The middleware. + */ +export const makeNotificationMiddleware = + (): JsonRpcMiddleware => { + return () => undefined; + }; + +/** + * Get the keys of a request that are not part of the standard JSON-RPC request + * properties. + * + * @param req - The request to get the extraneous keys from. + * @returns The extraneous keys. + */ +export function getExtraneousKeys(req: Record): string[] { + return Object.keys(req).filter( + (key) => !requestProps.find((requestProp) => requestProp === key), + ); +} diff --git a/packages/json-rpc-engine/v2.js b/packages/json-rpc-engine/v2.js new file mode 100644 index 0000000000..faf7abd236 --- /dev/null +++ b/packages/json-rpc-engine/v2.js @@ -0,0 +1,3 @@ +// Re-exported for compatibility with Browserify. +// eslint-disable-next-line +module.exports = require('./dist/v2/index.cjs'); diff --git a/yarn.lock b/yarn.lock index 20dc807154..f67c9bc6cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3845,10 +3845,13 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/safe-event-emitter": "npm:^3.0.0" "@metamask/utils": "npm:^11.8.1" + "@types/deep-freeze-strict": "npm:^1.1.0" "@types/jest": "npm:^27.4.1" + deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" jest-it-up: "npm:^2.0.2" + klona: "npm:^2.0.6" ts-jest: "npm:^27.1.4" typedoc: "npm:^0.24.8" typescript: "npm:~5.2.2"