From 0474ee1992d5d8a2f42486e240e694edfdae8344 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:49:25 +0000 Subject: [PATCH 1/7] feat(openrpc): JSON-RPC 2.0 handler, link, and OpenRPC generator Add @orpc/openrpc, which serves oRPC routers as a JSON-RPC 2.0 API over HTTP and WebSocket and describes them in OpenRPC documents. Streaming, files, and server-sent notifications are not supported. - OpenRPCHandler (fetch, node, fastify, aws-lambda, websocket): single calls, batches, and notifications, with the JSON-RPC protocol errors and ORPCErrors mapped to JSON-RPC error codes (errorCodeMap) - OpenRPCLink (fetch, websocket): calls any JSON-RPC 2.0 server and restores ORPCErrors, typesafe errors included - OpenRPCGenerator and openrpc() meta: OpenRPC 1.3 documents with by-name or by-position params, results, errors, components, examples, and a spec override, falling back to openapi() meta - OpenRPCDiscoveryHandlerPlugin: rpc.discover and a GET document endpoint - export OpenAPIComponentRegistry from @orpc/openapi for reuse - docs, including an OpenRPC adapters page, README tables, and tests mirroring the OpenAPI suite Refs #541 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ETTyGHAmagRW7GK4kevsX5 --- README.md | 1 + apps/content/docs/adapters/index.mdx | 2 +- apps/content/docs/api-reference.mdx | 1 + apps/content/docs/comparison.mdx | 3 +- apps/content/docs/meta.ts | 1 + apps/content/docs/openrpc/adapters.mdx | 31 ++ apps/content/docs/openrpc/handler.mdx | 149 ++++++ apps/content/docs/openrpc/index.mdx | 15 + apps/content/docs/openrpc/link.mdx | 54 ++ apps/content/docs/openrpc/meta.ts | 11 + apps/content/docs/openrpc/specification.mdx | 88 ++++ apps/content/pages/_home/Features.astro | 4 +- package.json | 1 + packages/ai-sdk/README.md | 1 + packages/arktype/README.md | 1 + packages/bun/README.md | 1 + packages/client/README.md | 1 + packages/cloudflare/README.md | 1 + packages/contract/README.md | 1 + packages/effect/README.md | 1 + packages/evlog/README.md | 1 + packages/hibernation/README.md | 1 + packages/json-schema/README.md | 1 + packages/lock/README.md | 1 + packages/mcp/README.md | 1 + packages/msw/README.md | 1 + packages/nest/README.md | 1 + packages/next/README.md | 1 + packages/node/README.md | 1 + packages/openapi/README.md | 1 + packages/openapi/src/index.test.ts | 3 +- packages/openapi/src/index.ts | 1 + .../src/openapi-generator-components.test.ts | 9 + .../src/openapi-generator-components.ts | 14 +- packages/openrpc/README.md | 226 +++++++++ packages/openrpc/package.json | 109 +++++ .../src/adapters/aws-lambda/index.test.ts | 5 + .../openrpc/src/adapters/aws-lambda/index.ts | 20 + .../aws-lambda/openrpc-handler.test.ts | 109 +++++ .../adapters/aws-lambda/openrpc-handler.ts | 27 + .../src/adapters/fastify/index.test.ts | 5 + .../openrpc/src/adapters/fastify/index.ts | 1 + .../adapters/fastify/openrpc-handler.test.ts | 102 ++++ .../src/adapters/fastify/openrpc-handler.ts | 24 + .../openrpc/src/adapters/fetch/index.test.ts | 6 + packages/openrpc/src/adapters/fetch/index.ts | 2 + .../adapters/fetch/openrpc-handler.test.ts | 98 ++++ .../src/adapters/fetch/openrpc-handler.ts | 24 + .../src/adapters/fetch/openrpc-link.test.ts | 169 +++++++ .../src/adapters/fetch/openrpc-link.ts | 21 + .../openrpc/src/adapters/node/index.test.ts | 5 + packages/openrpc/src/adapters/node/index.ts | 1 + .../src/adapters/node/openrpc-handler.test.ts | 67 +++ .../src/adapters/node/openrpc-handler.ts | 24 + .../src/adapters/standard/index.test.ts | 8 + .../openrpc/src/adapters/standard/index.ts | 5 + .../openrpc-envelope-handler-plugin.test.ts | 434 ++++++++++++++++ .../openrpc-envelope-handler-plugin.ts | 223 +++++++++ .../standard/openrpc-handler-codec.test.ts | 272 ++++++++++ .../standard/openrpc-handler-codec.ts | 150 ++++++ .../standard/openrpc-link-codec.test.ts | 328 +++++++++++++ .../adapters/standard/openrpc-link-codec.ts | 122 +++++ .../src/adapters/standard/utils.test.ts | 34 ++ .../openrpc/src/adapters/standard/utils.ts | 41 ++ .../src/adapters/websocket/index.test.ts | 7 + .../openrpc/src/adapters/websocket/index.ts | 3 + .../websocket/openrpc-handler.test-d.ts | 16 + .../websocket/openrpc-handler.test.ts | 221 +++++++++ .../src/adapters/websocket/openrpc-handler.ts | 105 ++++ .../adapters/websocket/openrpc-link.test.ts | 303 ++++++++++++ .../src/adapters/websocket/openrpc-link.ts | 21 + .../src/adapters/websocket/transport.ts | 117 +++++ .../openrpc/src/adapters/websocket/utils.ts | 10 + packages/openrpc/src/constants.test.ts | 22 + packages/openrpc/src/constants.ts | 53 ++ packages/openrpc/src/index.test.ts | 12 + packages/openrpc/src/index.ts | 5 + packages/openrpc/src/json-rpc.test.ts | 112 +++++ packages/openrpc/src/json-rpc.ts | 79 +++ packages/openrpc/src/meta.test.ts | 125 +++++ packages/openrpc/src/meta.ts | 100 ++++ .../openrpc/src/openrpc-generator.test-d.ts | 25 + .../openrpc/src/openrpc-generator.test.ts | 463 ++++++++++++++++++ packages/openrpc/src/openrpc-generator.ts | 268 ++++++++++ packages/openrpc/src/plugins/index.test.ts | 5 + packages/openrpc/src/plugins/index.ts | 1 + .../src/plugins/openrpc-discovery.test.ts | 194 ++++++++ .../openrpc/src/plugins/openrpc-discovery.ts | 68 +++ packages/openrpc/src/types.test-d.ts | 15 + packages/openrpc/src/types.ts | 91 ++++ packages/openrpc/src/utils.test.ts | 28 ++ packages/openrpc/src/utils.ts | 14 + packages/openrpc/tests/__shared__/schema.ts | 56 +++ .../composed-schemas.test.ts | 103 ++++ .../tests/openrpc-generator/crud.test.ts | 114 +++++ .../openrpc-generator/event-streaming.test.ts | 68 +++ .../openrpc-generator/file-transfer.test.ts | 58 +++ .../reusable-components.test.ts | 110 +++++ .../schema-libraries.test.ts | 87 ++++ .../openrpc-generator/typed-errors.test.ts | 72 +++ packages/openrpc/tsconfig.json | 19 + packages/opentelemetry/README.md | 1 + packages/pinia-colada/README.md | 1 + packages/pino/README.md | 1 + packages/publisher/README.md | 1 + packages/ratelimit/README.md | 1 + packages/server/README.md | 1 + packages/shared/README.md | 1 + packages/swr/README.md | 1 + packages/tanstack-query/README.md | 1 + packages/trpc/README.md | 1 + packages/valibot/README.md | 1 + packages/zod/README.md | 1 + pnpm-lock.yaml | 46 ++ skills/orpc/SKILL.md | 4 +- .../__shared__/client-server.hono-fetch.ts | 45 ++ .../__shared__/client-server.node-http.ts | 43 ++ .../__shared__/client-server.node-ws.ts | 39 ++ tests/openrpc/__shared__/client-server.ts | 16 + tests/openrpc/cancel-and-abort.test.ts | 78 +++ tests/openrpc/data-transfer.test.ts | 204 ++++++++ tests/plugins/all-plugins.test.ts | 21 + 122 files changed, 6738 insertions(+), 7 deletions(-) create mode 100644 apps/content/docs/openrpc/adapters.mdx create mode 100644 apps/content/docs/openrpc/handler.mdx create mode 100644 apps/content/docs/openrpc/index.mdx create mode 100644 apps/content/docs/openrpc/link.mdx create mode 100644 apps/content/docs/openrpc/meta.ts create mode 100644 apps/content/docs/openrpc/specification.mdx create mode 100644 packages/openrpc/README.md create mode 100644 packages/openrpc/package.json create mode 100644 packages/openrpc/src/adapters/aws-lambda/index.test.ts create mode 100644 packages/openrpc/src/adapters/aws-lambda/index.ts create mode 100644 packages/openrpc/src/adapters/aws-lambda/openrpc-handler.test.ts create mode 100644 packages/openrpc/src/adapters/aws-lambda/openrpc-handler.ts create mode 100644 packages/openrpc/src/adapters/fastify/index.test.ts create mode 100644 packages/openrpc/src/adapters/fastify/index.ts create mode 100644 packages/openrpc/src/adapters/fastify/openrpc-handler.test.ts create mode 100644 packages/openrpc/src/adapters/fastify/openrpc-handler.ts create mode 100644 packages/openrpc/src/adapters/fetch/index.test.ts create mode 100644 packages/openrpc/src/adapters/fetch/index.ts create mode 100644 packages/openrpc/src/adapters/fetch/openrpc-handler.test.ts create mode 100644 packages/openrpc/src/adapters/fetch/openrpc-handler.ts create mode 100644 packages/openrpc/src/adapters/fetch/openrpc-link.test.ts create mode 100644 packages/openrpc/src/adapters/fetch/openrpc-link.ts create mode 100644 packages/openrpc/src/adapters/node/index.test.ts create mode 100644 packages/openrpc/src/adapters/node/index.ts create mode 100644 packages/openrpc/src/adapters/node/openrpc-handler.test.ts create mode 100644 packages/openrpc/src/adapters/node/openrpc-handler.ts create mode 100644 packages/openrpc/src/adapters/standard/index.test.ts create mode 100644 packages/openrpc/src/adapters/standard/index.ts create mode 100644 packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.test.ts create mode 100644 packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.ts create mode 100644 packages/openrpc/src/adapters/standard/openrpc-handler-codec.test.ts create mode 100644 packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts create mode 100644 packages/openrpc/src/adapters/standard/openrpc-link-codec.test.ts create mode 100644 packages/openrpc/src/adapters/standard/openrpc-link-codec.ts create mode 100644 packages/openrpc/src/adapters/standard/utils.test.ts create mode 100644 packages/openrpc/src/adapters/standard/utils.ts create mode 100644 packages/openrpc/src/adapters/websocket/index.test.ts create mode 100644 packages/openrpc/src/adapters/websocket/index.ts create mode 100644 packages/openrpc/src/adapters/websocket/openrpc-handler.test-d.ts create mode 100644 packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts create mode 100644 packages/openrpc/src/adapters/websocket/openrpc-handler.ts create mode 100644 packages/openrpc/src/adapters/websocket/openrpc-link.test.ts create mode 100644 packages/openrpc/src/adapters/websocket/openrpc-link.ts create mode 100644 packages/openrpc/src/adapters/websocket/transport.ts create mode 100644 packages/openrpc/src/adapters/websocket/utils.ts create mode 100644 packages/openrpc/src/constants.test.ts create mode 100644 packages/openrpc/src/constants.ts create mode 100644 packages/openrpc/src/index.test.ts create mode 100644 packages/openrpc/src/index.ts create mode 100644 packages/openrpc/src/json-rpc.test.ts create mode 100644 packages/openrpc/src/json-rpc.ts create mode 100644 packages/openrpc/src/meta.test.ts create mode 100644 packages/openrpc/src/meta.ts create mode 100644 packages/openrpc/src/openrpc-generator.test-d.ts create mode 100644 packages/openrpc/src/openrpc-generator.test.ts create mode 100644 packages/openrpc/src/openrpc-generator.ts create mode 100644 packages/openrpc/src/plugins/index.test.ts create mode 100644 packages/openrpc/src/plugins/index.ts create mode 100644 packages/openrpc/src/plugins/openrpc-discovery.test.ts create mode 100644 packages/openrpc/src/plugins/openrpc-discovery.ts create mode 100644 packages/openrpc/src/types.test-d.ts create mode 100644 packages/openrpc/src/types.ts create mode 100644 packages/openrpc/src/utils.test.ts create mode 100644 packages/openrpc/src/utils.ts create mode 100644 packages/openrpc/tests/__shared__/schema.ts create mode 100644 packages/openrpc/tests/openrpc-generator/composed-schemas.test.ts create mode 100644 packages/openrpc/tests/openrpc-generator/crud.test.ts create mode 100644 packages/openrpc/tests/openrpc-generator/event-streaming.test.ts create mode 100644 packages/openrpc/tests/openrpc-generator/file-transfer.test.ts create mode 100644 packages/openrpc/tests/openrpc-generator/reusable-components.test.ts create mode 100644 packages/openrpc/tests/openrpc-generator/schema-libraries.test.ts create mode 100644 packages/openrpc/tests/openrpc-generator/typed-errors.test.ts create mode 100644 packages/openrpc/tsconfig.json create mode 100644 tests/openrpc/__shared__/client-server.hono-fetch.ts create mode 100644 tests/openrpc/__shared__/client-server.node-http.ts create mode 100644 tests/openrpc/__shared__/client-server.node-ws.ts create mode 100644 tests/openrpc/__shared__/client-server.ts create mode 100644 tests/openrpc/cancel-and-abort.test.ts create mode 100644 tests/openrpc/data-transfer.test.ts diff --git a/README.md b/README.md index 781f8128eb..aaa394fcd7 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/apps/content/docs/adapters/index.mdx b/apps/content/docs/adapters/index.mdx index 1f567ca0a5..158a0b4bcd 100644 --- a/apps/content/docs/adapters/index.mdx +++ b/apps/content/docs/adapters/index.mdx @@ -7,7 +7,7 @@ sidebar: hidden: true --- -An adapter connects a handler to where your code runs. The router stays the same, so switching hosts only changes the few lines that mount the handler. HTTP adapters serve both [RPC](/docs/rpc/adapters) and [OpenAPI](/docs/openapi/adapters); WebSocket and Message Port adapters serve RPC only. +An adapter connects a handler to where your code runs. The router stays the same, so switching hosts only changes the few lines that mount the handler. HTTP adapters serve [RPC](/docs/rpc/adapters), [OpenAPI](/docs/openapi/adapters), and [OpenRPC](/docs/openrpc/adapters); WebSocket adapters serve RPC and OpenRPC, and Message Port adapters serve RPC only. ## Core diff --git a/apps/content/docs/api-reference.mdx b/apps/content/docs/api-reference.mdx index a0d1ed2992..3c7368aa8d 100644 --- a/apps/content/docs/api-reference.mdx +++ b/apps/content/docs/api-reference.mdx @@ -19,6 +19,7 @@ For questions the reference does not answer, [oRPC on DeepWiki](https://deepwiki | [@orpc/server](https://npmx.dev/package-docs/@orpc/server) | Build APIs or implement contracts. | [Procedure](/docs/procedure), [Router](/docs/router), [Middleware](/docs/middleware), [Context](/docs/context), [RPC Handler](/docs/rpc/handler) | | [@orpc/client](https://npmx.dev/package-docs/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](/docs/client/client-side), [Server-Side Clients](/docs/client/server-side), [RPC Link](/docs/rpc/link), [Error Handling](/docs/client/error-handling) | | [@orpc/openapi](https://npmx.dev/package-docs/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](/docs/openapi/handler), [OpenAPI Link](/docs/openapi/link), [Specification](/docs/openapi/specification), [Scalar](/docs/openapi/scalar) | +| [@orpc/openrpc](https://npmx.dev/package-docs/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](/docs/openrpc/handler), [OpenRPC Link](/docs/openrpc/link), [Specification](/docs/openrpc/specification) | ## Schema Validation diff --git a/apps/content/docs/comparison.mdx b/apps/content/docs/comparison.mdx index e9ab5c61c3..4086e28b6c 100644 --- a/apps/content/docs/comparison.mdx +++ b/apps/content/docs/comparison.mdx @@ -36,13 +36,14 @@ oRPC types errors per procedure or globally: throw a plain `ORPCError`, [declare | Implementation-first approach | ✅ | ✅ | ✅ | | [Contract-first approach](/docs/contract-first) | ✅ | 🛑 | 🟡 | | [OpenAPI spec generation](/docs/openapi/specification) | ✅ | 🟡 | 🟡 | +| [JSON-RPC 2.0 & OpenRPC](/docs/openrpc/handler) | ✅ | 🛑 | 🛑 | | [Standard Schema (Zod, Valibot, ArkType, ...)](/docs/integrations/standard-schema) | ✅ | ✅ | 🟡 | | [Native types (Date, URL, Set, Map, ...)](/docs/rpc/serializer#supported-data-types) | ✅ | 🟡 | 🛑 | | [Custom serializers](/docs/rpc/serializer#custom-serializers) | ✅ | ✅ | 🛑 | | [Bracket notation](/docs/openapi/bracket-notation) | ✅ | 🛑 | 🛑 | | [Lazy router (code splitting, faster cold starts)](/docs/router#lazy-router) | ✅ | ✅ | 🛑 | -oRPC serves the same procedures over its [RPC protocol](/docs/rpc/protocol) and RESTful [OpenAPI endpoints](/docs/openapi/handler), and generates the spec with a first-party package. tRPC's `@trpc/openapi` is alpha and covers only tRPC-style endpoints, and Hono relies on third-party middleware like `@hono/zod-openapi`. Native types like `Date` or `Map` work out of the box in oRPC; tRPC needs a transformer, and Hono has no serialization layer: what you pass to `c.json()` is what you get. +oRPC serves the same procedures over its [RPC protocol](/docs/rpc/protocol), RESTful [OpenAPI endpoints](/docs/openapi/handler), and [JSON-RPC 2.0](/docs/openrpc/handler) with an [OpenRPC document](/docs/openrpc/specification), and generates the OpenAPI spec with a first-party package. tRPC's `@trpc/openapi` is alpha and covers only tRPC-style endpoints, and Hono relies on third-party middleware like `@hono/zod-openapi`. Native types like `Date` or `Map` work out of the box in oRPC; tRPC needs a transformer, and Hono has no serialization layer: what you pass to `c.json()` is what you get. ### Integrations & Ecosystem diff --git a/apps/content/docs/meta.ts b/apps/content/docs/meta.ts index 7da02683cf..16184d4d14 100644 --- a/apps/content/docs/meta.ts +++ b/apps/content/docs/meta.ts @@ -19,6 +19,7 @@ export default defineMeta({ 'metadata', 'rpc', 'openapi', + 'openrpc', 'contract', 'client', 'adapters', diff --git a/apps/content/docs/openrpc/adapters.mdx b/apps/content/docs/openrpc/adapters.mdx new file mode 100644 index 0000000000..185fad143e --- /dev/null +++ b/apps/content/docs/openrpc/adapters.mdx @@ -0,0 +1,31 @@ +--- +title: "OpenRPC Adapters" +description: "The HTTP and WebSocket adapters that run OpenRPC Handler and OpenRPC Link for JSON-RPC 2.0, and where to import each one." +--- + +OpenRPC runs over HTTP and WebSocket. Pick the adapter for your transport, then import [OpenRPC Handler](/docs/openrpc/handler) or [OpenRPC Link](/docs/openrpc/link) from it. For message ports, use [RPC](/docs/rpc/adapters). + +## Server Adapters + +| Adapter | Import `OpenRPCHandler` from | +| --------------------------------------- | ---------------------------- | +| [Fetch API](/docs/adapters/fetch-api) | `@orpc/openrpc/fetch` | +| [Node HTTP](/docs/adapters/node-http) | `@orpc/openrpc/node` | +| [Fastify](/docs/adapters/fastify) | `@orpc/openrpc/fastify` | +| [AWS Lambda](/docs/adapters/aws-lambda) | `@orpc/openrpc/aws-lambda` | +| [WebSocket](/docs/adapters/websocket) | `@orpc/openrpc/websocket` | + +The HTTP adapters answer `POST` requests to the `prefix` path itself, such as `/rpc`, so mount the handler on a route that matches it. In Fastify, use `app.all('/rpc', ...)`: `/rpc/*` does not match `/rpc`, and a `POST`-only route would keep [CORS](/docs/plugins/cors) preflight requests from reaching the handler. The WebSocket adapter answers each message on its socket, also from crossws hooks, see [OpenRPC Handler over WebSocket](/docs/openrpc/handler#websocket). + +## Client Adapters + +| Adapter | Import `OpenRPCLink` from | Calls | +| ------------------------------------- | ------------------------- | ------------------------ | +| [Fetch API](/docs/adapters/fetch-api) | `@orpc/openrpc/fetch` | Any HTTP server adapter | +| [WebSocket](/docs/adapters/websocket) | `@orpc/openrpc/websocket` | WebSocket server adapter | + +Both also call any other JSON-RPC 2.0 server over the same transport. + +## Frameworks and Platforms + +Every framework and platform adapter built on the Fetch API or Node HTTP also serves OpenRPC, such as [Next.js](/docs/adapters/next), [Hono](/docs/adapters/hono), [Express.js](/docs/adapters/express), and [Cloudflare Workers](/docs/adapters/cloudflare-workers). Where an example uses `RPCHandler`, swap in `OpenRPCHandler` from the matching import above. See [Adapters](/docs/adapters) for the full list. diff --git a/apps/content/docs/openrpc/handler.mdx b/apps/content/docs/openrpc/handler.mdx new file mode 100644 index 0000000000..e6a658fd6e --- /dev/null +++ b/apps/content/docs/openrpc/handler.mdx @@ -0,0 +1,149 @@ +--- +title: "OpenRPC Handler" +description: "Use OpenRPCHandler to serve oRPC procedures as a JSON-RPC 2.0 API, for OpenRPC Link or any other JSON-RPC client." +--- + +## Overview + +`OpenRPCHandler` serves your [router](/docs/router) as a [JSON-RPC 2.0](https://www.jsonrpc.org/specification) API over HTTP `POST`, for [OpenRPC Link](/docs/openrpc/link) or any other JSON-RPC client. Pair it with the [OpenRPC Generator](/docs/openrpc/specification) to describe the API in an OpenRPC document. + +```package-install +npm install @orpc/openrpc@beta +``` + +```ts +import { OpenRPCHandler } from '@orpc/openrpc/fetch' // or '@orpc/openrpc/node', '@orpc/openrpc/fastify', '@orpc/openrpc/aws-lambda' + +const handler = new OpenRPCHandler(router) + +export async function fetch(request: Request) { + const { matched, response } = await handler.handle(request, { + prefix: '/rpc', + context: {}, // Provide initial context if needed + }) + + if (matched) { + return response + } + + return new Response('Not Found', { status: 404 }) +} +``` + +The handler answers `POST` requests to the `prefix` path itself, here `/rpc`, so mount it on a route that matches that path in your [adapter](/docs/openrpc/adapters). Other requests are not matched, so other handlers or plugins can serve them. Requests must use `Content-Type: application/json` (or `application/json-rpc`), otherwise they are rejected with `415`, which also protects the endpoint from CSRF. + +```bash +curl -X POST http://localhost:3000/rpc \ + -H 'content-type: application/json' \ + -d '{"jsonrpc": "2.0", "method": "planet.find", "params": {"id": 1}, "id": 1}' +``` + +:::warning +JSON-RPC over HTTP is request/response only: procedures with an [event iterator](/docs/async-iterator-object) input or output are not exposed, and inputs or outputs containing files or streams cannot be sent. Server-sent notifications are not supported. +::: + +## Methods and Params + +Each procedure is a method named after its path joined by `.`, such as `planet.find`. Names starting with `rpc.` are reserved by JSON-RPC. + +| `params` | Procedure input | +| ---------------------- | ---------------------------------------------------------------- | +| `{ "id": 1 }` | `{ id: 1 }` (by-name) | +| `[x]` | `x` (by-position), for inputs that are not objects | +| absent, `[]`, or `{}` | `undefined` or `{}`, whichever the input schema accepts | +| `[x, y]` | rejected with `-32602`, since a procedure takes a single input | + +Results and error data are serialized like [OpenAPI responses](/docs/openapi/serializer), so a `Date` becomes an ISO string, and an `undefined` result is sent as `null`. Use the [Smart Coercion Plugin](/docs/plugins/smart-coercion) to restore native types from params. + +## Error Codes + +An `ORPCError` is answered with a JSON-RPC error whose `data` carries the oRPC error, so [OpenRPC Link](/docs/openrpc/link) restores it, including [typesafe errors](/docs/error-handling): + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32004, + "message": "Planet not found", + "data": { "code": "NOT_FOUND", "defined": true, "data": { "id": 2 } } + } +} +``` + +Error codes come from `COMMON_JSON_RPC_ERROR_CODE_MAP`: `BAD_REQUEST`, including input validation errors, is `-32602` "Invalid params", other `4xx` errors are `-32000 - (status - 400)`, such as `-32004` for `NOT_FOUND`, `5xx` errors are `-32603` "Internal error", and codes missing from the map are `-32000`. Use `errorCodeMap` to change it, and pass the same map to the [OpenRPC Generator](/docs/openrpc/specification): + +```ts +import { COMMON_JSON_RPC_ERROR_CODE_MAP } from '@orpc/openrpc' + +const handler = new OpenRPCHandler(router, { + errorCodeMap: { ...COMMON_JSON_RPC_ERROR_CODE_MAP, OUT_OF_STOCK: 1001 }, +}) +``` + +Requests that are not valid JSON-RPC are answered with the protocol errors `-32700` "Parse error", `-32600` "Invalid Request", or `-32601` "Method not found". JSON-RPC errors use HTTP status `200`. + +## Batch Requests and Notifications + +A JSON array of requests is a batch: its calls run concurrently and the responses keep the request order. A request without `id` is a notification: it runs to completion, but is never answered, and a request with only notifications is answered with `204`. Batches are limited to `maxBatchSize` requests (default `100`), larger ones are rejected with `413`. + +```ts +const handler = new OpenRPCHandler(router, { + maxBatchSize: 20, +}) +``` + +## WebSocket + +`OpenRPCHandler` from `@orpc/openrpc/websocket` serves the same API over a WebSocket connection, for the same WebSocket implementations as the [WebSocket Adapters](/docs/adapters/websocket). Each message is a request or a batch, answered with a message on the same socket, and notifications are not answered: + +```ts +import { WebSocketServer } from 'ws' +import { OpenRPCHandler } from '@orpc/openrpc/websocket' + +const handler = new OpenRPCHandler(router) + +const wss = new WebSocketServer({ port: 8080 }) + +wss.on('connection', (ws) => { + handler.upgrade(ws, { + context: {}, // Provide initial context if needed, resolved for each message + }) +}) +``` + +Responses to concurrent calls can arrive in any order, matched to their requests by `id`. When the socket closes, the `signal` of its in-flight calls is aborted. Use `.message(ws, data)` and `.close(ws)` instead of `upgrade` when your server hands you the messages, such as with Bun, Cloudflare Hibernation WebSocket, or [crossws](https://github.com/h3js/crossws): + +```ts +import crossws from 'crossws/adapters/node' // any crossws adapter is supported + +const ws = crossws({ + hooks: { + message: async (peer, message) => { + await handler.message(peer, message.text()) + }, + close: (peer) => { + handler.close(peer) + }, + }, +}) +``` + +## Interceptors + +[Interceptors](/docs/rpc/handler#interceptors) and plugins run once per JSON-RPC call, and see a sub-request per call. Use `getJsonRpcCall` to read the call: + +```ts +import { getJsonRpcCall } from '@orpc/openrpc/standard' + +const handler = new OpenRPCHandler(router, { + interceptors: [ + async ({ next, request }) => { + const call = getJsonRpcCall(request) // { method: 'planet.find', id: 1 } + return next() + }, + ], +}) +``` + +Plugins that work on the HTTP request itself, such as [CORS](/docs/plugins/cors), [Request Limit](/docs/plugins/request-limit), and [Response Headers](/docs/plugins/response-headers), run once per HTTP request. The response headers of the calls in a batch are merged. The [Batch Plugin](/docs/plugins/batch) is not needed, since JSON-RPC batches requests natively. diff --git a/apps/content/docs/openrpc/index.mdx b/apps/content/docs/openrpc/index.mdx new file mode 100644 index 0000000000..7a6e5a9893 --- /dev/null +++ b/apps/content/docs/openrpc/index.mdx @@ -0,0 +1,15 @@ +--- +title: "OpenRPC" +description: "Serve oRPC procedures as a JSON-RPC 2.0 API for any JSON-RPC client, call it with a typesafe link, and describe it in an OpenRPC document." +seo: + title: "OpenRPC: JSON-RPC 2.0 Handler, Link, and Specification" +sidebar: + hidden: true +--- + +OpenRPC serves the same router as a [JSON-RPC 2.0](https://www.jsonrpc.org/specification) API, for clients and tools of the JSON-RPC ecosystem. For oRPC's own protocol, see [RPC](/docs/rpc), and for a REST API, see [OpenAPI](/docs/openapi). + +- [OpenRPC Handler](/docs/openrpc/handler): serve procedures as JSON-RPC methods, with batches and notifications. +- [OpenRPC Link](/docs/openrpc/link): call a JSON-RPC server from a typesafe client. +- [OpenRPC Adapters](/docs/openrpc/adapters): the HTTP and WebSocket adapters that run them. +- [OpenRPC Specification](/docs/openrpc/specification): generate an OpenRPC document and serve it with service discovery. diff --git a/apps/content/docs/openrpc/link.mdx b/apps/content/docs/openrpc/link.mdx new file mode 100644 index 0000000000..2c07fede3d --- /dev/null +++ b/apps/content/docs/openrpc/link.mdx @@ -0,0 +1,54 @@ +--- +title: "OpenRPC Link" +description: "Use OpenRPCLink to call OpenRPCHandler, or any other JSON-RPC 2.0 server, from a typesafe oRPC client that restores typed errors." +--- + +## Overview + +`OpenRPCLink` sends each call as a JSON-RPC 2.0 request over HTTP `POST`, whose `method` is the procedure path joined by `.`. It needs no contract, so it can call [OpenRPC Handler](/docs/openrpc/handler) or any other JSON-RPC server. + +```ts +import type { JsonifiedClient } from '@orpc/openapi' +import type { RouterClient } from '@orpc/server' +import { createORPCClient } from '@orpc/client' +import { OpenRPCLink } from '@orpc/openrpc/fetch' + +const link = new OpenRPCLink({ + url: '/rpc', + origin: 'http://localhost:3000', + headers: () => ({ authorization: 'Bearer token' }), +}) + +const client: JsonifiedClient> = createORPCClient(link) + +const planet = await client.planet.find({ id: 1 }) // { "jsonrpc": "2.0", "id": 1, "method": "planet.find", "params": { "id": 1 } } +``` + +An object input is sent as by-name `params`, any other input as `[input]`, and an `undefined` input without `params`. + +:::info +Results are plain JSON, so use `JsonifiedClient` from `@orpc/openapi` to type them, such as a `Date` as a string. An `undefined` output arrives as `null`, although `JsonifiedClient` still types it as `undefined`, so check it with `== null`. With a [contract](/docs/contract-first), the [Smart Coercion Plugin](/docs/plugins/smart-coercion) can restore native types instead. +::: + +## Error Handling + +Errors from oRPC servers are restored as the original `ORPCError`, so [typesafe errors](/docs/client/error-handling) work as usual. Errors from other servers become an `ORPCError` with the code that matches their JSON-RPC error code (`INTERNAL_SERVER_ERROR` when none does), their message, and their `data`. + +Since JSON-RPC errors arrive with HTTP status `200`, the error's `cause` gets the HTTP status of its code, so the [Retry Plugin](/docs/plugins/retry) retries server errors but not client errors. A response that is not a JSON-RPC response for the request throws a `MalformedResponseError`. + +## WebSocket + +`OpenRPCLink` from `@orpc/openrpc/websocket` sends the calls as messages on a WebSocket connection, such as to an [OpenRPC Handler over WebSocket](/docs/openrpc/handler#websocket), and matches the responses by `id`: + +```ts +import { createORPCClient } from '@orpc/client' +import { OpenRPCLink } from '@orpc/openrpc/websocket' + +const link = new OpenRPCLink({ + connect: () => new WebSocket('ws://localhost:8080'), +}) + +const client: JsonifiedClient> = createORPCClient(link) +``` + +`connect` is called on the first call. When the socket closes, its pending calls fail, and the next call connects again. Aborting a call stops waiting for its response, but JSON-RPC cannot cancel it on the server. diff --git a/apps/content/docs/openrpc/meta.ts b/apps/content/docs/openrpc/meta.ts new file mode 100644 index 0000000000..489ad5e2a0 --- /dev/null +++ b/apps/content/docs/openrpc/meta.ts @@ -0,0 +1,11 @@ +import { defineMeta } from 'blume' + +export default defineMeta({ + title: 'OpenRPC', + pages: [ + 'handler', + 'link', + 'adapters', + 'specification', + ], +}) diff --git a/apps/content/docs/openrpc/specification.mdx b/apps/content/docs/openrpc/specification.mdx new file mode 100644 index 0000000000..d2a55493b5 --- /dev/null +++ b/apps/content/docs/openrpc/specification.mdx @@ -0,0 +1,88 @@ +--- +title: "OpenRPC Specification" +description: "Generate OpenRPC documents from oRPC contracts and routers with OpenRPCGenerator and openrpc metadata, and serve them with service discovery." +--- + +## OpenRPC Generator + +`OpenRPCGenerator` describes a contract or router as an [OpenRPC](https://spec.open-rpc.org) 1.3 document, with JSON Schemas from your [schema converters](/docs/openapi/specification#openapi-generator): + +```ts +import { OpenRPCGenerator } from '@orpc/openrpc' +import { ZodToJsonSchemaConverter } from '@orpc/zod' + +const generator = new OpenRPCGenerator({ + converters: [new ZodToJsonSchemaConverter()], +}) + +const spec = await generator.generate(router, { + base: { + info: { title: 'Planets', version: '1.0.0' }, + servers: [{ url: 'https://example.com/rpc' }], + }, + filter: (procedure, path) => !path.includes('internal'), +}) +``` + +Each procedure becomes a method, in the same way [OpenRPC Handler](/docs/openrpc/handler#methods-and-params) maps them: + +- **Params**: an object input is described as one by-name param per property, any other input as a single by-position `input` param, and a method without input has no params. +- **Result**: the output schema, which also allows `null` when the output is optional. +- **Errors**: the [typesafe errors](/docs/error-handling) with their JSON-RPC codes. Pass the handler's `errorCodeMap` to the generator too. +- **Metadata**: from [`openrpc` metadata](#metadata). + +Procedures with an [event iterator](/docs/async-iterator-object) are skipped, as the handler does not serve them. Reusable schemas, such as Zod schemas with `.meta({ id: 'Planet' })`, are placed in `components.schemas`. + +## Metadata + +Use `openrpc` metadata to describe a procedure's method. Fields left undefined fall back to its [`openapi` metadata](/docs/openapi/specification#metadata), so procedures already documented for OpenAPI need no extra metadata. + +```ts +import { openrpc } from '@orpc/openrpc' + +const findPlanet = os + .meta(openrpc({ + summary: 'Find a planet', + description: 'Returns a single planet.', + tags: ['planets'], + deprecated: false, + externalDocs: { url: 'https://example.com/planets' }, + examples: [ + { name: 'Earth', input: { id: 1 }, output: { id: 1, name: 'Earth' } }, + ], + })) + .input(z.object({ id: z.number() })) + .handler(({ input }) => getPlanet(input.id)) +``` + +Example inputs are described as params the same way the method's params are, here `[{ "name": "id", "value": 1 }]`. As on the wire, `undefined` params are left out and an `undefined` output is shown as `null`. + +Use `spec` to customize the generated Method Object: an object replaces it, and a function receives and returns it: + +```ts +const findPlanet = os + .meta(openrpc({ + spec: method => ({ ...method, 'x-rate-limit': 100 }), + })) +``` + +When `openrpc` is applied multiple times, fields are overridden by the most recent call, except `tags`, which are concatenated, and `spec`, which is combined: two functions are chained, a function combined with an object is applied to that object, and between two objects the most recent one wins. + +## Service Discovery + +`OpenRPCDiscoveryHandlerPlugin` answers the `rpc.discover` method with the document, as the [OpenRPC Service Discovery](https://spec.open-rpc.org/#service-discovery-method) method, and also serves it over `GET` at `/openrpc.json`, relative to the prefix: + +```ts +import { OpenRPCDiscoveryHandlerPlugin } from '@orpc/openrpc/plugins' + +const handler = new OpenRPCHandler(router, { + plugins: [ + new OpenRPCDiscoveryHandlerPlugin({ + spec: () => generator.generate(router), // memoize it for production + specPath: '/openrpc.json', // or false to disable the GET endpoint + }), + ], +}) +``` + +Tools such as the [OpenRPC Playground](https://playground.open-rpc.org) can then load the document from `http://localhost:3000/rpc/openrpc.json`. diff --git a/apps/content/pages/_home/Features.astro b/apps/content/pages/_home/Features.astro index 264fe5f599..ecf7fffef0 100644 --- a/apps/content/pages/_home/Features.astro +++ b/apps/content/pages/_home/Features.astro @@ -38,8 +38,8 @@ const features = [ }, { icon: 'route', - title: 'REST and OpenAPI', - body: 'The same router answers REST calls and writes its own OpenAPI document.', + title: 'REST, JSON-RPC, and OpenAPI', + body: 'The same router answers REST and JSON-RPC calls and writes its own OpenAPI and OpenRPC documents.', }, { icon: 'bot', diff --git a/package.json b/package.json index b23511b37e..3ed69caa67 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@orpc/next": "workspace:*", "@orpc/node": "workspace:*", "@orpc/openapi": "workspace:*", + "@orpc/openrpc": "workspace:*", "@orpc/opentelemetry": "workspace:*", "@orpc/pino": "workspace:*", "@orpc/publisher": "workspace:*", diff --git a/packages/ai-sdk/README.md b/packages/ai-sdk/README.md index 8d9d3e575c..eca6c312fd 100644 --- a/packages/ai-sdk/README.md +++ b/packages/ai-sdk/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/arktype/README.md b/packages/arktype/README.md index 301d17abaf..bfa357c721 100644 --- a/packages/arktype/README.md +++ b/packages/arktype/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/bun/README.md b/packages/bun/README.md index 8953d69b9c..36ab8c2d49 100644 --- a/packages/bun/README.md +++ b/packages/bun/README.md @@ -63,6 +63,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/client/README.md b/packages/client/README.md index 781f8128eb..aaa394fcd7 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index a2275a05c2..dcae0bc301 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/contract/README.md b/packages/contract/README.md index fd26601f37..37f9e5c30c 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/effect/README.md b/packages/effect/README.md index 0929b8f709..5c0e0b34a6 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/evlog/README.md b/packages/evlog/README.md index 3f1a3d29c5..e985a2e81a 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/hibernation/README.md b/packages/hibernation/README.md index 19b471f61e..719adaf6ca 100644 --- a/packages/hibernation/README.md +++ b/packages/hibernation/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/json-schema/README.md b/packages/json-schema/README.md index b90e061b0e..24fa68ece5 100644 --- a/packages/json-schema/README.md +++ b/packages/json-schema/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/lock/README.md b/packages/lock/README.md index 150053bc95..139afd4576 100644 --- a/packages/lock/README.md +++ b/packages/lock/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 0f95ea0f29..b1484d3102 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/msw/README.md b/packages/msw/README.md index e1e1aa957c..c46a296faa 100644 --- a/packages/msw/README.md +++ b/packages/msw/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/nest/README.md b/packages/nest/README.md index fd8f133ff4..a8c2b965a9 100644 --- a/packages/nest/README.md +++ b/packages/nest/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/next/README.md b/packages/next/README.md index fa9139a1f6..fa39181d64 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/node/README.md b/packages/node/README.md index b4d687de2b..e8d4c11598 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/openapi/README.md b/packages/openapi/README.md index 95e7ac4403..10e9770f5b 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/openapi/src/index.test.ts b/packages/openapi/src/index.test.ts index c4c7f8558e..ed6d07d4d8 100644 --- a/packages/openapi/src/index.test.ts +++ b/packages/openapi/src/index.test.ts @@ -1,7 +1,8 @@ -it('exports openapi meta, OpenAPIGenerator, OpenAPISerializer, OpenAPIJsonSerializer, BracketNotationSerializer', async () => { +it('exports openapi meta, OpenAPIGenerator, OpenAPIComponentRegistry, OpenAPISerializer, OpenAPIJsonSerializer, BracketNotationSerializer', async () => { await expect(import('.')).resolves.toMatchObject({ openapi: expect.any(Function), OpenAPIGenerator: expect.any(Function), + OpenAPIComponentRegistry: expect.any(Function), OpenAPISerializer: expect.any(Function), OpenAPIJsonSerializer: expect.any(Function), BracketNotationSerializer: expect.any(Function), diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index 261fb78de6..db53fb0f84 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -3,6 +3,7 @@ export * from './client-factory' export { DEFAULT_OPENAPI_METHOD } from './constants' export * from './meta' export * from './openapi-generator' +export * from './openapi-generator-components' export * from './openapi-json-serializer' export * from './openapi-serializer' export * from './router-utils' diff --git a/packages/openapi/src/openapi-generator-components.test.ts b/packages/openapi/src/openapi-generator-components.test.ts index 814c4ec07a..691baceb24 100644 --- a/packages/openapi/src/openapi-generator-components.test.ts +++ b/packages/openapi/src/openapi-generator-components.test.ts @@ -1,4 +1,5 @@ import type { JsonSchema } from '@orpc/json-schema' +import type { OpenAPIComponentRegistryDocument } from './openapi-generator-components' import type { OpenAPIV3_2 } from './types' import { OpenAPIComponentRegistry } from './openapi-generator-components' @@ -867,4 +868,12 @@ describe('openAPIComponentRegistry', () => { expect(doc.components?.schemas).toEqual({ Planet: { type: 'string' } }) }) }) + + it('collects components into a non-OpenAPI document', () => { + const doc: OpenAPIComponentRegistryDocument & { openrpc: string } = { openrpc: '1.3.2' } + const registry = new OpenAPIComponentRegistry(doc, undefined) + + expect(registry.register('Planet', { type: 'string' })).toEqual({ $ref: '#/components/schemas/Planet' }) + expect(doc).toEqual({ openrpc: '1.3.2', components: { schemas: { Planet: { type: 'string' } } } }) + }) }) diff --git a/packages/openapi/src/openapi-generator-components.ts b/packages/openapi/src/openapi-generator-components.ts index a4f264997f..9e2b46e013 100644 --- a/packages/openapi/src/openapi-generator-components.ts +++ b/packages/openapi/src/openapi-generator-components.ts @@ -11,6 +11,18 @@ import { getOwn, isDeepEqual, isPlainObject, setOwn } from '@orpc/shared' const DEFS_REF_PREFIX = '#/$defs/' const COMPONENTS_REF_PREFIX = '#/components/schemas/' +/** + * The part of a document {@link OpenAPIComponentRegistry} reads and writes: `components.schemas`. + * + * Any document that keeps reusable schemas under `#/components/schemas/`, such as an OpenAPI + * or OpenRPC document, satisfies it. + */ +export interface OpenAPIComponentRegistryDocument { + components?: { + schemas?: Record | undefined + } | undefined +} + /** * Collects reusable schemas into `doc.components.schemas`. * @@ -19,7 +31,7 @@ const COMPONENTS_REF_PREFIX = '#/components/schemas/' */ export class OpenAPIComponentRegistry { constructor( - private readonly doc: OpenAPIV3_2.OpenAPIObject, + private readonly doc: OpenAPIComponentRegistryDocument, private readonly componentName: ((defName: string, defSchema: JsonSchema) => string | undefined) | undefined, ) {} diff --git a/packages/openrpc/README.md b/packages/openrpc/README.md new file mode 100644 index 0000000000..f573cf5519 --- /dev/null +++ b/packages/openrpc/README.md @@ -0,0 +1,226 @@ +

oRPC - Typesafe APIs Made Simple 🪄

+ + + +**oRPC** is a TypeScript framework for building end-to-end typesafe APIs. Write your API once as plain TypeScript functions, and oRPC gives you a fully typed client, a REST API with an OpenAPI document, and MCP tools for AI agents, with no code generation. It runs on Node.js, Bun, Deno, Cloudflare Workers, and more. + +Define procedures on the server and serve them with an [RPC handler](https://orpc.dev/docs/rpc/handler): + +```ts +import { os } from '@orpc/server' +import * as z from 'zod' + +export const router = { + planet: { + find: os + .input(z.object({ id: z.number() })) + .handler(({ input }) => ({ id: input.id, name: 'Earth' })), + }, +} +``` + +Then call them from a client that knows every input and output, without generating anything: + +```ts +import type { RouterClient } from '@orpc/server' +import type { router } from './router' +import { createORPCClient } from '@orpc/client' +import { RPCLink } from '@orpc/client/fetch' + +const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc' }) +const client: RouterClient = createORPCClient(link) + +const planet = await client.planet.find({ id: 1 }) // { id: number, name: string } +``` + +## Documentation + +Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [Getting Started guide](https://orpc.dev/docs/getting-started). + +## Packages + +**Core** + +| Package | Purpose | Docs | +| -------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [@orpc/contract](https://www.npmjs.com/package/@orpc/contract) | Define API contract as the single source of truth. | [Procedure Contract](https://orpc.dev/docs/contract/procedure), [Contract Router](https://orpc.dev/docs/contract/router), [Implementation](https://orpc.dev/docs/contract/implementation), [Generate from OpenAPI](https://orpc.dev/docs/contract/generate-from-openapi) | +| [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | +| [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | +| [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | + +**Schema validation** + +| Package | Purpose | Docs | +| ------------------------------------------------------------ | ---------------------------------------------------------- | ----------------------------------------------------- | +| [@orpc/zod](https://www.npmjs.com/package/@orpc/zod) | JSON Schema converter for [Zod](https://zod.dev/). | [Zod](https://orpc.dev/docs/integrations/zod) | +| [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot) | JSON Schema converter for [Valibot](https://valibot.dev/). | [Valibot](https://orpc.dev/docs/integrations/valibot) | +| [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype) | JSON Schema converter for [ArkType](https://arktype.io/). | [ArkType](https://orpc.dev/docs/integrations/arktype) | + +**Built-in features** + +| Package | Purpose | Docs | +| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | + +**Framework & ecosystem integrations** + +| Package | Purpose | Docs | +| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [@orpc/next](https://www.npmjs.com/package/@orpc/next) | Integrate with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). | [Next.js](https://orpc.dev/docs/integrations/next) | +| [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk) | Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. | [AI SDK](https://orpc.dev/docs/integrations/ai-sdk) | +| [@orpc/mcp](https://www.npmjs.com/package/@orpc/mcp) | Expose procedures as [MCP](https://modelcontextprotocol.io/) tools. | [MCP](https://orpc.dev/docs/integrations/mcp) | +| [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query) | Integrate with [TanStack Query](https://tanstack.com/query/latest). | [TanStack Query](https://orpc.dev/docs/integrations/tanstack-query) | +| [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada) | Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). | [Pinia Colada](https://orpc.dev/docs/integrations/pinia-colada) | +| [@orpc/swr](https://www.npmjs.com/package/@orpc/swr) | Integrate with [SWR](https://swr.vercel.app/). | [SWR](https://orpc.dev/docs/integrations/swr) | +| [@orpc/msw](https://www.npmjs.com/package/@orpc/msw) | Mock procedures with [Mock Service Worker](https://mswjs.io/). | [MSW](https://orpc.dev/docs/integrations/msw) | +| [@orpc/effect](https://www.npmjs.com/package/@orpc/effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | +| [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | +| [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit), [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit), [Lock](https://orpc.dev/docs/helpers/lock), [Traces](https://orpc.dev/docs/integrations/cloudflare-traces) | +| [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | + +**Observability** + +| Package | Purpose | Docs | +| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| [@orpc/opentelemetry](https://www.npmjs.com/package/@orpc/opentelemetry) | Integrate with [OpenTelemetry](https://opentelemetry.io/) for distributed tracing. | [OpenTelemetry](https://orpc.dev/docs/integrations/opentelemetry) | +| [@orpc/pino](https://www.npmjs.com/package/@orpc/pino) | Integrate with [Pino](https://getpino.io/) for logging. | [Pino](https://orpc.dev/docs/integrations/pino) | +| [@orpc/evlog](https://www.npmjs.com/package/@orpc/evlog) | Integrate with [Evlog](https://evlog.dev/) for logging. | [Evlog](https://orpc.dev/docs/integrations/evlog) | + +## Sponsors + +Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going through [GitHub Sponsors](https://github.com/sponsors/dinwwwh) or [Open Collective](https://opencollective.com/middleapi). Every bit helps! 🚀 + + + + + + + + + + + +
ScreenshotOne.comScreenshotOne.com
The screenshot API for developers
YuzuYuzu
We're hiring NYC based engineers
MisskeyHQMisskeyHQ
Decentralized microblogging SNS born on Earth
+ +### Organization Sponsors + + + + + +
LN Markets
LN Markets
+ +### Sponsors + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Reece McDonald
Reece McDonald
あわわわとーにゅ
あわわわとーにゅ
nk
nk
supastarter
supastarter
herrfugbaum
herrfugbaum
Ryota Murakami
Ryota Murakami
David Cramer
David Cramer
Valerii Petryniak
Valerii Petryniak
Valerii Strilets
Valerii Strilets
Kyle Mistele
Kyle Mistele
christ12938
christ12938
Ryan Soderberg
Ryan Soderberg
shota
shota
Nicholas
Nicholas
Ellis Driscoll
Ellis Driscoll
Hoang Nguyen
Hoang Nguyen
Orestis Ioannou
Orestis Ioannou
Stefan Smiljkovic
Stefan Smiljkovic
+ +### Backers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
David Walsh
David Walsh
IPv4Addr
IPv4Addr
Robbe Vaes
Robbe Vaes
Aidan Sunbury
Aidan Sunbury
soonoo
soonoo
Kevin Porten
Kevin Porten
Denis
Denis
Christopher Kapic
Christopher Kapic
Tom Ballinger
Tom Ballinger
Sam
Sam
Titoine
Titoine
Igor Makowski
Igor Makowski
hanayashiki
hanayashiki
Lev Dubinets
Lev Dubinets
Kelly Peilin Chan
Kelly Peilin Chan
Guy Ariely
Guy Ariely
PaulSenon
PaulSenon
Shotaro Nakamura
Shotaro Nakamura
Alex
Alex
Andrey Gubanov
Andrey Gubanov
+ +With thanks to [39 past sponsors](https://orpc.dev/sponsors#past-sponsors) who helped get oRPC here. + +## Open Source Programs + +[middleapi](https://github.com/middleapi) is supported by these open source programs: + + + + + + + +
Claude
Claude
Pullfrog
Pullfrog
Macroscope
Macroscope
+ +## References + +oRPC is inspired by existing solutions that prioritize type safety and developer experience. Special acknowledgments to: + +- [tRPC](https://trpc.io): For pioneering the concept of end-to-end type-safe RPC and influencing the development of type-safe APIs. +- [ts-rest](https://ts-rest.com): For its emphasis on contract-first development and OpenAPI integration, which have greatly inspired oRPC's feature set. + +## License + +Distributed under the MIT License. See [LICENSE](https://github.com/middleapi/orpc/blob/main/LICENSE) for more information. diff --git a/packages/openrpc/package.json b/packages/openrpc/package.json new file mode 100644 index 0000000000..6568e48230 --- /dev/null +++ b/packages/openrpc/package.json @@ -0,0 +1,109 @@ +{ + "name": "@orpc/openrpc", + "type": "module", + "version": "2.0.0-beta.47", + "description": "Serve oRPC procedures over JSON-RPC 2.0 and generate OpenRPC documents", + "license": "MIT", + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "homepage": "https://orpc.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/middleapi/orpc.git", + "directory": "packages/openrpc" + }, + "keywords": [ + "orpc", + "openrpc", + "json-rpc", + "jsonrpc", + "rpc", + "api", + "typescript", + "typesafe", + "json-schema" + ], + "sideEffects": false, + "publishConfig": { + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + }, + "./plugins": { + "types": "./dist/plugins/index.d.mts", + "import": "./dist/plugins/index.mjs", + "default": "./dist/plugins/index.mjs" + }, + "./standard": { + "types": "./dist/adapters/standard/index.d.mts", + "import": "./dist/adapters/standard/index.mjs", + "default": "./dist/adapters/standard/index.mjs" + }, + "./fetch": { + "types": "./dist/adapters/fetch/index.d.mts", + "import": "./dist/adapters/fetch/index.mjs", + "default": "./dist/adapters/fetch/index.mjs" + }, + "./node": { + "types": "./dist/adapters/node/index.d.mts", + "import": "./dist/adapters/node/index.mjs", + "default": "./dist/adapters/node/index.mjs" + }, + "./aws-lambda": { + "types": "./dist/adapters/aws-lambda/index.d.mts", + "import": "./dist/adapters/aws-lambda/index.mjs", + "default": "./dist/adapters/aws-lambda/index.mjs" + }, + "./websocket": { + "types": "./dist/adapters/websocket/index.d.mts", + "import": "./dist/adapters/websocket/index.mjs", + "default": "./dist/adapters/websocket/index.mjs" + }, + "./fastify": { + "types": "./dist/adapters/fastify/index.d.mts", + "import": "./dist/adapters/fastify/index.mjs", + "default": "./dist/adapters/fastify/index.mjs" + } + } + }, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./plugins": "./src/plugins/index.ts", + "./standard": "./src/adapters/standard/index.ts", + "./fetch": "./src/adapters/fetch/index.ts", + "./node": "./src/adapters/node/index.ts", + "./aws-lambda": "./src/adapters/aws-lambda/index.ts", + "./websocket": "./src/adapters/websocket/index.ts", + "./fastify": "./src/adapters/fastify/index.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "prepack": "unbuild", + "type:check": "tsc -b" + }, + "dependencies": { + "@orpc/client": "workspace:*", + "@orpc/contract": "workspace:*", + "@orpc/json-schema": "workspace:*", + "@orpc/openapi": "workspace:*", + "@orpc/server": "workspace:*", + "@orpc/shared": "workspace:*", + "@standard-server/core": "~0.13.0", + "@standard-server/fetch": "~0.13.0" + }, + "devDependencies": { + "@orpc/zod": "workspace:*", + "@standard-server/aws-lambda": "~0.13.0", + "fastify": "^5.12.7", + "ws": "^8.22.0", + "zod": "^4.6.7" + } +} diff --git a/packages/openrpc/src/adapters/aws-lambda/index.test.ts b/packages/openrpc/src/adapters/aws-lambda/index.test.ts new file mode 100644 index 0000000000..45dd3dce25 --- /dev/null +++ b/packages/openrpc/src/adapters/aws-lambda/index.test.ts @@ -0,0 +1,5 @@ +it('exports OpenRPCHandler', async () => { + await expect(import('.')).resolves.toMatchObject({ + OpenRPCHandler: expect.any(Function), + }) +}) diff --git a/packages/openrpc/src/adapters/aws-lambda/index.ts b/packages/openrpc/src/adapters/aws-lambda/index.ts new file mode 100644 index 0000000000..d7208b86b4 --- /dev/null +++ b/packages/openrpc/src/adapters/aws-lambda/index.ts @@ -0,0 +1,20 @@ +export * from './openrpc-handler' + +export type { + AnyAPIGatewayProxyEvent, + APIGatewayProxyEvent, + /** + * AWS API Gateway proxy event, payload format 2.0, also used by Lambda Function URLs. + * + * @see {@link https://orpc.dev/docs/adapters/aws-lambda | AWS Lambda Adapter} + */ + APIGatewayProxyEventV2, + /** + * Shape of the `awslambda` global the Lambda Node.js runtime injects when response streaming is enabled. + * Declare it yourself: `declare const awslambda: AwsLambdaGlobal`. + * + * @see {@link https://orpc.dev/docs/adapters/aws-lambda | AWS Lambda Adapter} + */ + AwsLambdaGlobal, + HttpResponseStream, +} from '@orpc/server/aws-lambda' diff --git a/packages/openrpc/src/adapters/aws-lambda/openrpc-handler.test.ts b/packages/openrpc/src/adapters/aws-lambda/openrpc-handler.test.ts new file mode 100644 index 0000000000..c2f3ac9aaf --- /dev/null +++ b/packages/openrpc/src/adapters/aws-lambda/openrpc-handler.test.ts @@ -0,0 +1,109 @@ +import type { StandardHandlerPlugin } from '@orpc/server/standard' +import type { APIGatewayProxyEventV2, AwsLambdaGlobal, HttpResponseStream } from '@standard-server/aws-lambda' +import { Buffer } from 'node:buffer' +import { Writable } from 'node:stream' +import { os } from '@orpc/server' +import { OpenRPCHandler } from './openrpc-handler' + +class TestResponseStream extends Writable implements HttpResponseStream { + chunks: Buffer[] = [] + metadata: { statusCode: number, headers: Record, cookies: string[] } | undefined + + get text(): string { + return Buffer.concat(this.chunks).toString() + } + + setContentType(): void { + // noop + } + + override _write(chunk: any, _encoding: BufferEncoding, callback: () => void): void { + this.chunks.push(Buffer.from(chunk)) + callback() + } +} + +function createEvent(override: Partial = {}): APIGatewayProxyEventV2 { + return { + rawPath: '/', + rawQueryString: '', + headers: { + 'host': 'example.com', + 'content-type': 'application/json', + }, + requestContext: { http: { method: 'POST' } }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'ping', id: 1 }), + isBase64Encoded: false, + ...override, + } +} + +beforeEach(() => { + vi.clearAllMocks() + + vi.stubGlobal('awslambda', { + streamifyResponse: handler => handler, + HttpResponseStream: { + from(responseStream, metadata) { + (responseStream as TestResponseStream).metadata = metadata + return responseStream + }, + }, + } satisfies AwsLambdaGlobal) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('openRPCHandler', () => { + it('accepts context and prefix options in handle method', async () => { + const handler = new OpenRPCHandler({ + ping: os + .$context<{ userId: string }>() + .handler(({ context }) => context.userId), + }) + + const responseStream = new TestResponseStream() + const result = await handler.handle(createEvent({ rawPath: '/api/v1' }), responseStream, { + context: { userId: 'u_123' }, + prefix: '/api/v1', + }) + + expect(result.matched).toBe(true) + expect(responseStream.metadata?.statusCode).toBe(200) + expect(JSON.parse(responseStream.text)).toEqual({ jsonrpc: '2.0', id: 1, result: 'u_123' }) + + const mismatchStream = new TestResponseStream() + const mismatchResult = await handler.handle(createEvent({ rawPath: '/invalid' }), mismatchStream, { + context: { userId: 'u_123' }, + prefix: '/api/v1', + }) + + expect(mismatchResult.matched).toBe(false) + expect(mismatchStream.metadata).toBeUndefined() + expect(mismatchStream.text).toBe('') + }) + + it('supports standard handler plugins', async () => { + const plugin: StandardHandlerPlugin = { + name: 'test', + init(options) { + return { + ...options, + routingInterceptors: [ + async () => ({ matched: true, response: { status: 200, headers: {}, body: 'intercepted' } }), + ], + } + }, + } + + const handler = new OpenRPCHandler({}, { plugins: [plugin] }) + + const responseStream = new TestResponseStream() + const result = await handler.handle(createEvent({ requestContext: { http: { method: 'GET' } }, body: undefined }), responseStream) + + expect(result.matched).toBe(true) + expect(responseStream.text).toBe('"intercepted"') + }) +}) diff --git a/packages/openrpc/src/adapters/aws-lambda/openrpc-handler.ts b/packages/openrpc/src/adapters/aws-lambda/openrpc-handler.ts new file mode 100644 index 0000000000..e1f1e84773 --- /dev/null +++ b/packages/openrpc/src/adapters/aws-lambda/openrpc-handler.ts @@ -0,0 +1,27 @@ +import type { Context, Router } from '@orpc/server' +import type { AwsLambdaHandlerOptions } from '@orpc/server/aws-lambda' +import type { StandardHandlerOptions } from '@orpc/server/standard' +import type { OpenRPCEnvelopeHandlerPluginOptions, OpenRPCHandlerCodecOptions } from '../standard' +import { AwsLambdaHandler } from '@orpc/server/aws-lambda' +import { StandardHandler } from '@orpc/server/standard' +import { toArray } from '@orpc/shared' +import { OpenRPCEnvelopeHandlerPlugin, OpenRPCHandlerCodec } from '../standard' + +export interface OpenRPCHandlerOptions + extends AwsLambdaHandlerOptions, StandardHandlerOptions, OpenRPCHandlerCodecOptions, OpenRPCEnvelopeHandlerPluginOptions {} + +/** + * Serves oRPC procedures as a JSON-RPC 2.0 API over HTTP POST using AWS Lambda. + * + * @remarks + * **Warning**: Requires the AWS Lambda Node.js runtime with response streaming enabled — handlers should be wrapped with `awslambda.streamifyResponse`. + * + * @see {@link https://orpc.dev/docs/openrpc/handler | OpenRPC Handler} + */ +export class OpenRPCHandler extends AwsLambdaHandler { + constructor(router: Router, options: NoInfer> = {}) { + const codec = new OpenRPCHandlerCodec(router, options) + const plugins = [...toArray(options.plugins), new OpenRPCEnvelopeHandlerPlugin(options)] + super(new StandardHandler(codec, { ...options, plugins }), options) + } +} diff --git a/packages/openrpc/src/adapters/fastify/index.test.ts b/packages/openrpc/src/adapters/fastify/index.test.ts new file mode 100644 index 0000000000..45dd3dce25 --- /dev/null +++ b/packages/openrpc/src/adapters/fastify/index.test.ts @@ -0,0 +1,5 @@ +it('exports OpenRPCHandler', async () => { + await expect(import('.')).resolves.toMatchObject({ + OpenRPCHandler: expect.any(Function), + }) +}) diff --git a/packages/openrpc/src/adapters/fastify/index.ts b/packages/openrpc/src/adapters/fastify/index.ts new file mode 100644 index 0000000000..88e2d5f032 --- /dev/null +++ b/packages/openrpc/src/adapters/fastify/index.ts @@ -0,0 +1 @@ +export * from './openrpc-handler' diff --git a/packages/openrpc/src/adapters/fastify/openrpc-handler.test.ts b/packages/openrpc/src/adapters/fastify/openrpc-handler.test.ts new file mode 100644 index 0000000000..a8dd366035 --- /dev/null +++ b/packages/openrpc/src/adapters/fastify/openrpc-handler.test.ts @@ -0,0 +1,102 @@ +import type { StandardHandlerPlugin } from '@orpc/server/standard' +import type { FastifyInstance } from 'fastify' +import { os } from '@orpc/server' +import Fastify from 'fastify' +import request from 'supertest' +import { OpenRPCHandler } from './openrpc-handler' + +describe('openRPCHandler', () => { + let app: FastifyInstance + + beforeEach(() => { + vi.clearAllMocks() + app = Fastify() + }) + + afterEach(async () => { + await app.close() + }) + + it('accepts context and prefix options in handle method', async () => { + const handler = new OpenRPCHandler({ + ping: os + .$context<{ userId: string }>() + .handler(({ context }) => context.userId), + }) + + app.all('/*', async (req, reply) => { + const result = await handler.handle(req, reply, { + context: { userId: 'u_123' }, + prefix: '/api/v1', + }) + + if (!result.matched) { + return reply.status(404).send('not matched') + } + + return reply + }) + + await app.ready() + + const res = await request(app.server).post('/api/v1').set('content-type', 'application/json').send({ jsonrpc: '2.0', method: 'ping', id: 1 }) + + expect(res.status).toBe(200) + expect(res.body).toEqual({ jsonrpc: '2.0', id: 1, result: 'u_123' }) + + const mismatchRes = await request(app.server).post('/invalid').set('content-type', 'application/json').send({ jsonrpc: '2.0', method: 'ping', id: 1 }) + + expect(mismatchRes.status).toBe(404) + expect(mismatchRes.text).toBe('not matched') + }) + + it('reads batches Fastify has already parsed', async () => { + const handler = new OpenRPCHandler({ ping: os.handler(() => 'pong') }) + + app.all('/*', async (req, reply) => { + await handler.handle(req, reply) + return reply + }) + + await app.ready() + + const res = await request(app.server).post('/').set('content-type', 'application/json').send([ + { jsonrpc: '2.0', method: 'ping', id: 1 }, + { jsonrpc: '2.0', method: 'ping', id: 2 }, + ]) + + expect(res.status).toBe(200) + expect(res.body).toEqual([ + { jsonrpc: '2.0', id: 1, result: 'pong' }, + { jsonrpc: '2.0', id: 2, result: 'pong' }, + ]) + }) + + it('supports standard handler plugins', async () => { + const plugin: StandardHandlerPlugin = { + name: 'test', + init(options) { + return { + ...options, + routingInterceptors: [ + async () => ({ matched: true, response: { status: 200, headers: {}, body: 'intercepted' } }), + ], + } + }, + } + + const handler = new OpenRPCHandler({}, { plugins: [plugin] }) + + app.all('/*', async (req, reply) => { + await handler.handle(req, reply) + return reply + }) + + await app.ready() + + const res = await request(app.server).get('/test') + + expect(res.status).toBe(200) + expect(res.body).toBe('intercepted') + }) +}) diff --git a/packages/openrpc/src/adapters/fastify/openrpc-handler.ts b/packages/openrpc/src/adapters/fastify/openrpc-handler.ts new file mode 100644 index 0000000000..77efeaa7fa --- /dev/null +++ b/packages/openrpc/src/adapters/fastify/openrpc-handler.ts @@ -0,0 +1,24 @@ +import type { Context, Router } from '@orpc/server' +import type { FastifyHandlerOptions } from '@orpc/server/fastify' +import type { StandardHandlerOptions } from '@orpc/server/standard' +import type { OpenRPCEnvelopeHandlerPluginOptions, OpenRPCHandlerCodecOptions } from '../standard' +import { FastifyHandler } from '@orpc/server/fastify' +import { StandardHandler } from '@orpc/server/standard' +import { toArray } from '@orpc/shared' +import { OpenRPCEnvelopeHandlerPlugin, OpenRPCHandlerCodec } from '../standard' + +export interface OpenRPCHandlerOptions + extends FastifyHandlerOptions, StandardHandlerOptions, OpenRPCHandlerCodecOptions, OpenRPCEnvelopeHandlerPluginOptions {} + +/** + * Serves oRPC procedures as a JSON-RPC 2.0 API over HTTP POST using Fastify. + * + * @see {@link https://orpc.dev/docs/openrpc/handler | OpenRPC Handler} + */ +export class OpenRPCHandler extends FastifyHandler { + constructor(router: Router, options: NoInfer> = {}) { + const codec = new OpenRPCHandlerCodec(router, options) + const plugins = [...toArray(options.plugins), new OpenRPCEnvelopeHandlerPlugin(options)] + super(new StandardHandler(codec, { ...options, plugins }), options) + } +} diff --git a/packages/openrpc/src/adapters/fetch/index.test.ts b/packages/openrpc/src/adapters/fetch/index.test.ts new file mode 100644 index 0000000000..cb9fa145a4 --- /dev/null +++ b/packages/openrpc/src/adapters/fetch/index.test.ts @@ -0,0 +1,6 @@ +it('exports OpenRPCHandler and OpenRPCLink', async () => { + await expect(import('.')).resolves.toMatchObject({ + OpenRPCHandler: expect.any(Function), + OpenRPCLink: expect.any(Function), + }) +}) diff --git a/packages/openrpc/src/adapters/fetch/index.ts b/packages/openrpc/src/adapters/fetch/index.ts new file mode 100644 index 0000000000..23a408a57e --- /dev/null +++ b/packages/openrpc/src/adapters/fetch/index.ts @@ -0,0 +1,2 @@ +export * from './openrpc-handler' +export * from './openrpc-link' diff --git a/packages/openrpc/src/adapters/fetch/openrpc-handler.test.ts b/packages/openrpc/src/adapters/fetch/openrpc-handler.test.ts new file mode 100644 index 0000000000..fab4969b8b --- /dev/null +++ b/packages/openrpc/src/adapters/fetch/openrpc-handler.test.ts @@ -0,0 +1,98 @@ +import type { StandardHandlerPlugin } from '@orpc/server/standard' +import { ORPCError } from '@orpc/client' +import { os } from '@orpc/server' +import { OpenRPCHandler } from './openrpc-handler' + +function createRequest(url: string, body: unknown) { + return new Request(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('openRPCHandler', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('accepts context and prefix options in handle method', async () => { + const contextHandler = new OpenRPCHandler({ + ping: os + .$context<{ userId: string }>() + .handler(({ context }) => context.userId), + }) + + const { matched, response } = await contextHandler.handle( + createRequest('https://example.com/api/v1', { jsonrpc: '2.0', method: 'ping', id: 1 }), + { + context: { userId: 'u_123' }, + prefix: '/api/v1', + }, + ) + + expect(matched).toBe(true) + expect(response!.status).toBe(200) + await expect(response!.json()).resolves.toEqual({ jsonrpc: '2.0', id: 1, result: 'u_123' }) + + const misMatchPrefixResult = await contextHandler.handle( + createRequest('https://example.com/invalid', { jsonrpc: '2.0', method: 'ping', id: 1 }), + { + context: { userId: 'u_123' }, + prefix: '/api/v1', + }, + ) + + expect(misMatchPrefixResult.matched).toBe(false) + expect(misMatchPrefixResult.response).toBeUndefined() + }) + + it('passes options to the codec and the envelope plugin', async () => { + const handler = new OpenRPCHandler({ + ping: os.handler(() => 'pong'), + hidden: os.handler(() => 'hidden'), + find: os.handler(() => { + throw new ORPCError('NOT_FOUND') + }), + }, { + filter: (_, path) => path[0] !== 'hidden', + errorCodeMap: { NOT_FOUND: 404 }, + maxBatchSize: 1, + }) + + const call = async (body: unknown) => { + const { response } = await handler.handle(createRequest('https://example.com/rpc', body), { prefix: '/rpc' }) + return { status: response!.status, json: await response!.json() } + } + + await expect(call({ jsonrpc: '2.0', method: 'hidden', id: 1 })).resolves.toMatchObject({ status: 200, json: { error: { code: -32601 } } }) + await expect(call({ jsonrpc: '2.0', method: 'find', id: 1 })).resolves.toMatchObject({ status: 200, json: { error: { code: 404 } } }) + // the error code map replaces the common one, for the errors of the envelope too + await expect(call([{ jsonrpc: '2.0', method: 'ping', id: 1 }, { jsonrpc: '2.0', method: 'ping', id: 2 }])) + .resolves + .toMatchObject({ status: 413, json: { error: { code: -32000, data: { code: 'PAYLOAD_TOO_LARGE' } } } }) + }) + + it('supports standard handler plugins', async () => { + const plugin: StandardHandlerPlugin = { + name: 'test', + init(options) { + return { + ...options, + routingInterceptors: [ + async () => ({ matched: true, response: { status: 200, headers: {}, body: 'intercepted' } }), + ], + } + }, + } + + const handler = new OpenRPCHandler({}, { plugins: [plugin] }) + + const { matched, response } = await handler.handle(new Request('https://example.com/test')) + + expect(matched).toBe(true) + expect(response).toBeInstanceOf(Response) + expect(response!.status).toBe(200) + return expect(response!.text()).resolves.toBe('"intercepted"') + }) +}) diff --git a/packages/openrpc/src/adapters/fetch/openrpc-handler.ts b/packages/openrpc/src/adapters/fetch/openrpc-handler.ts new file mode 100644 index 0000000000..1d77fcb0d4 --- /dev/null +++ b/packages/openrpc/src/adapters/fetch/openrpc-handler.ts @@ -0,0 +1,24 @@ +import type { Context, Router } from '@orpc/server' +import type { FetchHandlerOptions } from '@orpc/server/fetch' +import type { StandardHandlerOptions } from '@orpc/server/standard' +import type { OpenRPCEnvelopeHandlerPluginOptions, OpenRPCHandlerCodecOptions } from '../standard' +import { FetchHandler } from '@orpc/server/fetch' +import { StandardHandler } from '@orpc/server/standard' +import { toArray } from '@orpc/shared' +import { OpenRPCEnvelopeHandlerPlugin, OpenRPCHandlerCodec } from '../standard' + +export interface OpenRPCHandlerOptions + extends FetchHandlerOptions, StandardHandlerOptions, OpenRPCHandlerCodecOptions, OpenRPCEnvelopeHandlerPluginOptions {} + +/** + * Serves oRPC procedures as a JSON-RPC 2.0 API over HTTP POST using Fetch API `Request`/`Response`. + * + * @see {@link https://orpc.dev/docs/openrpc/handler | OpenRPC Handler} + */ +export class OpenRPCHandler extends FetchHandler { + constructor(router: Router, options: NoInfer> = {}) { + const codec = new OpenRPCHandlerCodec(router, options) + const plugins = [...toArray(options.plugins), new OpenRPCEnvelopeHandlerPlugin(options)] + super(new StandardHandler(codec, { ...options, plugins }), options) + } +} diff --git a/packages/openrpc/src/adapters/fetch/openrpc-link.test.ts b/packages/openrpc/src/adapters/fetch/openrpc-link.test.ts new file mode 100644 index 0000000000..06abb88a77 --- /dev/null +++ b/packages/openrpc/src/adapters/fetch/openrpc-link.test.ts @@ -0,0 +1,169 @@ +import type { StandardLinkPlugin } from '@orpc/client/standard' +import type { JsonifiedClient } from '@orpc/openapi' +import type { RouterClient } from '@orpc/server' +import type { OpenRPCLinkOptions } from './openrpc-link' +import { createORPCClient, isDefinedError, MalformedResponseError, ORPCError, safe } from '@orpc/client' +import { RetryLinkPlugin } from '@orpc/client/plugins' +import { os } from '@orpc/server' +import { z } from 'zod' +import { OpenRPCHandler } from './openrpc-handler' +import { OpenRPCLink } from './openrpc-link' + +describe('openRPCLink', () => { + const date = new Date('2024-01-02T03:04:05.000Z') + const flaky = vi.fn() + + const router = { + planet: { + find: os + .errors({ NOT_FOUND: { data: z.object({ id: z.number() }) } }) + .input(z.object({ id: z.number() })) + .handler(({ input, errors }) => { + if (input.id !== 1) { + throw errors.NOT_FOUND({ data: { id: input.id } }) + } + + return { id: 1, discoveredAt: date } + }), + echo: os.input(z.any()).handler(({ input }) => input), + buy: os.handler(() => { + throw new ORPCError('OUT_OF_STOCK', { message: 'Sold out', data: { left: 0 } }) + }), + flaky: os.handler(flaky), + }, + } + + const handler = new OpenRPCHandler(router) + + const bodies: unknown[] = [] + const fetch = vi.fn(async (url: string, init: RequestInit) => { + const request = new Request(url, init) + bodies.push(await request.clone().json()) + + const { matched, response } = await handler.handle(request, { prefix: '/rpc' }) + + if (!matched || !response) { + throw new Error('No procedure match') + } + + return response + }) + + function createClient(options: OpenRPCLinkOptions = {}) { + return createORPCClient>>(new OpenRPCLink({ + fetch, + origin: 'http://localhost:3000', + url: '/rpc', + ...options, + })) + } + + let client: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + bodies.length = 0 + // request ids count per link, so each test starts at 1 + client = createClient() + }) + + it('calls a JSON-RPC endpoint through fetch transport', async () => { + await expect(client.planet.find({ id: 1 })).resolves.toEqual({ id: 1, discoveredAt: date.toISOString() }) + + expect(fetch).toHaveBeenCalledOnce() + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:3000/rpc', + expect.objectContaining({ method: 'POST' }), + expect.objectContaining({ context: {} }), + ['planet', 'find'], + ) + expect(bodies).toEqual([{ jsonrpc: '2.0', id: 1, method: 'planet.find', params: { id: 1 } }]) + }) + + it('sends inputs by-name, by-position, or without params', async () => { + await client.planet.echo({ nested: { date, arr: [3, date] }, big: 1n }) + await client.planet.echo('text') + await client.planet.echo([1, 2]) + await client.planet.echo(undefined) + + expect(bodies).toEqual([ + { jsonrpc: '2.0', id: 1, method: 'planet.echo', params: { nested: { date: date.toISOString(), arr: [3, date.toISOString()] }, big: '1' } }, + { jsonrpc: '2.0', id: 2, method: 'planet.echo', params: ['text'] }, + { jsonrpc: '2.0', id: 3, method: 'planet.echo', params: [[1, 2]] }, + { jsonrpc: '2.0', id: 4, method: 'planet.echo' }, + ]) + }) + + it('restores ORPCErrors of oRPC servers', async () => { + const [error] = await safe(client.planet.find({ id: 2 })) + + expect(isDefinedError(error)).toBe(true) + expect(error).toMatchObject({ code: 'NOT_FOUND', data: { id: 2 }, cause: { response: { status: 404 } } }) + + await expect(client.planet.buy()).rejects.toMatchObject({ + code: 'OUT_OF_STOCK', + defined: false, + message: 'Sold out', + data: { left: 0 }, + cause: { response: { status: 400 } }, + }) + }) + + it('decodes protocol errors of the handler', async () => { + await expect((client as any).planet.missing()).rejects.toMatchObject({ + code: 'NOT_FOUND', + defined: false, + message: 'Method not found', + cause: { response: { status: 404 } }, + }) + + // a proxy changing the content type: the whole request is rejected with a `null` id and its real status + const textPlain = createClient({ fetch: (url, init) => fetch(url, { ...init, headers: { 'content-type': 'text/plain' } }) }) + + await expect(textPlain.planet.echo(1)).rejects.toMatchObject({ + code: 'UNSUPPORTED_MEDIA_TYPE', + defined: false, + cause: { response: { status: 415 } }, + }) + }) + + it('throws MalformedResponseError on responses that are not JSON', async () => { + const proxy = createClient({ fetch: async () => new Response('Bad Gateway', { status: 502, headers: { 'content-type': 'text/html' } }) }) + const [error] = await safe(proxy.planet.echo(1)) + + expect(error).toBeInstanceOf(MalformedResponseError) + expect(error).toMatchObject({ message: 'Bad Gateway', response: { status: 502 }, cause: expect.any(SyntaxError) }) + }) + + it('retries errors with a server status, but not client errors', async () => { + const retry = createClient({ plugins: [new RetryLinkPlugin({ default: { retry: 1 } })] }) + + flaky.mockImplementationOnce(() => { + throw new ORPCError('SERVICE_UNAVAILABLE') + }).mockReturnValueOnce('ok') + await expect(retry.planet.flaky()).resolves.toBe('ok') + + await expect(retry.planet.find({ id: 2 })).rejects.toMatchObject({ code: 'NOT_FOUND' }) + expect(fetch).toHaveBeenCalledTimes(3) + }) + + it('supports standard link plugins', async () => { + const plugin: StandardLinkPlugin = { + name: 'test', + init() { + return { + transportInterceptors: [ + async ({ request }) => ({ + status: 200, + headers: {}, + resolveBody: async () => ({ jsonrpc: '2.0', id: (request.body as { id: number }).id, result: 'intercepted' }), + }), + ], + } + }, + } + + await expect(createClient({ plugins: [plugin] }).planet.echo('ignored')).resolves.toBe('intercepted') + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/packages/openrpc/src/adapters/fetch/openrpc-link.ts b/packages/openrpc/src/adapters/fetch/openrpc-link.ts new file mode 100644 index 0000000000..cf8dfd0ce2 --- /dev/null +++ b/packages/openrpc/src/adapters/fetch/openrpc-link.ts @@ -0,0 +1,21 @@ +import type { ClientContext } from '@orpc/client' +import type { FetchLinkTransportOptions } from '@orpc/client/fetch' +import type { StandardLinkOptions } from '@orpc/client/standard' +import type { OpenRPCLinkCodecOptions } from '../standard' +import { FetchLinkTransport } from '@orpc/client/fetch' +import { StandardLink } from '@orpc/client/standard' +import { OpenRPCLinkCodec } from '../standard' + +export interface OpenRPCLinkOptions + extends StandardLinkOptions, FetchLinkTransportOptions, OpenRPCLinkCodecOptions {} + +/** + * Client link that calls a JSON-RPC 2.0 server, such as `OpenRPCHandler`, over HTTP using the Fetch API. + * + * @see {@link https://orpc.dev/docs/openrpc/link | OpenRPC Link} + */ +export class OpenRPCLink extends StandardLink { + constructor(options: OpenRPCLinkOptions = {}) { + super(new OpenRPCLinkCodec(options), new FetchLinkTransport(options), options) + } +} diff --git a/packages/openrpc/src/adapters/node/index.test.ts b/packages/openrpc/src/adapters/node/index.test.ts new file mode 100644 index 0000000000..45dd3dce25 --- /dev/null +++ b/packages/openrpc/src/adapters/node/index.test.ts @@ -0,0 +1,5 @@ +it('exports OpenRPCHandler', async () => { + await expect(import('.')).resolves.toMatchObject({ + OpenRPCHandler: expect.any(Function), + }) +}) diff --git a/packages/openrpc/src/adapters/node/index.ts b/packages/openrpc/src/adapters/node/index.ts new file mode 100644 index 0000000000..88e2d5f032 --- /dev/null +++ b/packages/openrpc/src/adapters/node/index.ts @@ -0,0 +1 @@ +export * from './openrpc-handler' diff --git a/packages/openrpc/src/adapters/node/openrpc-handler.test.ts b/packages/openrpc/src/adapters/node/openrpc-handler.test.ts new file mode 100644 index 0000000000..b54fe5d4d3 --- /dev/null +++ b/packages/openrpc/src/adapters/node/openrpc-handler.test.ts @@ -0,0 +1,67 @@ +import type { StandardHandlerPlugin } from '@orpc/server/standard' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { os } from '@orpc/server' +import request from 'supertest' +import { OpenRPCHandler } from './openrpc-handler' + +describe('openRPCHandler', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('accepts context and prefix options in handle method', async () => { + const handler = new OpenRPCHandler({ + ping: os + .$context<{ userId: string }>() + .handler(({ context }) => context.userId), + }) + + const res = await request(async (req: IncomingMessage, response: ServerResponse) => { + await handler.handle(req as any, response as any, { + context: { userId: 'u_123' }, + prefix: '/api/v1', + }) + }).post('/api/v1').set('content-type', 'application/json').send({ jsonrpc: '2.0', method: 'ping', id: 1 }) + + expect(res.status).toBe(200) + expect(res.body).toEqual({ jsonrpc: '2.0', id: 1, result: 'u_123' }) + + const mismatchRes = await request(async (req: IncomingMessage, response: ServerResponse) => { + const result = await handler.handle(req as any, response as any, { + context: { userId: 'u_123' }, + prefix: '/api/v1', + }) + + if (!result.matched) { + response.statusCode = 404 + response.end('not matched') + } + }).post('/invalid').set('content-type', 'application/json').send({ jsonrpc: '2.0', method: 'ping', id: 1 }) + + expect(mismatchRes.status).toBe(404) + expect(mismatchRes.text).toBe('not matched') + }) + + it('supports standard handler plugins', async () => { + const plugin: StandardHandlerPlugin = { + name: 'test', + init(options) { + return { + ...options, + routingInterceptors: [ + async () => ({ matched: true, response: { status: 200, headers: {}, body: 'intercepted' } }), + ], + } + }, + } + + const handler = new OpenRPCHandler({}, { plugins: [plugin] }) + + const res = await request(async (req: IncomingMessage, response: ServerResponse) => { + await handler.handle(req as any, response as any) + }).get('/test') + + expect(res.status).toBe(200) + expect(res.body).toBe('intercepted') + }) +}) diff --git a/packages/openrpc/src/adapters/node/openrpc-handler.ts b/packages/openrpc/src/adapters/node/openrpc-handler.ts new file mode 100644 index 0000000000..f78a0a7542 --- /dev/null +++ b/packages/openrpc/src/adapters/node/openrpc-handler.ts @@ -0,0 +1,24 @@ +import type { Context, Router } from '@orpc/server' +import type { NodeHttpHandlerOptions } from '@orpc/server/node' +import type { StandardHandlerOptions } from '@orpc/server/standard' +import type { OpenRPCEnvelopeHandlerPluginOptions, OpenRPCHandlerCodecOptions } from '../standard' +import { NodeHttpHandler } from '@orpc/server/node' +import { StandardHandler } from '@orpc/server/standard' +import { toArray } from '@orpc/shared' +import { OpenRPCEnvelopeHandlerPlugin, OpenRPCHandlerCodec } from '../standard' + +export interface OpenRPCHandlerOptions + extends NodeHttpHandlerOptions, StandardHandlerOptions, OpenRPCHandlerCodecOptions, OpenRPCEnvelopeHandlerPluginOptions {} + +/** + * Serves oRPC procedures as a JSON-RPC 2.0 API over HTTP POST using Node.js built-in HTTP request/response. + * + * @see {@link https://orpc.dev/docs/openrpc/handler | OpenRPC Handler} + */ +export class OpenRPCHandler extends NodeHttpHandler { + constructor(router: Router, options: NoInfer> = {}) { + const codec = new OpenRPCHandlerCodec(router, options) + const plugins = [...toArray(options.plugins), new OpenRPCEnvelopeHandlerPlugin(options)] + super(new StandardHandler(codec, { ...options, plugins }), options) + } +} diff --git a/packages/openrpc/src/adapters/standard/index.test.ts b/packages/openrpc/src/adapters/standard/index.test.ts new file mode 100644 index 0000000000..a028a3a479 --- /dev/null +++ b/packages/openrpc/src/adapters/standard/index.test.ts @@ -0,0 +1,8 @@ +it('exports OpenRPCHandlerCodec, OpenRPCEnvelopeHandlerPlugin, OpenRPCLinkCodec, getJsonRpcCall', async () => { + await expect(import('.')).resolves.toMatchObject({ + OpenRPCHandlerCodec: expect.any(Function), + OpenRPCEnvelopeHandlerPlugin: expect.any(Function), + OpenRPCLinkCodec: expect.any(Function), + getJsonRpcCall: expect.any(Function), + }) +}) diff --git a/packages/openrpc/src/adapters/standard/index.ts b/packages/openrpc/src/adapters/standard/index.ts new file mode 100644 index 0000000000..2347a374d1 --- /dev/null +++ b/packages/openrpc/src/adapters/standard/index.ts @@ -0,0 +1,5 @@ +export * from './openrpc-envelope-handler-plugin' +export * from './openrpc-handler-codec' +export * from './openrpc-link-codec' +export { getJsonRpcCall } from './utils' +export type { JsonRpcCall } from './utils' diff --git a/packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.test.ts b/packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.test.ts new file mode 100644 index 0000000000..d3f8019c95 --- /dev/null +++ b/packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.test.ts @@ -0,0 +1,434 @@ +import type { ResponseHeadersHandlerPluginContext } from '@orpc/server/plugins' +import type { StandardHandlerPlugin } from '@orpc/server/standard' +import type { OpenRPCHandlerOptions } from '../fetch' +import { ORPCError } from '@orpc/client' +import { os } from '@orpc/server' +import { CORSHandlerPlugin, RequestLimitHandlerPlugin, ResponseHeadersHandlerPlugin, RethrowHandlerPlugin } from '@orpc/server/plugins' +import { getTracer, promiseWithResolvers, setTracer } from '@orpc/shared' +import { z } from 'zod' +import { OpenRPCHandler } from '../fetch' +import { getJsonRpcCall, JSON_RPC_CALL_SYMBOL } from './utils' + +beforeEach(() => { + vi.clearAllMocks() +}) + +const notify = vi.fn() + +const router = { + subtract: os + .input(z.union([z.tuple([z.number(), z.number()]), z.object({ minuend: z.number(), subtrahend: z.number() })])) + .handler(({ input }) => Array.isArray(input) ? input[0] - input[1] : input.minuend - input.subtrahend), + sum: os.input(z.array(z.number())).handler(({ input }) => input.reduce((a, b) => a + b, 0)), + update: os.input(z.array(z.number())).handler(({ input }) => notify('update', input)), + notify_sum: os.input(z.array(z.number())).handler(({ input }) => notify('notify_sum', input)), + notify_hello: os.input(z.number()).handler(({ input }) => notify('notify_hello', input)), + get_data: os.handler(() => ['hello', 5]), + planet: { + list: os.input(z.object({ limit: z.number().optional() })).handler(({ input }) => input), + find: os + .errors({ NOT_FOUND: { message: 'Planet not found', data: z.object({ id: z.number() }) } }) + .input(z.object({ id: z.number() })) + .handler(({ input, errors }) => { + throw errors.NOT_FOUND({ data: { id: input.id } }) + }), + fail: os.handler(() => { + throw new Error('secret') + }), + }, +} + +function createHandler(options: OpenRPCHandlerOptions = {}) { + return new OpenRPCHandler(router, options) +} + +function createRequest(body: unknown, headers: Record = {}) { + return new Request('https://example.com/rpc', { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }) +} + +async function post(handler: OpenRPCHandler, body: unknown, options: { headers?: Record, context?: object } = {}) { + const { response } = await handler.handle(createRequest(body, options.headers), { prefix: '/rpc', context: options.context ?? {} }) + const text = await response!.text() + + return { status: response!.status, json: text ? JSON.parse(text) : undefined } +} + +function batchOf(size: number) { + return Array.from({ length: size }, (_, id) => ({ jsonrpc: '2.0', method: 'notify_hello', params: [id], id })) +} + +describe('openRPCEnvelopeHandlerPlugin', () => { + // https://www.jsonrpc.org/specification#examples + // positional params are wrapped in one array, since a procedure takes a single input + it.each([ + ['rpc call with positional parameters', { jsonrpc: '2.0', method: 'subtract', params: [[42, 23]], id: 1 }, { jsonrpc: '2.0', result: 19, id: 1 }], + ['rpc call with named parameters', { jsonrpc: '2.0', method: 'subtract', params: { subtrahend: 23, minuend: 42 }, id: 3 }, { jsonrpc: '2.0', result: 19, id: 3 }], + ['rpc call of non-existent method', { jsonrpc: '2.0', method: 'foobar', id: '1' }, { jsonrpc: '2.0', error: { code: -32601, message: 'Method not found' }, id: '1' }], + ['rpc call with invalid JSON', '{"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]', { jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null }], + ['rpc call with invalid Request object', { jsonrpc: '2.0', method: 1, params: 'bar' }, { jsonrpc: '2.0', error: { code: -32600, message: 'Invalid Request' }, id: null }], + ['rpc call Batch, invalid JSON', '[\n {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"},\n {"jsonrpc": "2.0", "method"\n]', { jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null }], + ['rpc call with an empty Array', [], { jsonrpc: '2.0', error: { code: -32600, message: 'Invalid Request' }, id: null }], + ['rpc call with an invalid Batch (but not empty)', [1], [{ jsonrpc: '2.0', error: { code: -32600, message: 'Invalid Request' }, id: null }]], + ['rpc call with invalid Batch', [1, 2, 3], Array.from({ length: 3 }, () => ({ jsonrpc: '2.0', error: { code: -32600, message: 'Invalid Request' }, id: null }))], + ['rpc call Batch', [ + { jsonrpc: '2.0', method: 'sum', params: [[1, 2, 4]], id: '1' }, + { jsonrpc: '2.0', method: 'notify_hello', params: [7] }, + { jsonrpc: '2.0', method: 'subtract', params: [[42, 23]], id: '2' }, + { foo: 'boo' }, + { jsonrpc: '2.0', method: 'foo.get', params: { name: 'myself' }, id: '5' }, + { jsonrpc: '2.0', method: 'get_data', id: '9' }, + ], [ + { jsonrpc: '2.0', result: 7, id: '1' }, + { jsonrpc: '2.0', result: 19, id: '2' }, + { jsonrpc: '2.0', error: { code: -32600, message: 'Invalid Request' }, id: null }, + { jsonrpc: '2.0', error: { code: -32601, message: 'Method not found' }, id: '5' }, + { jsonrpc: '2.0', result: ['hello', 5], id: '9' }, + ]], + ])('answers the specification example: %s', async (_, request, response) => { + await expect(post(createHandler(), request)).resolves.toEqual({ status: 200, json: response }) + }) + + describe('notifications', () => { + it('runs notifications to completion and answers a request of only notifications with 204', async () => { + const handler = createHandler() + + await expect(post(handler, { jsonrpc: '2.0', method: 'update', params: [[1, 2, 3, 4, 5]] })).resolves.toEqual({ status: 204, json: undefined }) + await expect(post(handler, [ + { jsonrpc: '2.0', method: 'notify_sum', params: [[1, 2, 4]] }, + { jsonrpc: '2.0', method: 'notify_hello', params: [7] }, + ])).resolves.toEqual({ status: 204, json: undefined }) + + expect(notify.mock.calls).toEqual([['update', [1, 2, 3, 4, 5]], ['notify_sum', [1, 2, 4]], ['notify_hello', 7]]) + }) + + it('never answers the errors of notifications', async () => { + const handler = createHandler() + + await expect(post(handler, { jsonrpc: '2.0', method: 'foobar' })).resolves.toEqual({ status: 204, json: undefined }) + await expect(post(handler, { jsonrpc: '2.0', method: 'planet.fail' })).resolves.toEqual({ status: 204, json: undefined }) + await expect(post(handler, { jsonrpc: '2.0', method: 'planet.find', params: { id: 'x' } })).resolves.toEqual({ status: 204, json: undefined }) + }) + + it.each([0, '', null])('answers a call with the falsy id %j, which is not a notification', async (id) => { + await expect(post(createHandler(), { jsonrpc: '2.0', method: 'get_data', id })).resolves.toEqual({ + status: 200, + json: { jsonrpc: '2.0', id, result: ['hello', 5] }, + }) + }) + }) + + describe('calls', () => { + it('answers procedure errors with HTTP 200 and the encoded oRPC error', async () => { + const handler = createHandler() + + await expect(post(handler, { jsonrpc: '2.0', method: 'planet.find', params: { id: 2 }, id: 1 })).resolves.toEqual({ + status: 200, + json: { jsonrpc: '2.0', id: 1, error: { code: -32004, message: 'Planet not found', data: { code: 'NOT_FOUND', defined: true, data: { id: 2 } } } }, + }) + + expect((await post(handler, { jsonrpc: '2.0', method: 'planet.find', params: { id: 'x' }, id: 1 })).json).toMatchObject({ + error: { code: -32602, message: 'Input validation failed', data: { code: 'BAD_REQUEST', defined: false, data: { issues: expect.any(Array) } } }, + }) + + expect((await post(handler, { jsonrpc: '2.0', method: 'planet.fail', id: 1 })).json).toEqual({ + jsonrpc: '2.0', + id: 1, + error: { code: -32603, message: 'Internal Server Error', data: { code: 'INTERNAL_SERVER_ERROR', defined: false } }, + }) + }) + + it('treats null params as absent', async () => { + expect((await post(createHandler(), { jsonrpc: '2.0', method: 'planet.list', params: null, id: 1 })).json).toEqual({ jsonrpc: '2.0', id: 1, result: {} }) + }) + + it.each([ + ['an unsupported jsonrpc version', { jsonrpc: '1.0', method: 'get_data', id: 'a' }, 'a'], + ['no method', { jsonrpc: '2.0', id: 1 }, 1], + ['params that are neither an array nor an object', { jsonrpc: '2.0', method: 'get_data', params: 'bar', id: 1 }, 1], + ['an invalid id', { jsonrpc: '2.0', method: 'get_data', id: {} }, null], + ['a non-object body', null, null], + ])('answers a request with %s with -32600, echoing its id when valid', async (_, request, id) => { + expect((await post(createHandler(), request)).json).toEqual({ jsonrpc: '2.0', id, error: { code: -32600, message: 'Invalid Request' } }) + }) + + it.each([ + ['a body that is not a JSON-RPC result or error', 'replaced'], + ['both a result and an error', { result: 1, error: { code: 1, message: 'x' } }], + ['an invalid error object', { error: { code: 1.5, message: 'x' } }], + ])('answers -32603 when an interceptor replaces the response with %s', async (_, body) => { + const handler = createHandler({ interceptors: [async () => ({ status: 200, headers: {}, body })] }) + + expect((await post(handler, { jsonrpc: '2.0', method: 'get_data', id: 1 })).json).toEqual({ + jsonrpc: '2.0', + id: 1, + error: { code: -32603, message: 'Internal error' }, + }) + }) + }) + + describe('batches', () => { + it('runs the calls of a batch concurrently and answers them in the request order', async () => { + const { promise, resolve } = promiseWithResolvers() + const handler = new OpenRPCHandler({ + slow: os.handler(async () => { + await promise + return 'slow' + }), + fast: os.handler(() => { + resolve() + return 'fast' + }), + }) + + expect((await post(handler, [{ jsonrpc: '2.0', method: 'slow', id: 1 }, { jsonrpc: '2.0', method: 'fast', id: 2 }])).json).toEqual([ + { jsonrpc: '2.0', id: 1, result: 'slow' }, + { jsonrpc: '2.0', id: 2, result: 'fast' }, + ]) + }) + + it('rejects a batch larger than maxBatchSize (default 100) with 413, without running its calls', async () => { + await expect(post(createHandler(), batchOf(101))).resolves.toEqual({ + status: 413, + json: { + jsonrpc: '2.0', + id: null, + error: { code: -32013, message: 'Batch size exceeds the maximum of 100.', data: { code: 'PAYLOAD_TOO_LARGE', defined: false } }, + }, + }) + await expect(post(createHandler({ maxBatchSize: 1 }), batchOf(2))).resolves.toMatchObject({ status: 413 }) + expect(notify).not.toHaveBeenCalled() + + await expect(post(createHandler(), batchOf(100))).resolves.toMatchObject({ status: 200 }) + }) + + it('passes the HTTP request signal to every call of a batch, notifications included', async () => { + const signals: (AbortSignal | undefined)[] = [] + const handler = createHandler({ + interceptors: [({ request, next }) => { + signals.push(request.signal) + return next() + }], + }) + + const controller = new AbortController() + const batch = [{ jsonrpc: '2.0', method: 'get_data', id: 1 }, { jsonrpc: '2.0', method: 'notify_hello', params: [1] }] + await handler.handle(new Request(createRequest(batch), { signal: controller.signal }), { prefix: '/rpc' }) + + expect(signals.map(signal => signal?.aborted)).toEqual([false, false]) + controller.abort() + expect(signals.map(signal => signal?.aborted)).toEqual([true, true]) + }) + + it('resolves a maxBatchSize function per request', async () => { + const maxBatchSize = vi.fn(({ context }) => context.maxBatchSize) + const handler = createHandler({ maxBatchSize }) + + await expect(post(handler, batchOf(2), { context: { maxBatchSize: 2 } })).resolves.toMatchObject({ status: 200 }) + await expect(post(handler, batchOf(2), { context: { maxBatchSize: 1 } })).resolves.toMatchObject({ status: 413 }) + expect(maxBatchSize).toHaveBeenCalledWith(expect.objectContaining({ prefix: '/rpc', request: expect.objectContaining({ url: '/rpc' }) })) + }) + }) + + describe('the HTTP request', () => { + it('rejects the whole request with 415 when its content type is not JSON', async () => { + await expect(post(createHandler(), batchOf(1), { headers: { 'content-type': 'text/plain' } })).resolves.toEqual({ + status: 415, + json: { + jsonrpc: '2.0', + id: null, + error: { code: -32015, message: 'JSON-RPC requests must use Content-Type: application/json', data: { code: 'UNSUPPORTED_MEDIA_TYPE', defined: false } }, + }, + }) + expect(notify).not.toHaveBeenCalled() + }) + + it('parses the body as JSON for every JSON-RPC media type', async () => { + await expect(post(createHandler(), { jsonrpc: '2.0', method: 'get_data', id: 1 }, { headers: { 'content-type': 'application/json-rpc' } })) + .resolves + .toEqual({ status: 200, json: { jsonrpc: '2.0', id: 1, result: ['hello', 5] } }) + }) + + it('passes through requests that are not a POST to the prefix', async () => { + const handler = createHandler() + + const get = await handler.handle(new Request('https://example.com/rpc'), { prefix: '/rpc' }) + const nested = await handler.handle(new Request('https://example.com/rpc/notify_hello', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'notify_hello', params: [1], id: 1 }), + }), { prefix: '/rpc' }) + + expect(get.matched).toBe(false) + expect(nested.matched).toBe(false) + expect(notify).not.toHaveBeenCalled() + }) + + it('answers POST requests to any path without a prefix', async () => { + const { matched, response } = await createHandler().handle(new Request('https://example.com/any/path', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'get_data', id: 1 }), + })) + + expect(matched).toBe(true) + await expect(response!.json()).resolves.toEqual({ jsonrpc: '2.0', id: 1, result: ['hello', 5] }) + }) + + it('passes through sub-requests that are already marked as a call', async () => { + const handler = createHandler({ + plugins: [{ + name: 'mark', + after: ['~openrpc'], + init: options => ({ + ...options, + routingInterceptors: [({ next, ...rest }) => next({ + ...rest, + request: { ...rest.request, resolveBody: async () => undefined, [JSON_RPC_CALL_SYMBOL]: { method: 'get_data', id: 1 } } as typeof rest.request, + }), ...options.routingInterceptors ?? []], + }), + }], + }) + + // the codec answer, not wrapped in a JSON-RPC response + await expect(post(handler, { jsonrpc: '2.0', method: 'planet.list', id: 2 })).resolves.toEqual({ status: 200, json: { result: ['hello', 5] } }) + }) + }) + + describe('plugins and interceptors', () => { + it('runs plugins inside the envelope once per call, and outside it once per request', async () => { + const calls: unknown[] = [] + const plugin = (name: string, order: Pick, 'before' | 'after'>): StandardHandlerPlugin => ({ + name, + ...order, + init: options => ({ + ...options, + routingInterceptors: [({ request, next }) => { + calls.push([name, request.url, getJsonRpcCall(request)]) + return next() + }, ...options.routingInterceptors ?? []], + }), + }) + + const handler = createHandler({ plugins: [plugin('outer', { after: ['~openrpc'] }), plugin('inner', { before: ['~openrpc'] })] }) + await post(handler, [{ jsonrpc: '2.0', method: 'get_data', id: 1 }, { jsonrpc: '2.0', method: 'notify_hello', params: [1] }]) + + expect(calls).toEqual([ + ['outer', '/rpc', undefined], + ['inner', '/rpc/get_data', { method: 'get_data', id: 1 }], + ['inner', '/rpc/notify_hello', { method: 'notify_hello', id: undefined }], + ]) + }) + + it('passes the HTTP request headers to each call, without its content-length', async () => { + const seen: Record[] = [] + const handler = createHandler({ + interceptors: [({ request, next }) => { + seen.push(request.headers) + return next() + }], + }) + + const body = JSON.stringify({ jsonrpc: '2.0', method: 'get_data', id: 1 }) + await post(handler, body, { headers: { 'authorization': 'Bearer token', 'content-length': String(body.length) } }) + + expect(seen).toEqual([expect.objectContaining({ 'authorization': 'Bearer token', 'content-type': 'application/json' })]) + expect(seen[0]!['content-length']).toBeUndefined() + }) + + it('forwards the response headers of a call, without its body headers', async () => { + const handler = createHandler({ + interceptors: [async ({ next }) => { + const response = await next() + return { ...response, headers: { ...response.headers, 'x-custom': 'value', 'content-disposition': 'attachment' } } + }], + }) + + const { response } = await handler.handle(createRequest({ jsonrpc: '2.0', method: 'get_data', id: 1 }), { prefix: '/rpc' }) + + expect(response!.headers.get('x-custom')).toBe('value') + expect(response!.headers.get('content-disposition')).toBeNull() + expect(response!.headers.get('content-type')).toBe('application/json') + }) + + it('merges the response headers of the calls of a batch, including notifications', async () => { + const handler = createHandler({ + interceptors: [async ({ next, path }) => { + const response = await next() + return { ...response, headers: { ...response.headers, 'set-cookie': `${path.join('.')}=1` } } + }], + }) + + const { response } = await handler.handle(createRequest([ + { jsonrpc: '2.0', method: 'get_data', id: 1 }, + { jsonrpc: '2.0', method: 'notify_hello', params: [1] }, + ]), { prefix: '/rpc' }) + + expect(response!.headers.getSetCookie()).toEqual(['get_data=1', 'notify_hello=1']) + }) + + it('keeps CORS, request limit and response headers plugins on the HTTP request', async () => { + const handler = new OpenRPCHandler({ + login: os.$context().handler(({ context }) => { + context.resHeaders?.set('cache-control', 'no-store') + }), + }, { + plugins: [new CORSHandlerPlugin(), new ResponseHeadersHandlerPlugin(), new RequestLimitHandlerPlugin({ maxBodySize: 200 })], + }) + + const { response } = await handler.handle(createRequest([{ jsonrpc: '2.0', method: 'login', id: 1 }], { origin: 'https://example.com' }), { prefix: '/rpc', context: {} }) + + expect(response!.headers.get('access-control-allow-origin')).toBe('*') + expect(response!.headers.get('cache-control')).toBe('no-store') + + await expect(post(handler, batchOf(5))).resolves.toMatchObject({ + status: 413, + json: { id: null, error: { code: -32013, data: { code: 'PAYLOAD_TOO_LARGE' } } }, + }) + }) + + it('opens one tracing span per call of a batch', async ({ onTestFinished }) => { + const tracer = getTracer() + onTestFinished(() => setTracer(tracer)) + + const names: string[] = [] + const span = { setAttribute: vi.fn(), updateName: vi.fn(), addEvent: vi.fn(), recordException: vi.fn(), end: vi.fn() } + setTracer({ + startSpan: () => span, + startActiveSpan: (name, _parent, fn) => { + names.push(name) + return fn(span) + }, + getActiveSpan: () => span, + withActiveSpan: (_span, fn) => fn(), + }) + + await post(createHandler(), [{ jsonrpc: '2.0', method: 'get_data', id: 1 }, { jsonrpc: '2.0', method: 'notify_hello', params: [1] }]) + + expect(names.filter(name => name.startsWith('POST'))).toEqual(['POST /rpc/get_data', 'POST /rpc/notify_hello']) + }) + + it('rethrows errors of a single call, but answers the other calls of a batch with -32603', async ({ onTestFinished }) => { + const rejectSpy = vi.spyOn(Promise, 'reject').mockImplementation(() => new Promise(() => {}) as Promise) + + onTestFinished(() => { + rejectSpy.mockRestore() + }) + + const handler = createHandler({ plugins: [new RethrowHandlerPlugin({ filter: error => !(error instanceof ORPCError) })] }) + + await expect(post(handler, { jsonrpc: '2.0', method: 'planet.fail', id: 1 })).rejects.toThrow('secret') + expect(rejectSpy).not.toHaveBeenCalled() + + expect((await post(handler, [{ jsonrpc: '2.0', method: 'planet.fail', id: 1 }, { jsonrpc: '2.0', method: 'get_data', id: 2 }])).json).toEqual([ + { jsonrpc: '2.0', id: 1, error: { code: -32603, message: 'Internal error' } }, + { jsonrpc: '2.0', id: 2, result: ['hello', 5] }, + ]) + expect(rejectSpy).toHaveBeenCalledOnce() + expect(rejectSpy).toHaveBeenCalledWith(expect.objectContaining({ message: 'secret' })) + }) + }) +}) diff --git a/packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.ts b/packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.ts new file mode 100644 index 0000000000..dd86813eaa --- /dev/null +++ b/packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.ts @@ -0,0 +1,223 @@ +import type { AnyORPCError } from '@orpc/client' +import type { Context } from '@orpc/server' +import type { StandardHandlerHandleResult, StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor, StandardHandlerRoutingInterceptorOptions } from '@orpc/server/standard' +import type { Promisable, Value } from '@orpc/shared' +import type { StandardHeaders, StandardLazyRequest } from '@standard-server/core' +import type { JsonRpcId, JsonRpcResponse } from '../../json-rpc' +import type { OpenRPCHandlerCodecOptions } from './openrpc-handler-codec' +import type { JsonRpcCall } from './utils' +import { COMMON_ERROR_STATUS_MAP, ORPCError } from '@orpc/client' +import { OpenAPIJsonSerializer } from '@orpc/openapi' +import { DEFAULT_ERROR_STATUS } from '@orpc/server' +import { getOwn, isPlainObject, matchesHttpPath, mergeHttpPath, pathToHttpPath, toArray, value } from '@orpc/shared' +import { mergeStandardHeaders } from '@standard-server/core' +import { DEFAULT_JSON_RPC_MAX_BATCH_SIZE, JSON_RPC_ERROR_CODES, JSON_RPC_VERSION } from '../../constants' +import { encodeJsonRpcError, isJsonRpcErrorObject, isJsonRpcId } from '../../json-rpc' +import { getJsonRpcCall, isJsonRpcMediaType, JSON_RPC_CALL_SYMBOL } from './utils' + +export interface OpenRPCEnvelopeHandlerPluginOptions + extends Pick, 'serializer' | 'errorCodeMap'> { + /** + * The max number of requests in a batch. Larger batches are rejected with `413`. + * + * @default DEFAULT_JSON_RPC_MAX_BATCH_SIZE (100) + * @see {@link https://orpc.dev/docs/openrpc/handler#batch-requests-and-notifications | OpenRPC Handler - Batch Requests and Notifications} + */ + maxBatchSize?: Value, [options: StandardHandlerRoutingInterceptorOptions]> | undefined +} + +const PROTOCOL_ERROR_MESSAGES = { + [JSON_RPC_ERROR_CODES.PARSE_ERROR]: 'Parse error', + [JSON_RPC_ERROR_CODES.INVALID_REQUEST]: 'Invalid Request', + [JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND]: 'Method not found', + [JSON_RPC_ERROR_CODES.INTERNAL_ERROR]: 'Internal error', +} + +function protocolError(id: JsonRpcId, code: keyof typeof PROTOCOL_ERROR_MESSAGES): JsonRpcResponse { + return { jsonrpc: JSON_RPC_VERSION, id, error: { code, message: PROTOCOL_ERROR_MESSAGES[code] } } +} + +function reply(body: JsonRpcResponse | JsonRpcResponse[] | undefined, headers: StandardHeaders = {}): StandardHandlerHandleResult { + return { matched: true, response: { status: body === undefined ? 204 : 200, headers, body } } +} + +/** + * Reads the JSON-RPC 2.0 requests (single or batch) of an HTTP POST request and runs each through + * the rest of the handler as its own sub-request, then answers with the JSON-RPC response(s). + * Other requests (not POST, or another path than the prefix) pass through untouched. + * + * `OpenRPCHandler` adds it automatically. Plugins and interceptors inside it, such as tracing, + * see one sub-request per call, see `getJsonRpcCall`. + */ +export class OpenRPCEnvelopeHandlerPlugin implements StandardHandlerPlugin { + name = '~openrpc' + + /** + * Wrap the tracing interceptor so each call gets its own span. + */ + after = ['~tracing'] + + /** + * These must apply to the real HTTP request and response, not to each call. + */ + before = ['~cors', '~request-limit', '~request-compression', '~response-compression', '~response-headers', '~tmp-file-upload'] + + private readonly maxBatchSize: Exclude['maxBatchSize'], undefined> + private readonly serializer: Exclude['serializer'], undefined> + private readonly errorCodeMap: OpenRPCEnvelopeHandlerPluginOptions['errorCodeMap'] + + constructor(options: OpenRPCEnvelopeHandlerPluginOptions = {}) { + this.maxBatchSize = options.maxBatchSize ?? DEFAULT_JSON_RPC_MAX_BATCH_SIZE + this.serializer = options.serializer ?? new OpenAPIJsonSerializer() + this.errorCodeMap = options.errorCodeMap + } + + init(options: StandardHandlerOptions): StandardHandlerOptions { + const routingInterceptor: StandardHandlerRoutingInterceptor = async (interceptorOptions) => { + const { request, prefix } = interceptorOptions + + if ( + request.method !== 'POST' + || (prefix !== undefined && !matchesHttpPath(request.url, prefix)) + || getJsonRpcCall(request) !== undefined + ) { + return interceptorOptions.next() + } + + if (!isJsonRpcMediaType(request.headers['content-type'])) { + return this.reject(new ORPCError('UNSUPPORTED_MEDIA_TYPE', { message: 'JSON-RPC requests must use Content-Type: application/json' })) + } + + let payload: unknown + + try { + payload = await request.resolveBody('json') + } + catch (error) { + // errors of outer plugins, such as the request limit plugin + if (error instanceof ORPCError) { + return this.reject(error) + } + } + + if (payload === undefined) { + return reply(protocolError(null, JSON_RPC_ERROR_CODES.PARSE_ERROR)) + } + + if (!Array.isArray(payload)) { + const { response, headers } = await this.call(interceptorOptions, payload, false) + return reply(response, headers) + } + + if (payload.length === 0) { + return reply(protocolError(null, JSON_RPC_ERROR_CODES.INVALID_REQUEST)) + } + + const maxBatchSize = await value(this.maxBatchSize, interceptorOptions) + + if (payload.length > maxBatchSize) { + return this.reject(new ORPCError('PAYLOAD_TOO_LARGE', { message: `Batch size exceeds the maximum of ${maxBatchSize}.` })) + } + + const results = await Promise.all(payload.map(item => this.call(interceptorOptions, item, true))) + const responses = results.flatMap(({ response }) => response ? [response] : []) + + // a batch of only notifications is answered with nothing, never an empty array + return reply(responses.length ? responses : undefined, results.reduce((merged, { headers }) => mergeStandardHeaders(merged, headers), {})) + } + + return { + ...options, + routingInterceptors: [routingInterceptor, ...toArray(options.routingInterceptors)], + } + } + + /** + * Runs one call as a sub-request whose body is its `params`. Notifications run to completion too, but are never answered. + */ + private async call( + options: Parameters>[0], + item: unknown, + batch: boolean, + ): Promise<{ response?: JsonRpcResponse, headers: StandardHeaders }> { + const { jsonrpc, method, params, id: rawId } = isPlainObject(item) + ? { jsonrpc: getOwn(item, 'jsonrpc'), method: getOwn(item, 'method'), params: getOwn(item, 'params'), id: getOwn(item, 'id') } + : {} as Record + + if ( + jsonrpc !== JSON_RPC_VERSION + || typeof method !== 'string' + || (rawId !== undefined && !isJsonRpcId(rawId)) + || (params !== undefined && params !== null && !Array.isArray(params) && !isPlainObject(params)) + ) { + // answered even without an `id`, with the `id` when it is valid + return { response: protocolError(isJsonRpcId(rawId) ? rawId : null, JSON_RPC_ERROR_CODES.INVALID_REQUEST), headers: {} } + } + + const id = rawId as JsonRpcId | undefined + const call: JsonRpcCall = { method, id } + const request: StandardLazyRequest & { [JSON_RPC_CALL_SYMBOL]: JsonRpcCall } = { + method: 'POST', + // only for logs and spans, the codec routes by the call + url: mergeHttpPath(options.prefix ?? '/', pathToHttpPath(method.split('.'))), + headers: { ...options.request.headers, 'content-length': undefined }, + signal: options.request.signal, + resolveBody: async () => params ?? undefined, + [JSON_RPC_CALL_SYMBOL]: call, + } + + let result: StandardHandlerHandleResult + + try { + result = await options.next({ ...options, request }) + } + catch (error) { + if (!batch) { + throw error + } + + // like the batch plugin, report it as an unhandled rejection and answer the other calls + Promise.reject(error) + result = { matched: true, response: { status: 500, headers: {}, body: undefined } } + } + + const headers = Object.fromEntries(Object.entries(result.response?.headers ?? {}) + .filter(([name]) => !name.startsWith('content-') && name !== 'standard-server')) + + if (id === undefined) { + return { headers } + } + + if (!result.matched) { + return { response: protocolError(id, JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND), headers } + } + + const body = result.response.body + + if (isPlainObject(body) && Object.hasOwn(body, 'result') !== Object.hasOwn(body, 'error')) { + if (Object.hasOwn(body, 'result')) { + return { response: { jsonrpc: JSON_RPC_VERSION, id, result: body.result }, headers } + } + + if (isJsonRpcErrorObject(body.error)) { + return { response: { jsonrpc: JSON_RPC_VERSION, id, error: body.error }, headers } + } + } + + return { response: protocolError(id, JSON_RPC_ERROR_CODES.INTERNAL_ERROR), headers } + } + + /** + * Rejects the whole HTTP request (wrong content type, body or batch too large) with the status of the error. + */ + private reject(error: AnyORPCError): StandardHandlerHandleResult { + return { + matched: true, + response: { + status: getOwn(COMMON_ERROR_STATUS_MAP, error.code) ?? DEFAULT_ERROR_STATUS, + headers: {}, + body: { jsonrpc: JSON_RPC_VERSION, id: null, error: encodeJsonRpcError(error, this.serializer, this.errorCodeMap) }, + }, + } + } +} diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler-codec.test.ts b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.test.ts new file mode 100644 index 0000000000..d27aba6af6 --- /dev/null +++ b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.test.ts @@ -0,0 +1,272 @@ +import type { StandardLazyRequest } from '@standard-server/core' +import { createORPCErrorFromJson, ORPCError } from '@orpc/client' +import { eventIterator } from '@orpc/contract' +import { DEFAULT_ERROR_STATUS, os } from '@orpc/server' +import { z } from 'zod' +import { OpenRPCHandlerCodec } from './openrpc-handler-codec' +import { JSON_RPC_CALL_SYMBOL } from './utils' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('openRPCHandlerCodec', () => { + /** + * A sub-request the envelope plugin creates for a JSON-RPC call, or a raw HTTP request when `method` is undefined. + */ + function createRequest(method: string | undefined, params?: unknown, overrides: Partial = {}): StandardLazyRequest { + return { + method: 'POST', + url: '/', + headers: {}, + signal: undefined, + resolveBody: vi.fn(async () => params), + ...(method === undefined ? {} : { [JSON_RPC_CALL_SYMBOL]: { method, id: 1 } }), + ...overrides, + } + } + + describe('.resolveProcedure', () => { + describe('routing', () => { + const ping = os.handler(vi.fn()) + const find = os.handler(vi.fn()) + const router = { + ping, + planet: { find }, + rpc: { discover: os.handler(vi.fn()) }, + events: os.output(eventIterator(z.string())).handler(async function* () {}), + upload: os.input(eventIterator(z.string())).handler(vi.fn()), + } + + const codec = new OpenRPCHandlerCodec(router) + + it('resolves the procedure named by the call method, ignoring the request url', async () => { + const result = await codec.resolveProcedure(createRequest('planet.find', undefined, { url: '/ping' })) + + expect(result).toBeDefined() + expect(result!.path).toEqual(['planet', 'find']) + expect(result!.procedure).toBe(find) + }) + + it('never resolves requests that are not marked as a JSON-RPC call', async () => { + await expect(codec.resolveProcedure(createRequest(undefined, undefined, { url: '/ping' }))).resolves.toBeUndefined() + await expect(codec.resolveProcedure(createRequest(undefined, undefined, { url: '/planet/find' }))).resolves.toBeUndefined() + }) + + it.each([ + 'missing', + 'planet', + 'toString', + '__proto__', + 'planet/find', + ])('returns undefined for a method that is not a procedure: %s', async (method) => { + await expect(codec.resolveProcedure(createRequest(method))).resolves.toBeUndefined() + }) + + it('returns undefined for reserved rpc.* method names, even when the router has them', async () => { + await expect(codec.resolveProcedure(createRequest('rpc.discover'))).resolves.toBeUndefined() + }) + + it.each([ + '', + 'ping.', + ])('returns undefined for a method with an empty segment: %j', async (method) => { + await expect(codec.resolveProcedure(createRequest(method))).resolves.toBeUndefined() + }) + + it('never resolves procedures with an event iterator input or output', async () => { + await expect(codec.resolveProcedure(createRequest('events'))).resolves.toBeUndefined() + await expect(codec.resolveProcedure(createRequest('upload'))).resolves.toBeUndefined() + }) + + it('respects the filter option', async () => { + const filter = vi.fn((_procedure: unknown, path: readonly string[]) => path[0] !== 'ping') + const codec = new OpenRPCHandlerCodec(router, { filter }) + + await expect(codec.resolveProcedure(createRequest('ping'))).resolves.toBeUndefined() + await expect(codec.resolveProcedure(createRequest('planet.find'))).resolves.toMatchObject({ procedure: find }) + expect(filter).toHaveBeenCalledWith(ping, ['ping']) + }) + }) + + describe('params', () => { + const router = { + any: os.handler(vi.fn()), + object: os.input(z.object({ limit: z.number().optional() })).handler(vi.fn()), + void: os.input(z.void()).handler(vi.fn()), + required: os.input(z.object({ id: z.number() })).handler(vi.fn()), + asyncDefined: os.input(z.any().refine(async input => input !== undefined)).handler(vi.fn()), + // reads `input.limit` without validating, so it throws for an `undefined` input + throwing: os.input(z.custom<{ limit?: number }>().transform(input => ({ limit: input.limit ?? 10 }))).handler(vi.fn()), + } + + const codec = new OpenRPCHandlerCodec(router) + + async function decode(method: keyof typeof router, params: unknown) { + const result = await codec.resolveProcedure(createRequest(method, params)) + return result!.decodeInput() + } + + it('reads params only once the input is decoded', async () => { + const request = createRequest('any', { id: 1 }) + const result = await codec.resolveProcedure(request) + + expect(request.resolveBody).not.toHaveBeenCalled() + await expect(result!.decodeInput()).resolves.toEqual({ id: 1 }) + expect(request.resolveBody).toHaveBeenCalledOnce() + }) + + it('uses by-name params as the input itself', async () => { + await expect(decode('required', { id: 1, extra: [true] })).resolves.toEqual({ id: 1, extra: [true] }) + }) + + it.each([ + [[42], 42], + [[[1, 2]], [1, 2]], + [[{}], {}], + [[null], null], + ])('uses the single positional param of %j as the input', async (params, input) => { + await expect(decode('any', params)).resolves.toEqual(input) + }) + + it('rejects more than one positional param with BAD_REQUEST', async () => { + const promise = decode('any', [1, 2]) + + await expect(promise).rejects.toBeInstanceOf(ORPCError) + await expect(promise).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Invalid params: expected at most 1 positional parameter, received 2.', + }) + }) + + describe('empty params', () => { + it.each([undefined, [], {}])('resolves %j to {} when the input schema only accepts an object', async (params) => { + await expect(decode('object', params)).resolves.toEqual({}) + }) + + it.each([undefined, [], {}])('resolves %j to undefined when the input schema only accepts undefined', async (params) => { + await expect(decode('void', params)).resolves.toBeUndefined() + }) + + it.each([ + [undefined, undefined], + [[], undefined], + [{}, {}], + ])('keeps %j as %j when the input schema accepts both or neither', async (params, input) => { + await expect(decode('any', params)).resolves.toEqual(input) + await expect(decode('required', params)).resolves.toEqual(input) + }) + + it('probes async schemas', async () => { + await expect(decode('asyncDefined', undefined)).resolves.toEqual({}) + }) + + it('treats a schema that throws while probing as not accepting the value', async () => { + await expect(decode('throwing', undefined)).resolves.toEqual({}) + }) + }) + }) + }) + + describe('.encodeOutput', () => { + const procedure = os.handler(vi.fn()) + const codec = new OpenRPCHandlerCodec({ procedure }) + + it('serializes the output into the result like OpenAPI responses', async () => { + await expect(codec.encodeOutput({ + date: new Date(0), + bigint: 1n, + set: new Set([1, 2]), + url: new URL('https://example.com'), + omitted: undefined, + }, procedure, ['procedure'])).resolves.toEqual({ + status: 200, + headers: {}, + body: { result: { date: '1970-01-01T00:00:00.000Z', bigint: '1', set: [1, 2], url: 'https://example.com/' } }, + }) + }) + + it('sends an undefined output as a null result, keeping the required result member', async () => { + const response = await codec.encodeOutput(undefined, procedure, ['procedure']) + + expect(response.body).toEqual({ result: null }) + expect(JSON.parse(JSON.stringify(response.body))).toEqual({ result: null }) + }) + + it.each([ + ['ReadableStream', (cancel: () => void) => new ReadableStream({ cancel })], + ['async iterator', (cancel: () => void) => ({ next: vi.fn(), return: cancel, [Symbol.asyncIterator]() { return this } })], + ])('cancels a %s output and throws, since JSON-RPC cannot stream', async (_, create) => { + const cancel = vi.fn() + + await expect(codec.encodeOutput(create(cancel), procedure, ['planet', 'stream'])).rejects.toThrow( + new TypeError('[OpenRPCHandler] Procedure "planet.stream" returned a stream, which JSON-RPC cannot send.'), + ) + expect(cancel).toHaveBeenCalledOnce() + }) + + it.each([ + ['a blob', { file: new Blob(['x']) }], + ['a function', () => {}], + ['a symbol', Symbol('x')], + ])('throws for an output with %s, which JSON cannot carry', async (_, output) => { + await expect(codec.encodeOutput(output, procedure, ['procedure'])).rejects.toThrow( + new TypeError('[OpenRPCHandler] Procedure "procedure" returned a value JSON-RPC cannot send.'), + ) + }) + }) + + describe('.encodeError', () => { + it('encodes the error with the HTTP status of its code, keeping the oRPC error in data', () => { + const codec = new OpenRPCHandlerCodec({}) + + expect(codec.encodeError(new ORPCError('NOT_FOUND', { message: 'Planet not found', data: { at: new Date(0) } }))).toEqual({ + status: 404, + headers: {}, + body: { + error: { + code: -32004, + message: 'Planet not found', + data: { code: 'NOT_FOUND', defined: false, data: { at: '1970-01-01T00:00:00.000Z' } }, + }, + }, + }) + + expect(codec.encodeError(createORPCErrorFromJson({ code: 'CONFLICT', defined: true, message: 'Conflict', data: undefined }))).toEqual({ + status: 409, + headers: {}, + body: { error: { code: -32009, message: 'Conflict', data: { code: 'CONFLICT', defined: true } } }, + }) + }) + + it('uses the errorCodeMap option instead of the common map, falling back to -32000', () => { + const codec = new OpenRPCHandlerCodec({}, { errorCodeMap: { OUT_OF_STOCK: 1001 } }) + + expect(codec.encodeError(new ORPCError('OUT_OF_STOCK'))).toMatchObject({ status: DEFAULT_ERROR_STATUS, body: { error: { code: 1001 } } }) + expect(codec.encodeError(new ORPCError('NOT_FOUND'))).toMatchObject({ status: 404, body: { error: { code: -32000 } } }) + }) + + it('does not resolve error codes through Object.prototype', () => { + const codec = new OpenRPCHandlerCodec({}) + + expect(codec.encodeError(new ORPCError('toString' as any))).toMatchObject({ status: DEFAULT_ERROR_STATUS, body: { error: { code: -32000 } } }) + }) + }) + + it('uses the serializer option for results and error data', async () => { + const serializer = { + serializeJson: vi.fn(() => ({ json: '__serialized__', maps: [], blobs: [] })), + deserializeJson: vi.fn(), + } + + const procedure = os.handler(vi.fn()) + const codec = new OpenRPCHandlerCodec({ procedure }, { serializer }) + const error = new ORPCError('BAD_REQUEST', { data: '__data__' }) + + await expect(codec.encodeOutput('__output__', procedure, ['procedure'])).resolves.toMatchObject({ body: { result: '__serialized__' } }) + expect(codec.encodeError(error)).toMatchObject({ body: { error: { data: '__serialized__' } } }) + + expect(serializer.serializeJson).toHaveBeenNthCalledWith(1, '__output__') + expect(serializer.serializeJson).toHaveBeenNthCalledWith(2, { code: 'BAD_REQUEST', defined: false, data: '__data__' }) + }) +}) diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts new file mode 100644 index 0000000000..0cfe6bdb3c --- /dev/null +++ b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts @@ -0,0 +1,150 @@ +import type { AnyORPCError } from '@orpc/client' +import type { AnyProcedureContract } from '@orpc/contract' +import type { AnyProcedure, AnyRouter, Context } from '@orpc/server' +import type { StandardHandlerCodec, StandardHandlerCodecResolvedProcedure } from '@orpc/server/standard' +import type { Public, Value } from '@orpc/shared' +import type { StandardLazyRequest, StandardResponse } from '@standard-server/core' +import { COMMON_ERROR_STATUS_MAP, ORPCError } from '@orpc/client' +import { OpenAPIJsonSerializer } from '@orpc/openapi' +import { DEFAULT_ERROR_STATUS } from '@orpc/server' +import { RPCMatcher } from '@orpc/server/standard' +import { getOwn, isAsyncIteratorObject, pathToHttpPath, value } from '@orpc/shared' +import { cancelStandardBody } from '@standard-server/core' +import { encodeJsonRpcError } from '../../json-rpc' +import { isEventIteratorProcedure } from '../../utils' +import { getJsonRpcCall } from './utils' + +export interface OpenRPCHandlerCodecOptions<_T extends Context> { + /** + * Filter which procedures are exposed as JSON-RPC methods. Excluded procedures answer "Method not found". + * Procedures with an event iterator input or output are always excluded. + * + * @default true + */ + filter?: Value | undefined + + /** + * Serializes results and error data into JSON the same way OpenAPI responses are. + * + * @default new OpenAPIJsonSerializer() + */ + serializer?: Public | undefined + + /** + * Mapping ORPCError Code -> JSON-RPC error code. Codes missing from the map fall back to + * `DEFAULT_JSON_RPC_ERROR_CODE` (-32000). Pass the same map to `OpenRPCGenerator`. + * + * @default COMMON_JSON_RPC_ERROR_CODE_MAP + * @see {@link https://orpc.dev/docs/openrpc/handler#error-codes | OpenRPC Handler - Error Codes} + */ + errorCodeMap?: Record | undefined +} + +/** + * The codec of `OpenRPCHandler`. It only resolves the per-call sub-requests created by `OpenRPCEnvelopeHandlerPlugin`, + * so use the two together. + */ +export class OpenRPCHandlerCodec implements StandardHandlerCodec { + private readonly matcher: RPCMatcher + private readonly serializer: Public + private readonly errorCodeMap: OpenRPCHandlerCodecOptions['errorCodeMap'] + + constructor(router: AnyRouter, options: OpenRPCHandlerCodecOptions = {}) { + const filter = options.filter ?? true + + this.matcher = new RPCMatcher(router, { + allowMethods: ['POST'], + filter: (procedure, path) => !isEventIteratorProcedure(procedure) && value(filter, procedure, path), + }) + this.serializer = options.serializer ?? new OpenAPIJsonSerializer() + this.errorCodeMap = options.errorCodeMap + } + + async resolveProcedure(request: StandardLazyRequest): Promise { + const method = getJsonRpcCall(request)?.method + const path = method?.split('.') + + // `rpc.*` names are reserved, and an empty segment would alias another path (`a.` -> `/a/` -> `/a`) + if (!method || method.startsWith('rpc.') || !path || path.includes('')) { + return undefined + } + + const matched = await this.matcher.match('POST', pathToHttpPath(path), undefined) + + if (!matched) { + return undefined + } + + return { + ...matched, + decodeInput: async () => decodeParams(await request.resolveBody(), matched.procedure), + } + } + + async encodeOutput(output: unknown, _procedure: AnyProcedure, path: string[]): Promise { + if (output instanceof ReadableStream || isAsyncIteratorObject(output)) { + await cancelStandardBody(output).catch(() => {}) + throw new TypeError(`[OpenRPCHandler] Procedure "${path.join('.')}" returned a stream, which JSON-RPC cannot send.`) + } + + const { json, blobs } = this.serializer.serializeJson(output) + + // `JSON.stringify` drops functions and symbols, which would leave the response without its required `result` + if (blobs?.length || typeof json === 'function' || typeof json === 'symbol') { + throw new TypeError(`[OpenRPCHandler] Procedure "${path.join('.')}" returned a value JSON-RPC cannot send.`) + } + + // `undefined` serializes to `null` + return { status: 200, headers: {}, body: { result: json } } + } + + encodeError(error: AnyORPCError): StandardResponse { + return { + // the status the per-call plugins (such as rate limiting) see, the HTTP status of JSON-RPC errors is always 200 + status: getOwn(COMMON_ERROR_STATUS_MAP, error.code) ?? DEFAULT_ERROR_STATUS, + headers: {}, + body: { error: encodeJsonRpcError(error, this.serializer, this.errorCodeMap) }, + } + } +} + +/** + * `[x]` is `x` (by-position), a non-empty object is the input itself (by-name), and empty params + * (absent, `[]`, or `{}`) are whichever of `undefined` or `{}` the input schemas accept, + * since generic clients send any of them for methods without required params. + */ +async function decodeParams(params: unknown, procedure: AnyProcedure): Promise { + if (Array.isArray(params)) { + if (params.length > 1) { + throw new ORPCError('BAD_REQUEST', { + message: `Invalid params: expected at most 1 positional parameter, received ${params.length}.`, + }) + } + + if (params.length === 1) { + return params[0] + } + } + else if (params !== undefined && Object.keys(params as object).length > 0) { + return params + } + + const [preferred, other] = params === undefined || Array.isArray(params) ? [undefined, {}] : [{}, undefined] + + return await acceptsInput(procedure, preferred) || !await acceptsInput(procedure, other) ? preferred : other +} + +async function acceptsInput(procedure: AnyProcedure, input: unknown): Promise { + for (const schema of procedure['~orpc'].inputSchemas ?? []) { + try { + if ((await schema['~standard'].validate(input)).issues) { + return false + } + } + catch { + return false + } + } + + return true +} diff --git a/packages/openrpc/src/adapters/standard/openrpc-link-codec.test.ts b/packages/openrpc/src/adapters/standard/openrpc-link-codec.test.ts new file mode 100644 index 0000000000..989328470b --- /dev/null +++ b/packages/openrpc/src/adapters/standard/openrpc-link-codec.test.ts @@ -0,0 +1,328 @@ +import type { StandardUrl } from '@standard-server/core' +import { MalformedResponseError, ORPCError, ResponseError } from '@orpc/client' +import { OpenAPIJsonSerializer } from '@orpc/openapi' +import { OpenRPCLinkCodec } from './openrpc-link-codec' + +function expectORPCErrorResult( + result: any, + code: string, + options: { + defined: boolean + message?: string + data?: unknown + }, +) { + expect(result.kind).toBe('error') + expect(result.error).toBeInstanceOf(ORPCError) + expect(result.error.code).toBe(code) + expect(result.error.defined).toBe(options.defined) + + if (options.message !== undefined) { + expect(result.error.message).toBe(options.message) + } + + if (options.data !== undefined) { + expect(result.error.data).toEqual(options.data) + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('openRPCLinkCodec', () => { + describe('.encodeInput', () => { + it('builds a POST JSON-RPC request with default url and headers', async () => { + const codec = new OpenRPCLinkCodec() + + const request = await codec.encodeInput({ id: 1 }, ['planet', 'find'], { context: {} }) + + expect(request).toEqual({ + method: 'POST', + url: '/', + headers: {}, + body: { jsonrpc: '2.0', id: 1, method: 'planet.find', params: { id: 1 } }, + signal: undefined, + }) + }) + + it('uses the url, headers and signal of the call', async () => { + const codec = new OpenRPCLinkCodec({ url: '/rpc', headers: { authorization: 'Bearer token' } }) + const signal = AbortSignal.timeout(100) + + const request = await codec.encodeInput('input', ['ping'], { context: {}, signal }) + + expect(request.url).toBe('/rpc') + expect(request.headers).toEqual({ authorization: 'Bearer token' }) + expect(request.signal).toBe(signal) + }) + + it('increments the request id per codec', async () => { + const codec = new OpenRPCLinkCodec() + const other = new OpenRPCLinkCodec() + + const ids = [ + (await codec.encodeInput('a', ['ping'], { context: {} })).body, + (await codec.encodeInput('b', ['ping'], { context: {} })).body, + (await other.encodeInput('c', ['ping'], { context: {} })).body, + ].map(body => (body as { id: number }).id) + + expect(ids).toEqual([1, 2, 1]) + }) + + describe('params', () => { + it('sends plain objects by-name', async () => { + const codec = new OpenRPCLinkCodec() + + const request = await codec.encodeInput({ id: 1, name: 'earth' }, ['ping'], { context: {} }) + + expect(request.body).toEqual({ jsonrpc: '2.0', id: 1, method: 'ping', params: { id: 1, name: 'earth' } }) + }) + + it.each([ + ['a string', 'text', ['text']], + ['a number', 42, [42]], + ['null', null, [null]], + ['an array', [1, 2], [[1, 2]]], + ])('sends %s by-position', async (_, input, params) => { + const codec = new OpenRPCLinkCodec() + + const request = await codec.encodeInput(input, ['ping'], { context: {} }) + + expect(request.body).toEqual({ jsonrpc: '2.0', id: 1, method: 'ping', params }) + }) + + it('omits params for undefined input', async () => { + const codec = new OpenRPCLinkCodec() + + const request = await codec.encodeInput(undefined, ['ping'], { context: {} }) + + expect(request.body).toEqual({ jsonrpc: '2.0', id: 1, method: 'ping' }) + expect(request.body).not.toHaveProperty('params') + }) + + it('serializes params like OpenAPI JSON', async () => { + const codec = new OpenRPCLinkCodec() + + const request = await codec.encodeInput({ + date: new Date('2024-01-02T03:04:05.000Z'), + bigint: 123n, + set: new Set([1, 2]), + nested: { url: new URL('https://orpc.dev'), missing: undefined }, + }, ['ping'], { context: {} }) + + expect(request.body).toEqual({ + jsonrpc: '2.0', + id: 1, + method: 'ping', + params: { + date: '2024-01-02T03:04:05.000Z', + bigint: '123', + set: [1, 2], + nested: { url: 'https://orpc.dev/' }, + }, + }) + }) + }) + + it.each([ + ['a Blob', () => new Blob(['data'])], + ['a nested File', () => ({ avatar: new File(['data'], 'avatar.png') })], + ['a ReadableStream', () => new ReadableStream()], + ['an async iterator', () => (async function* () { yield 1 })()], + ])('throws for %s input before assigning an id', async (_, factory) => { + const codec = new OpenRPCLinkCodec() + + await expect(codec.encodeInput(factory(), ['planet', 'upload'], { context: {} })).rejects.toThrow( + new TypeError('[OpenRPCLink] JSON-RPC cannot send streams or binary data in call to procedure (planet.upload).'), + ) + + const request = await codec.encodeInput('next', ['ping'], { context: {} }) + expect(request.body).toMatchObject({ id: 1 }) + }) + + describe('option handling', () => { + it('supports url as a function', async () => { + const url = vi.fn(async () => '/dynamic' as StandardUrl) + const codec = new OpenRPCLinkCodec({ url }) + const options = { context: {} } + + const request = await codec.encodeInput('input', ['ping'], options) + + expect(request.url).toBe('/dynamic') + expect(url).toHaveBeenCalledWith(options, ['ping'], 'input') + }) + + it('supports headers as a function', async () => { + const headers = vi.fn(async () => ({ 'x-dynamic': 'yes' })) + const codec = new OpenRPCLinkCodec({ headers }) + const options = { context: {} } + + const request = await codec.encodeInput('input', ['ping'], options) + + expect(request.headers).toEqual({ 'x-dynamic': 'yes' }) + expect(headers).toHaveBeenCalledWith(options, ['ping'], 'input') + }) + + it('uses a custom serializer', async () => { + const codec = new OpenRPCLinkCodec({ + serializer: new OpenAPIJsonSerializer({ + handlers: { + date: { condition: v => v instanceof Date, serialize: (v: Date) => v.getTime(), isTerminal: true }, + }, + }), + }) + + const request = await codec.encodeInput({ at: new Date(5) }, ['ping'], { context: {} }) + + expect(request.body).toMatchObject({ params: { at: 5 } }) + }) + }) + }) + + describe('.decodeResponse', () => { + /** + * Encodes a call first, so the codec knows the request id (`1`) the response must answer. + */ + async function decode(body: unknown, { status = 200, headers = {} }: { status?: number, headers?: Record } = {}) { + const codec = new OpenRPCLinkCodec() + const options = { context: {} } + const resolveBody = vi.fn(async () => body) + + await codec.encodeInput('input', ['ping'], options) + + return { + resolveBody, + result: await codec.decodeResponse({ status, headers, resolveBody }, ['ping'], options), + } + } + + it('returns the result as output', async () => { + const { result, resolveBody } = await decode({ jsonrpc: '2.0', id: 1, result: { ok: true } }) + + expect(result).toEqual({ kind: 'output', output: { ok: true } }) + expect(resolveBody).toHaveBeenCalledOnce() + expect(resolveBody).toHaveBeenCalledWith('json') + }) + + it('restores ORPCErrors of oRPC servers, including their defined flag', async () => { + const body = { + jsonrpc: '2.0', + id: 1, + error: { code: -32004, message: 'Planet not found', data: { code: 'NOT_FOUND', defined: true, data: { id: 2 } } }, + } + + const { result } = await decode(body, { headers: { 'x-request-id': 'abc' } }) as { result: any } + + expectORPCErrorResult(result, 'NOT_FOUND', { defined: true, message: 'Planet not found', data: { id: 2 } }) + expect(result.error.cause).toBeInstanceOf(ResponseError) + expect(result.error.cause).not.toBeInstanceOf(MalformedResponseError) + expect(result.error.cause.message).toBe('Received JSON-RPC error -32004.') + expect(result.error.cause.response).toEqual({ status: 404, headers: { 'x-request-id': 'abc' }, body }) + }) + + it('maps errors of other servers by their JSON-RPC code', async () => { + const { result } = await decode({ jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found', data: '0x' } }) + + expectORPCErrorResult(result, 'NOT_FOUND', { defined: false, message: 'Method not found', data: '0x' }) + }) + + it('keeps data of other servers that only partly looks like an oRPC error', async () => { + const data = { code: 'NOT_FOUND', data: { id: 2 } } + + const { result } = await decode({ jsonrpc: '2.0', id: 1, error: { code: -32603, message: 'Failed', data } }) + + expectORPCErrorResult(result, 'INTERNAL_SERVER_ERROR', { defined: false, data }) + }) + + it.each([ + ['the status of the oRPC code', 200, { code: 'TOO_MANY_REQUESTS', defined: false }, 429], + ['the status of the mapped code', 200, undefined, 500], + ['the real error status', 503, { code: 'NOT_FOUND', defined: false }, 503], + ['400 for an unknown code', 200, { code: 'OUT_OF_STOCK', defined: true }, 400], + ])('gives the error cause %s', async (_, status, data, expected) => { + const { result } = await decode({ jsonrpc: '2.0', id: 1, error: { code: -32000, message: 'Failed', data } }, { status }) as { result: any } + + expect(result.error.cause.response.status).toBe(expected) + }) + + describe('request ids', () => { + it.each([ + ['another id', { jsonrpc: '2.0', id: 2, result: 1 }], + ['the id as a string', { jsonrpc: '2.0', id: '1', result: 1 }], + ['a null id', { jsonrpc: '2.0', id: null, result: 1 }], + ['another id on an error', { jsonrpc: '2.0', id: 2, error: { code: -32603, message: 'Failed' } }], + ])('rejects a response with %s', async (_, body) => { + await expect(decode(body)).rejects.toThrow(new MalformedResponseError({ + message: 'Invalid JSON-RPC response.', + response: { status: 200, headers: {}, body }, + })) + }) + + it('accepts an error with a null id, which the server could not relate to the request', async () => { + const { result } = await decode({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }) + + expectORPCErrorResult(result, 'BAD_REQUEST', { defined: false, message: 'Parse error' }) + }) + + it('matches each response to the id of its own call', async () => { + const codec = new OpenRPCLinkCodec() + const first = { context: {} } + const second = { context: {} } + + await codec.encodeInput('a', ['ping'], first) + await codec.encodeInput('b', ['ping'], second) + + const respond = (id: number) => ({ status: 200, headers: {}, resolveBody: async () => ({ jsonrpc: '2.0', id, result: id }) }) + + await expect(codec.decodeResponse(respond(2), ['ping'], second)).resolves.toEqual({ kind: 'output', output: 2 }) + await expect(codec.decodeResponse(respond(1), ['ping'], first)).resolves.toEqual({ kind: 'output', output: 1 }) + await expect(codec.decodeResponse(respond(2), ['ping'], first)).rejects.toThrow('Invalid JSON-RPC response.') + }) + }) + + it.each([ + ['a batch', [{ jsonrpc: '2.0', id: 1, result: 1 }]], + ['a string', ''], + ])('throws MalformedResponseError on %s', async (_, body) => { + const error: any = await decode(body, { status: 502 }).then(() => null, e => e) + + expect(error).toBeInstanceOf(MalformedResponseError) + expect(error.message).toBe('Invalid JSON-RPC response.') + expect(error.response).toEqual({ status: 502, headers: {}, body }) + }) + + it('throws MalformedResponseError when the body is not JSON', async () => { + const codec = new OpenRPCLinkCodec() + const cause = new SyntaxError('Unexpected token \'<\'') + + const error: any = await codec.decodeResponse({ + status: 502, + headers: { 'content-type': 'text/html' }, + resolveBody: async () => { + throw cause + }, + }, ['ping'], { context: {} }).then(() => null, e => e) + + expect(error).toBeInstanceOf(MalformedResponseError) + expect(error.message).toBe('Bad Gateway') + expect(error.response).toEqual({ status: 502, headers: { 'content-type': 'text/html' }, body: undefined }) + expect(error.cause).toBe(cause) + }) + + it('rethrows the abort reason when aborted while reading the body', async () => { + const codec = new OpenRPCLinkCodec() + const controller = new AbortController() + const reason = new Error('aborted') + + await expect(codec.decodeResponse({ + status: 200, + headers: {}, + resolveBody: async () => { + controller.abort(reason) + throw reason + }, + }, ['ping'], { context: {}, signal: controller.signal })).rejects.toBe(reason) + }) + }) +}) diff --git a/packages/openrpc/src/adapters/standard/openrpc-link-codec.ts b/packages/openrpc/src/adapters/standard/openrpc-link-codec.ts new file mode 100644 index 0000000000..04a07602ff --- /dev/null +++ b/packages/openrpc/src/adapters/standard/openrpc-link-codec.ts @@ -0,0 +1,122 @@ +import type { ClientContext, ClientOptions } from '@orpc/client' +import type { StandardLinkCodec, StandardLinkCodecDecodedResponse } from '@orpc/client/standard' +import type { Promisable, Public, Value } from '@orpc/shared' +import type { StandardHeaders, StandardLazyResponse, StandardRequest, StandardUrl } from '@standard-server/core' +import type { JsonRpcRequest } from '../../json-rpc' +import { COMMON_ERROR_STATUS_MAP, createORPCErrorFromJson, MalformedResponseError, ResponseError } from '@orpc/client' +import { OpenAPIJsonSerializer } from '@orpc/openapi' +import { DEFAULT_ERROR_STATUS } from '@orpc/server' +import { getOwn, isAsyncIteratorObject, isPlainObject, value } from '@orpc/shared' +import { JSON_RPC_VERSION } from '../../constants' +import { fromJsonRpcErrorCode, isJsonRpcResponse } from '../../json-rpc' + +export interface OpenRPCLinkCodecOptions { + /** + * The JSON-RPC endpoint (without origin). Should match the handler's prefix. + * + * @default '/' + */ + url?: Value, [options: ClientOptions, path: string[], input: unknown]> | undefined + + /** + * Inject headers to the request. + */ + headers?: Value, [options: ClientOptions, path: string[], input: unknown]> | undefined + + /** + * Serializes the input into JSON the same way OpenAPI requests are. + * + * @default new OpenAPIJsonSerializer() + */ + serializer?: Public | undefined +} + +/** + * Sends each call as a JSON-RPC 2.0 request whose `method` is the procedure path joined by `.`, + * and restores `ORPCError`s, including their `defined` flag, from the responses of oRPC servers. + */ +export class OpenRPCLinkCodec implements StandardLinkCodec { + private readonly url: Exclude['url'], undefined> + private readonly headers: Exclude['headers'], undefined> + private readonly serializer: Exclude['serializer'], undefined> + + /** + * The request id of each call. `StandardLink` passes the same `options` object to `encodeInput` and `decodeResponse`. + */ + private readonly ids = new WeakMap, number>() + private lastId = 0 + + constructor(options: OpenRPCLinkCodecOptions = {}) { + this.url = options.url ?? '/' + this.headers = options.headers ?? {} + this.serializer = options.serializer ?? new OpenAPIJsonSerializer() + } + + async encodeInput(input: unknown, path: string[], options: ClientOptions): Promise { + const serialized = input === undefined ? undefined : this.serializer.serializeJson(input) + + if (input instanceof ReadableStream || isAsyncIteratorObject(input) || serialized?.blobs?.length) { + throw new TypeError(`[OpenRPCLink] JSON-RPC cannot send streams or binary data in call to procedure (${path.join('.')}).`) + } + + const id = ++this.lastId + this.ids.set(options, id) + + const body: JsonRpcRequest = { jsonrpc: JSON_RPC_VERSION, id, method: path.join('.') } + + if (serialized !== undefined) { + // objects are sent by-name, anything else by-position + body.params = isPlainObject(serialized.json) ? serialized.json : [serialized.json] + } + + return { + method: 'POST', + url: await value(this.url, options, path, input), + headers: await value(this.headers, options, path, input), + body, + signal: options.signal, + } + } + + async decodeResponse(response: StandardLazyResponse, _path: string[], options: ClientOptions): Promise { + let body: unknown + + try { + body = await response.resolveBody('json') + } + catch (cause) { + if (options.signal?.aborted) { + throw cause + } + + throw new MalformedResponseError({ response: { status: response.status, headers: response.headers, body: undefined }, cause }) + } + + const snapshot = { status: response.status, headers: response.headers, body } + + // an error the server could not relate to a request (such as a parse error) has a `null` id + if (!isJsonRpcResponse(body) || (body.id !== this.ids.get(options) && !('error' in body && body.id === null))) { + throw new MalformedResponseError({ message: 'Invalid JSON-RPC response.', response: snapshot }) + } + + if (!('error' in body)) { + return { kind: 'output', output: body.result } + } + + const { code, message, data } = body.error + // errors of oRPC servers carry their ORPCError in `data` + const orpc = isPlainObject(data) && typeof data.code === 'string' && typeof data.defined === 'boolean' ? data : undefined + const orpcCode = orpc ? orpc.code as string : fromJsonRpcErrorCode(code) + + const cause = new ResponseError({ + message: `Received JSON-RPC error ${code}.`, + // JSON-RPC errors usually come with 200, so give status-based plugins (such as retry) the status of the code + response: { ...snapshot, status: response.status >= 400 ? response.status : getOwn(COMMON_ERROR_STATUS_MAP, orpcCode) ?? DEFAULT_ERROR_STATUS }, + }) + + return { + kind: 'error', + error: createORPCErrorFromJson({ code: orpcCode, defined: orpc?.defined === true, message, data: orpc ? orpc.data : data }, { cause }), + } + } +} diff --git a/packages/openrpc/src/adapters/standard/utils.test.ts b/packages/openrpc/src/adapters/standard/utils.test.ts new file mode 100644 index 0000000000..90113844d6 --- /dev/null +++ b/packages/openrpc/src/adapters/standard/utils.test.ts @@ -0,0 +1,34 @@ +import type { StandardLazyRequest } from '@standard-server/core' +import { getJsonRpcCall, isJsonRpcMediaType, JSON_RPC_CALL_SYMBOL } from './utils' + +describe('getJsonRpcCall', () => { + const request: StandardLazyRequest = { method: 'POST', url: '/rpc', headers: {}, resolveBody: vi.fn() } + const call = { method: 'planet.find', id: 1 } + + it('returns the call of a sub-request marked by the envelope', () => { + expect(getJsonRpcCall({ ...request, [JSON_RPC_CALL_SYMBOL]: call } as StandardLazyRequest)).toBe(call) + }) + + it('returns undefined for other requests', () => { + expect(getJsonRpcCall(request)).toBeUndefined() + }) + + it('reads calls marked by another copy of the package', () => { + expect(getJsonRpcCall({ ...request, [Symbol.for('ORPC_JSON_RPC_CALL')]: call } as StandardLazyRequest)).toBe(call) + }) +}) + +describe('isJsonRpcMediaType', () => { + it.each([ + ['application/json', true], + ['application/json-rpc', true], + ['application/jsonrequest', true], + ['Application/JSON; charset=utf-8', true], + [['application/json'], true], + [['application/json', 'text/plain'], false], + ['text/plain', false], + [undefined, false], + ])('isJsonRpcMediaType(%j) is %s', (contentType, expected) => { + expect(isJsonRpcMediaType(contentType)).toBe(expected) + }) +}) diff --git a/packages/openrpc/src/adapters/standard/utils.ts b/packages/openrpc/src/adapters/standard/utils.ts new file mode 100644 index 0000000000..87ed5b01a2 --- /dev/null +++ b/packages/openrpc/src/adapters/standard/utils.ts @@ -0,0 +1,41 @@ +import type { StandardLazyRequest } from '@standard-server/core' +import type { JsonRpcId } from '../../json-rpc' +import { flattenStandardHeader } from '@standard-server/core' + +/** + * The JSON-RPC call a per-call sub-request represents. + * + * @see {@link https://orpc.dev/docs/openrpc/handler#interceptors | OpenRPC Handler - Interceptors} + */ +export interface JsonRpcCall { + method: string + /** + * `undefined` for notifications, which are never answered. + */ + id: JsonRpcId | undefined +} + +/** + * Marks the sub-requests the envelope plugin creates, one per JSON-RPC call. The codec only resolves marked + * requests, so a raw HTTP request (whose body or headers cannot carry a symbol) never routes to a procedure. + */ +export const JSON_RPC_CALL_SYMBOL: unique symbol = Symbol.for('ORPC_JSON_RPC_CALL') + +/** + * Returns the JSON-RPC call a sub-request represents, or `undefined` for any other request, + * such as the raw HTTP request seen by plugins outside the envelope. + * + * @see {@link https://orpc.dev/docs/openrpc/handler#interceptors | OpenRPC Handler - Interceptors} + */ +export function getJsonRpcCall(request: StandardLazyRequest): JsonRpcCall | undefined { + return (request as { [JSON_RPC_CALL_SYMBOL]?: JsonRpcCall })[JSON_RPC_CALL_SYMBOL] +} + +/** + * None of these is CORS-safelisted, so a cross-site request always needs a preflight (CSRF protection). + */ +const JSON_RPC_MEDIA_TYPES = new Set(['application/json', 'application/json-rpc', 'application/jsonrequest']) + +export function isJsonRpcMediaType(contentType: string | readonly string[] | undefined): boolean { + return JSON_RPC_MEDIA_TYPES.has(flattenStandardHeader(contentType)?.split(';')[0]?.trim().toLowerCase() ?? '') +} diff --git a/packages/openrpc/src/adapters/websocket/index.test.ts b/packages/openrpc/src/adapters/websocket/index.test.ts new file mode 100644 index 0000000000..a2df28172e --- /dev/null +++ b/packages/openrpc/src/adapters/websocket/index.test.ts @@ -0,0 +1,7 @@ +it('exports OpenRPCHandler, OpenRPCLink and OpenRPCWebSocketLinkTransport', async () => { + await expect(import('.')).resolves.toMatchObject({ + OpenRPCHandler: expect.any(Function), + OpenRPCLink: expect.any(Function), + OpenRPCWebSocketLinkTransport: expect.any(Function), + }) +}) diff --git a/packages/openrpc/src/adapters/websocket/index.ts b/packages/openrpc/src/adapters/websocket/index.ts new file mode 100644 index 0000000000..f53f3f7848 --- /dev/null +++ b/packages/openrpc/src/adapters/websocket/index.ts @@ -0,0 +1,3 @@ +export * from './openrpc-handler' +export * from './openrpc-link' +export * from './transport' diff --git a/packages/openrpc/src/adapters/websocket/openrpc-handler.test-d.ts b/packages/openrpc/src/adapters/websocket/openrpc-handler.test-d.ts new file mode 100644 index 0000000000..98df6ec7e0 --- /dev/null +++ b/packages/openrpc/src/adapters/websocket/openrpc-handler.test-d.ts @@ -0,0 +1,16 @@ +import { os } from '@orpc/server' +import { OpenRPCHandler } from './openrpc-handler' + +it('requires the initial context in message and upgrade', () => { + const handler = new OpenRPCHandler({ ping: os.$context<{ userId: string }>().handler(() => 'pong') }) + const ws = {} as WebSocket + + handler.message(ws, '', { context: { userId: 'u_123' }, prefix: '/api' }) + handler.upgrade(ws, { context: async () => ({ userId: 'u_123' }) }) + // @ts-expect-error - missing context + handler.message(ws, '') + // @ts-expect-error - missing context + handler.upgrade(ws) + // @ts-expect-error - invalid context + handler.upgrade(ws, { context: { userId: 123 } }) +}) diff --git a/packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts b/packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts new file mode 100644 index 0000000000..a0b4827bbc --- /dev/null +++ b/packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts @@ -0,0 +1,221 @@ +import type { StandardHandlerPlugin } from '@orpc/server/standard' +import type { AddressInfo } from 'node:net' +import { once } from 'node:events' +import { os } from '@orpc/server' +import { AbortError, promiseWithResolvers } from '@orpc/shared' +import WebSocket, { WebSocketServer } from 'ws' +import { getJsonRpcCall } from '../standard' +import { OpenRPCHandler } from './openrpc-handler' + +describe('openRPCHandler', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const createHandler = (options: ConstructorParameters[1] = {}) => { + return new OpenRPCHandler({ + ping: os.handler(() => 'pong'), + echo: os.$context<{ userId?: string }>().handler(({ context }) => context.userId), + }, options) + } + + const createWs = () => ({ + send: vi.fn((_data: string | Uint8Array) => undefined), + }) + + const getSentMessages = (ws: ReturnType) => ws.send.mock.calls.map(([data]) => JSON.parse(data as string)) + + const call = (method: string, id?: string | number) => ({ jsonrpc: '2.0', method, id }) + + const request = JSON.stringify(call('ping', 1)) + + it('accepts context and prefix options in message method', async () => { + const interceptor = vi.fn(({ next }) => next()) + const handler = createHandler({ interceptors: [interceptor] }) + const ws = createWs() + + const result = await handler.message(ws, JSON.stringify(call('echo', 1)), { + context: { userId: 'u_123' }, + prefix: '/api', + }) + + expect(result).toEqual({ matched: true }) + expect(getSentMessages(ws)).toEqual([{ jsonrpc: '2.0', id: 1, result: 'u_123' }]) + expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ prefix: '/api', request: expect.objectContaining({ url: '/api/echo' }) })) + }) + + it.each([ + ['string', () => request], + ['bytes', () => new TextEncoder().encode(request)], + ['arrayBuffer', () => new TextEncoder().encode(request).buffer], + ['arrayBuffer parts', () => [new TextEncoder().encode(request).buffer]], + ])('handles %s request', async (_type, createMessage) => { + const handler = createHandler() + const ws = createWs() + + await expect(handler.message(ws, createMessage())).resolves.toEqual({ matched: true }) + expect(getSentMessages(ws)).toEqual([{ jsonrpc: '2.0', id: 1, result: 'pong' }]) + }) + + it('reads the message before the buffer is detached, like uWebSockets does once the listener returns', async () => { + const handler = createHandler() + const ws = createWs() + const buffer = new TextEncoder().encode(request).buffer + + const result = handler.message(ws, buffer, { + context: async () => { + await new Promise(resolve => setTimeout(resolve, 1)) + return {} + }, + }) + structuredClone(buffer, { transfer: [buffer] }) + + await expect(result).resolves.toEqual({ matched: true }) + expect(getSentMessages(ws)).toEqual([{ jsonrpc: '2.0', id: 1, result: 'pong' }]) + }) + + it('answers a batch with one message, and notifications with none', async () => { + const handler = createHandler() + const ws = createWs() + const notification = call('ping') + + await handler.message(ws, JSON.stringify([call('ping', 1), notification, call('ping', 'b')])) + await handler.message(ws, JSON.stringify(notification)) + await handler.message(ws, JSON.stringify([notification, notification])) + + expect(getSentMessages(ws)).toEqual([[ + { jsonrpc: '2.0', id: 1, result: 'pong' }, + { jsonrpc: '2.0', id: 'b', result: 'pong' }, + ]]) + }) + + it('answers unparsable messages with a parse error', async () => { + const handler = createHandler() + const ws = createWs() + + await handler.message(ws, '{"jsonrpc":') + + expect(getSentMessages(ws)).toEqual([{ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }]) + }) + + it('passes options to the codec and the envelope plugin', async () => { + const handler = createHandler({ filter: (_, path) => path[0] !== 'echo', maxBatchSize: 1 }) + const ws = createWs() + + await handler.message(ws, JSON.stringify(call('echo', 1))) + await handler.message(ws, JSON.stringify([call('ping', 1), call('ping', 2)])) + + expect(getSentMessages(ws)).toMatchObject([ + { id: 1, error: { code: -32601 } }, + { id: null, error: { code: -32013, data: { code: 'PAYLOAD_TOO_LARGE' } } }, + ]) + }) + + it('runs plugins and interceptors once per call', async () => { + const interceptor = vi.fn(({ next }) => next()) + const pluginInterceptor = vi.fn(({ next }) => next()) + const plugin: StandardHandlerPlugin = { + name: 'test', + init: options => ({ ...options, interceptors: [...options.interceptors ?? [], pluginInterceptor] }), + } + const handler = createHandler({ interceptors: [interceptor], plugins: [plugin] }) + + await handler.message(createWs(), JSON.stringify([call('ping', 1), call('echo')])) + + for (const spy of [interceptor, pluginInterceptor]) { + expect(spy.mock.calls.map(([{ request }]) => getJsonRpcCall(request))).toEqual([ + { method: 'ping', id: 1 }, + { method: 'echo', id: undefined }, + ]) + } + }) + + it('aborts the in-flight calls of a closed socket only', async () => { + const signals: AbortSignal[] = [] + const release = promiseWithResolvers() + const handler = new OpenRPCHandler({ + ping: os.handler(async ({ signal }) => { + signals.push(signal!) + await release.promise + signal?.throwIfAborted() + return 'pong' + }), + }) + + const closedWs = createWs() + const openWs = createWs() + const closedResult = handler.message(closedWs, request) + const openResult = handler.message(openWs, request) + + await vi.waitFor(() => expect(signals).toHaveLength(2)) + + handler.close(closedWs) + handler.close(closedWs) // safely call again + release.resolve() + + await expect(closedResult).resolves.toEqual({ matched: true }) + await expect(openResult).resolves.toEqual({ matched: true }) + expect(signals[0]!.reason).toEqual(new AbortError('WebSocket closed')) + expect(signals[1]!.aborted).toBe(false) + expect(closedWs.send).not.toHaveBeenCalled() + expect(getSentMessages(openWs)).toEqual([{ jsonrpc: '2.0', id: 1, result: 'pong' }]) + }) + + it.each([ + ['string', (data: string) => data], + ['Blob', (data: string) => new Blob([data])], + ])('wires %s message, close, and error events via upgrade', async (_type, toData) => { + const listeners = new Map void>() + const ws = { + addEventListener: vi.fn((event: string, listener: any) => listeners.set(event, listener)), + send: vi.fn((_data: string | Uint8Array) => undefined), + } + const handler = createHandler() + const close = vi.spyOn(handler, 'close') + + handler.upgrade(ws as any, { context: { userId: 'u_123' } }) + + expect([...listeners.keys()]).toEqual(['message', 'close', 'error']) + + listeners.get('message')!({ data: toData(JSON.stringify(call('echo', 1))) }) + + await vi.waitFor(() => expect(getSentMessages(ws)).toEqual([{ jsonrpc: '2.0', id: 1, result: 'u_123' }])) + + listeners.get('close')!() + expect(close).toHaveBeenCalledWith(ws) + }) + + it('handles messages in arrival order via upgrade, even when a Blob loads first', async () => { + const listeners = new Map void>() + const ws = { + addEventListener: vi.fn((event: string, listener: any) => listeners.set(event, listener)), + send: vi.fn((_data: string | Uint8Array) => undefined), + } + const handler = createHandler() + const message = vi.spyOn(handler, 'message') + + handler.upgrade(ws as any) + + listeners.get('message')!({ data: new Blob([JSON.stringify(call('ping', 1))]) }) + listeners.get('message')!({ data: JSON.stringify(call('ping', 2)) }) + + await vi.waitFor(() => expect(getSentMessages(ws)).toHaveLength(2)) + expect(message.mock.calls.map(([, data]) => data)).toEqual([expect.any(Uint8Array), JSON.stringify(call('ping', 2))]) + }) + + it('closes the connection instead of crashing on malformed ws frames via upgrade', async () => { + const handler = createHandler() + const wss = new WebSocketServer({ port: 0 }) + wss.on('connection', ws => handler.upgrade(ws)) + onTestFinished(() => wss.close()) + + const client = new WebSocket(`ws://localhost:${(wss.address() as AddressInfo).port}`) + await once(client, 'open') + + // `ws` rejects text frames carrying invalid UTF-8 by emitting `error` on the server socket + client.send(new Uint8Array([0xFF]), { binary: false }) + + const [code] = await once(client, 'close') + expect(code).toBe(1007) + }) +}) diff --git a/packages/openrpc/src/adapters/websocket/openrpc-handler.ts b/packages/openrpc/src/adapters/websocket/openrpc-handler.ts new file mode 100644 index 0000000000..e1bd7ed97e --- /dev/null +++ b/packages/openrpc/src/adapters/websocket/openrpc-handler.ts @@ -0,0 +1,105 @@ +import type { Context, Router } from '@orpc/server' +import type { FriendlyStandardHandlerHandleOptions, StandardHandlerOptions } from '@orpc/server/standard' +import type { WebSocketLike } from '@orpc/server/websocket' +import type { Arrayable, MaybeOptionalOptions } from '@orpc/shared' +import type { OpenRPCEnvelopeHandlerPluginOptions, OpenRPCHandlerCodecOptions } from '../standard' +import { resolveFriendlyStandardHandlerHandleOptions, StandardHandler } from '@orpc/server/standard' +import { AbortError, loadBytes, resolveMaybeOptionalOptions, sequential, stringifyJSON, toArray, toStringOrBytes } from '@orpc/shared' +import { OpenRPCEnvelopeHandlerPlugin, OpenRPCHandlerCodec } from '../standard' +import { parseJsonMessage } from './utils' + +export interface OpenRPCHandlerOptions + extends StandardHandlerOptions, OpenRPCHandlerCodecOptions, OpenRPCEnvelopeHandlerPluginOptions {} + +/** + * Serves oRPC procedures as a JSON-RPC 2.0 API over a WebSocket connection: each message is a request or a batch, + * answered with a message on the same socket. Supports standard WebSocket instances (Deno, Bun, Cloudflare), + * Bun ServerWebSocket, "ws" WebSocket, and similar implementations. + * + * @see {@link https://orpc.dev/docs/openrpc/handler#websocket | OpenRPC Handler - WebSocket} + */ +export class OpenRPCHandler { + private readonly handler: StandardHandler + + /** + * Aborts the in-flight calls of a socket once it closes. + */ + private readonly controllers = new WeakMap() + + constructor(router: Router, options: NoInfer> = {}) { + const codec = new OpenRPCHandlerCodec(router, options) + const plugins = [...toArray(options.plugins), new OpenRPCEnvelopeHandlerPlugin(options)] + this.handler = new StandardHandler(codec, { ...options, plugins }) + } + + /** + * Handles a single WebSocket message, a JSON-RPC request or batch, and sends its response on the same socket. + * Notifications are not answered. + * + * @param ws The WebSocket instance. Use the same instance for all messages. + * @param data The received message. Binary data should already be loaded (not a `Blob`). + */ + async message( + ws: WebSocketLike, + data: Arrayable, 'buffer' | 'byteOffset' | 'byteLength'>>, + ...rest: MaybeOptionalOptions> + ): Promise<{ matched: boolean }> { + let controller = this.controllers.get(ws) + + if (!controller) { + this.controllers.set(ws, controller = new AbortController()) + } + + const signal = controller.signal + const options = resolveFriendlyStandardHandlerHandleOptions(resolveMaybeOptionalOptions(rest)) + // parsed before the first `await`, since some servers, like uWebSockets, detach the message buffer once the listener returns. + // invalid JSON resolves to `undefined`, which the envelope answers with "Parse error" + const body = parseJsonMessage(toStringOrBytes(data)) + + const { matched, response } = await this.handler.handle({ + method: 'POST', + // `StandardHandler` and the envelope only accept requests sent to the prefix itself + url: options.prefix ?? '/', + headers: { 'content-type': 'application/json' }, + signal, + resolveBody: async () => body, + }, options) + + if (response?.body !== undefined && !signal.aborted) { + await ws.send(stringifyJSON(response.body)!) + } + + return { matched } + } + + /** + * Aborts the in-flight calls of a closed WebSocket. + * + * @param ws The same WebSocket instance passed to `.message()`. + */ + close(ws: WebSocketLike): void { + this.controllers.get(ws)?.abort(new AbortError('WebSocket closed')) + this.controllers.delete(ws) + } + + /** + * Attaches message, close, and error event listeners to a WebSocket. + * + * Prefer this over calling `.message()` and `.close()` manually. + */ + upgrade( + ws: Pick, + ...rest: MaybeOptionalOptions> + ): void { + // loading a `Blob` is async, so `sequential` keeps the messages in arrival order + ws.addEventListener('message', sequential(async (event) => { + const data = event.data instanceof Blob ? await loadBytes(event.data) : event.data + // not awaited, so a slow call does not hold back the next messages + this.message(ws, data, ...rest) + })) + ws.addEventListener('close', () => this.close(ws)) + + // EventEmitter-based implementations like `ws` throw an unhandled `error` event. `close` always follows and handles cleanup. + ws.addEventListener('error', () => {}) + } +} diff --git a/packages/openrpc/src/adapters/websocket/openrpc-link.test.ts b/packages/openrpc/src/adapters/websocket/openrpc-link.test.ts new file mode 100644 index 0000000000..2f6ad264b8 --- /dev/null +++ b/packages/openrpc/src/adapters/websocket/openrpc-link.test.ts @@ -0,0 +1,303 @@ +import type { AddressInfo } from 'node:net' +import { createORPCClient } from '@orpc/client' +import { OpenAPIJsonSerializer } from '@orpc/openapi' +import { AbortError, promiseWithResolvers } from '@orpc/shared' +import { WebSocketServer, WebSocket as WsWebSocket } from 'ws' +import { OpenRPCLink } from './openrpc-link' + +beforeEach(() => { + vi.clearAllMocks() +}) + +/** + * Some env maybe not available WebSocket global, like node 20 + */ +const WEBSOCKET_CONNECTING = 0 satisfies WebSocket['CONNECTING'] +const WEBSOCKET_OPEN = 1 satisfies WebSocket['OPEN'] +const WEBSOCKET_CLOSING = 2 satisfies WebSocket['CLOSING'] +const WEBSOCKET_CLOSED = 3 satisfies WebSocket['CLOSED'] + +describe('openRPCLink', () => { + const createWs = (readyState: 0 | 1 | 2 | 3 = WEBSOCKET_OPEN) => { + const listeners = { + open: new Set<() => void>(), + message: new Set<(event: { data: unknown }) => Promise>(), + close: new Set<(event: { code: number, reason: string }) => void>(), + } + + const websocket = { + readyState: readyState as number, + addEventListener: vi.fn((event: string, listener: any) => { + listeners[event as keyof typeof listeners]?.add(listener) + }), + removeEventListener: vi.fn(), + send: vi.fn((_data: string) => undefined), + open() { + websocket.readyState = WEBSOCKET_OPEN + listeners.open.forEach(listener => listener()) + }, + async receive(data: unknown) { + await Promise.all([...listeners.message].map(listener => listener({ data }))) + }, + close({ code = 1006, reason = '' } = {}) { + websocket.readyState = WEBSOCKET_CLOSED + listeners.close.forEach(listener => listener({ code, reason })) + }, + } + + return websocket + } + + const getSentRequest = (ws: ReturnType) => JSON.parse(ws.send.mock.calls[0]![0]) + const result = (id: number, value: unknown = 'pong') => ({ jsonrpc: '2.0', id, result: value }) + const respond = (ws: ReturnType, body: unknown) => ws.receive(JSON.stringify(body)) + + it.each([ + ['string', (raw: string) => raw], + ['bytes', (raw: string) => new TextEncoder().encode(raw)], + ['Blob', (raw: string) => new Blob([raw])], + ])('sends JSON-RPC requests and resolves %s responses', async (_type, toData) => { + const ws = createWs() + const orpc = createORPCClient(new OpenRPCLink({ connect: () => ws })) as any + + const promise = orpc.planet.find({ id: 1 }) + + await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(1)) + expect(getSentRequest(ws)).toEqual({ jsonrpc: '2.0', id: 1, method: 'planet.find', params: { id: 1 } }) + + await ws.receive(toData(JSON.stringify(result(1)))) + await expect(promise).resolves.toBe('pong') + }) + + it('connects lazily on the first call and reuses the connection', async () => { + const ws = createWs() + const connect = vi.fn(() => ws) + const orpc = createORPCClient(new OpenRPCLink({ connect })) as any + + expect(connect).not.toHaveBeenCalled() + + const first = orpc.ping('first') + await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(1)) + await respond(ws, result(1, 'pong-1')) + await expect(first).resolves.toBe('pong-1') + + const second = orpc.ping('second') + await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(2)) + await respond(ws, result(2, 'pong-2')) + await expect(second).resolves.toBe('pong-2') + + expect(connect).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['separate messages', (responses: unknown[]) => responses], + ['one batch message', (responses: unknown[]) => [responses]], + ])('matches concurrent responses by id from %s', async (_type, toMessages) => { + const ws = createWs() + const orpc = createORPCClient(new OpenRPCLink({ connect: () => ws })) as any + + const first = orpc.ping('first') + const second = orpc.ping('second') + + await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(2)) + + for (const message of toMessages([result(2, 'pong-2'), result(1, 'pong-1')])) { + await respond(ws, message) + } + + await expect(first).resolves.toBe('pong-1') + await expect(second).resolves.toBe('pong-2') + }) + + it('ignores unparsable messages and unknown ids', async () => { + const ws = createWs() + const orpc = createORPCClient(new OpenRPCLink({ connect: () => ws })) as any + + const promise = orpc.ping('input') + await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(1)) + + await ws.receive('not json') + await respond(ws, result(999, 'unknown')) + await respond(ws, [null, 'text', { jsonrpc: '2.0', result: 'no id' }]) + await respond(ws, result(1)) + + await expect(promise).resolves.toBe('pong') + }) + + it('waits for a CONNECTING socket to open', async () => { + const ws = createWs(WEBSOCKET_CONNECTING) + const orpc = createORPCClient(new OpenRPCLink({ connect: () => ws })) as any + + const promise = orpc.ping('input') + + await vi.waitFor(() => expect(ws.addEventListener).toHaveBeenCalledWith('open', expect.any(Function))) + expect(ws.send).not.toHaveBeenCalled() + + ws.open() + await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(1)) + await respond(ws, result(1)) + await expect(promise).resolves.toBe('pong') + }) + + it('rejects calls when the socket closes before opening', async () => { + const ws = createWs(WEBSOCKET_CONNECTING) + const orpc = createORPCClient(new OpenRPCLink({ connect: () => ws })) as any + + const promise = orpc.ping('input') + + await vi.waitFor(() => expect(ws.addEventListener).toHaveBeenCalledWith('close', expect.any(Function))) + ws.close() + + await expect(promise).rejects.toThrow(new AbortError('WebSocket closed (code 1006: )')) + expect(ws.send).not.toHaveBeenCalled() + }) + + it.each([ + ['connect throws', () => Promise.reject(new Error('connect failed')), new Error('connect failed')], + ['the socket is already closing', () => createWs(WEBSOCKET_CLOSING), new AbortError('WebSocket is already closing or closed')], + ['the socket is already closed', () => createWs(WEBSOCKET_CLOSED), new AbortError('WebSocket is already closing or closed')], + ])('rejects the call when %s, and connects again on the next call', async (_type, fail, error) => { + const ws = createWs() + const connect = vi.fn().mockImplementationOnce(fail).mockImplementationOnce(() => ws) + const orpc = createORPCClient(new OpenRPCLink({ connect })) as any + + await expect(orpc.ping('first')).rejects.toThrow(error) + + const promise = orpc.ping('second') + await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(1)) + await respond(ws, result(2)) + + await expect(promise).resolves.toBe('pong') + expect(connect).toHaveBeenCalledTimes(2) + }) + + it('rejects pending calls when the socket closes, and connects again on the next call', async () => { + const firstWs = createWs() + const secondWs = createWs() + const connect = vi.fn().mockReturnValueOnce(firstWs).mockReturnValueOnce(secondWs) + const orpc = createORPCClient(new OpenRPCLink({ connect })) as any + + const pending = orpc.ping('first') + await vi.waitFor(() => expect(firstWs.send).toHaveBeenCalledTimes(1)) + firstWs.close({ code: 4001, reason: 'server restart' }) + + await expect(pending).rejects.toThrow(new AbortError('WebSocket closed (code 4001: server restart)')) + + const promise = orpc.ping('second') + await vi.waitFor(() => expect(secondWs.send).toHaveBeenCalledTimes(1)) + await respond(secondWs, result(2)) + + await expect(promise).resolves.toBe('pong') + expect(connect).toHaveBeenCalledTimes(2) + }) + + it('aborts a call while the websocket connection is still being resolved', async () => { + const ws = createWs() + const connection = promiseWithResolvers() + const connect = vi.fn(() => connection.promise) + const orpc = createORPCClient(new OpenRPCLink({ connect })) as any + const controller = new AbortController() + const reason = new Error('request aborted') + + const promise = orpc.ping('input', { signal: controller.signal }) + + await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(1)) + controller.abort(reason) + connection.resolve(ws) + + await expect(promise).rejects.toBe(reason) + expect(ws.send).not.toHaveBeenCalled() + }) + + it('rejects an aborted call client-side only, and ignores its late response', async () => { + const ws = createWs() + const orpc = createORPCClient(new OpenRPCLink({ connect: () => ws })) as any + const controller = new AbortController() + const reason = new Error('request aborted') + + const aborted = orpc.ping('aborted', { signal: controller.signal }) + await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(1)) + controller.abort(reason) + + await expect(aborted).rejects.toBe(reason) + expect(ws.send).toHaveBeenCalledTimes(1) // JSON-RPC has no cancel message + + const promise = orpc.ping('next') + await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(2)) + await respond(ws, result(1, 'late')) + await respond(ws, result(2, 'next')) + + await expect(promise).resolves.toBe('next') + }) + + it('rejects a call aborted at any moment before its response', async () => { + const ws = createWs() + const orpc = createORPCClient(new OpenRPCLink({ connect: () => ws })) as any + const reason = new Error('request aborted') + + // abort after a growing number of microtasks, so each step of the call is hit, the gap between awaits included + for (let ticks = 0; ticks < 30; ticks++) { + ws.send.mockClear() + const controller = new AbortController() + const assertion = expect(orpc.ping('input', { signal: controller.signal })).rejects.toBe(reason) + + for (let i = 0; i < ticks; i++) { + await Promise.resolve() + } + + controller.abort(reason) + await new Promise(resolve => setTimeout(resolve)) + // a missed abort would resolve with this response instead of hanging + await Promise.all(ws.send.mock.calls.map(([data]) => respond(ws, result(JSON.parse(data).id)))) + await assertion + } + }) + + it('rejects a call whose send throws, and does not keep it pending', async ({ onTestFinished }) => { + const unhandledRejection = vi.fn() + process.on('unhandledRejection', unhandledRejection) + onTestFinished(() => { + process.off('unhandledRejection', unhandledRejection) + }) + + const ws = createWs() + const error = new Error('send failed') + ws.send.mockImplementationOnce(() => { + throw error + }) + const orpc = createORPCClient(new OpenRPCLink({ connect: () => ws })) as any + + await expect(orpc.ping('input')).rejects.toBe(error) + + ws.close() + await new Promise(resolve => setTimeout(resolve)) + expect(unhandledRejection).not.toHaveBeenCalled() + }) + + it('passes the serializer and standard link options', async () => { + const ws = createWs() + const serializer = new OpenAPIJsonSerializer() + const serializeJson = vi.spyOn(serializer, 'serializeJson') + const interceptor = vi.fn(({ next }) => next()) + const orpc = createORPCClient(new OpenRPCLink({ connect: () => ws, serializer, interceptors: [interceptor] })) as any + + const promise = orpc.ping('input') + await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(1)) + await respond(ws, result(1)) + + await expect(promise).resolves.toBe('pong') + expect(serializeJson).toHaveBeenCalledWith('input') + expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ path: ['ping'], input: 'input' })) + }) + + // `ws` is EventEmitter-based, so an `error` event without a listener throws and crashes the process + it('rejects calls instead of crashing when the connection is refused', async () => { + const refused = new WebSocketServer({ port: 0 }) + const refusedUrl = `ws://localhost:${(refused.address() as AddressInfo).port}` + await new Promise(resolve => refused.close(resolve)) + + const orpc = createORPCClient(new OpenRPCLink({ connect: () => new WsWebSocket(refusedUrl) })) as any + + await expect(orpc.ping('input')).rejects.toThrow(new AbortError('WebSocket closed (code 1006: )')) + }) +}) diff --git a/packages/openrpc/src/adapters/websocket/openrpc-link.ts b/packages/openrpc/src/adapters/websocket/openrpc-link.ts new file mode 100644 index 0000000000..2033cb40f2 --- /dev/null +++ b/packages/openrpc/src/adapters/websocket/openrpc-link.ts @@ -0,0 +1,21 @@ +import type { ClientContext } from '@orpc/client' +import type { StandardLinkOptions } from '@orpc/client/standard' +import type { OpenRPCLinkCodecOptions } from '../standard' +import type { OpenRPCWebSocketLinkTransportOptions } from './transport' +import { StandardLink } from '@orpc/client/standard' +import { OpenRPCLinkCodec } from '../standard' +import { OpenRPCWebSocketLinkTransport } from './transport' + +export interface OpenRPCLinkOptions + extends StandardLinkOptions, OpenRPCWebSocketLinkTransportOptions, Pick, 'serializer'> {} + +/** + * Client link that calls a JSON-RPC 2.0 server, such as `OpenRPCHandler`, over a WebSocket connection. + * + * @see {@link https://orpc.dev/docs/openrpc/link#websocket | OpenRPC Link - WebSocket} + */ +export class OpenRPCLink extends StandardLink { + constructor(options: OpenRPCLinkOptions) { + super(new OpenRPCLinkCodec(options), new OpenRPCWebSocketLinkTransport(options), options) + } +} diff --git a/packages/openrpc/src/adapters/websocket/transport.ts b/packages/openrpc/src/adapters/websocket/transport.ts new file mode 100644 index 0000000000..5ea36e95f2 --- /dev/null +++ b/packages/openrpc/src/adapters/websocket/transport.ts @@ -0,0 +1,117 @@ +import type { ClientContext, ClientOptions } from '@orpc/client' +import type { StandardLinkTransport } from '@orpc/client/standard' +import type { WebSocketLike } from '@orpc/client/websocket' +import type { Promisable } from '@orpc/shared' +import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' +import type { JsonRpcId, JsonRpcRequest } from '../../json-rpc' +import { AbortError, getOwn, isPlainObject, loadBytes, promiseWithResolvers, runWithSignal, sequential, stringifyJSON, toArray, toStringOrBytes } from '@orpc/shared' +import { parseJsonMessage } from './utils' + +/** + * Some environments have no WebSocket global, like Node.js 20 + */ +const WEBSOCKET_CONNECTING = 0 satisfies WebSocket['CONNECTING'] +const WEBSOCKET_OPEN = 1 satisfies WebSocket['OPEN'] + +export interface OpenRPCWebSocketLinkTransportOptions { + /** + * Returns the WebSocket to send calls on. Called on the first call, and again for the next call + * once the previous socket has closed. + */ + connect: () => Promisable +} + +interface Connection { + websocket: WebSocketLike + /** + * The calls waiting for their response, by request id. + */ + pending: Map>> +} + +/** + * Sends JSON-RPC requests as WebSocket messages, and matches the responses to them by `id`. + */ +export class OpenRPCWebSocketLinkTransport implements StandardLinkTransport { + private readonly connect: OpenRPCWebSocketLinkTransportOptions['connect'] + private connection: Promise | undefined + + constructor(options: OpenRPCWebSocketLinkTransportOptions) { + this.connect = options.connect + } + + async send(request: StandardRequest, _path: string[], _options: ClientOptions): Promise { + const { websocket, pending } = await runWithSignal(request.signal, () => this.getConnection()) + const id = (request.body as JsonRpcRequest).id ?? null + const call = promiseWithResolvers() + + pending.set(id, call) + + try { + // checks the signal again before sending, and stops waiting once it aborts + const body = await runWithSignal(request.signal, async () => { + websocket.send(stringifyJSON(request.body)!) + return call.promise + }) + + return { status: 200, headers: {}, resolveBody: async () => body } + } + finally { + // also drops the call when it aborts or `send` throws, so closing the socket later does not reject it unawaited + pending.delete(id) + } + } + + private getConnection(): Promise { + this.connection ??= this.open().catch((error) => { + this.connection = undefined + throw error + }) + + return this.connection + } + + private async open(): Promise { + const websocket = await this.connect() + + if (websocket.readyState !== WEBSOCKET_CONNECTING && websocket.readyState !== WEBSOCKET_OPEN) { + throw new AbortError('WebSocket is already closing or closed') + } + + const pending: Connection['pending'] = new Map() + const opened = websocket.readyState === WEBSOCKET_CONNECTING ? promiseWithResolvers() : undefined + + if (opened) { + websocket.addEventListener('open', () => opened.resolve()) + } + + // loading a `Blob` is async, so `sequential` keeps the messages in arrival order + websocket.addEventListener('message', sequential(async (event: MessageEvent) => { + const data = event.data instanceof Blob ? await loadBytes(event.data) : toStringOrBytes(event.data) + + // unparsable messages and responses to unknown ids are ignored + for (const response of toArray(parseJsonMessage(data))) { + if (isPlainObject(response)) { + pending.get(getOwn(response, 'id') as JsonRpcId)?.resolve(response) + } + } + })) + + websocket.addEventListener('close', (event: CloseEvent) => { + const error = new AbortError(`WebSocket closed (code ${event.code}: ${event.reason})`) + this.connection = undefined + opened?.reject(error) + + for (const call of pending.values()) { + call.reject(error) + } + }) + + // EventEmitter-based implementations like `ws` throw an unhandled `error` event. `close` always follows and handles cleanup. + websocket.addEventListener('error', () => {}) + + await opened?.promise + + return { websocket, pending } + } +} diff --git a/packages/openrpc/src/adapters/websocket/utils.ts b/packages/openrpc/src/adapters/websocket/utils.ts new file mode 100644 index 0000000000..9805c71a55 --- /dev/null +++ b/packages/openrpc/src/adapters/websocket/utils.ts @@ -0,0 +1,10 @@ +import { tryOrUndefined } from '@orpc/shared' + +const textDecoder = new TextDecoder() + +/** + * Parses a WebSocket message as JSON, or returns `undefined` when it is not valid JSON. + */ +export function parseJsonMessage(message: string | Uint8Array): unknown { + return tryOrUndefined(() => JSON.parse(typeof message === 'string' ? message : textDecoder.decode(message))) +} diff --git a/packages/openrpc/src/constants.test.ts b/packages/openrpc/src/constants.test.ts new file mode 100644 index 0000000000..b8e5fee9f2 --- /dev/null +++ b/packages/openrpc/src/constants.test.ts @@ -0,0 +1,22 @@ +import { COMMON_ERROR_STATUS_MAP } from '@orpc/client' +import { COMMON_JSON_RPC_ERROR_CODE_MAP } from './constants' + +it('exports', async () => { + await expect(import('./constants')).resolves.toMatchObject({ + JSON_RPC_VERSION: '2.0', + JSON_RPC_ERROR_CODES: expect.any(Object), + COMMON_JSON_RPC_ERROR_CODE_MAP: expect.any(Object), + DEFAULT_JSON_RPC_ERROR_CODE: expect.any(Number), + DEFAULT_JSON_RPC_MAX_BATCH_SIZE: expect.any(Number), + }) +}) + +it('maps every common error code, BAD_REQUEST to "Invalid params", other 4xx like tRPC and 5xx to "Internal error"', () => { + expect(Object.keys(COMMON_JSON_RPC_ERROR_CODE_MAP)).toEqual(Object.keys(COMMON_ERROR_STATUS_MAP)) + + for (const [code, status] of Object.entries(COMMON_ERROR_STATUS_MAP)) { + const expected = code === 'BAD_REQUEST' ? -32602 : status >= 500 ? -32603 : -32000 - (status - 400) + + expect(COMMON_JSON_RPC_ERROR_CODE_MAP[code as keyof typeof COMMON_ERROR_STATUS_MAP]).toBe(expected) + } +}) diff --git a/packages/openrpc/src/constants.ts b/packages/openrpc/src/constants.ts new file mode 100644 index 0000000000..67b1d4d636 --- /dev/null +++ b/packages/openrpc/src/constants.ts @@ -0,0 +1,53 @@ +import type { COMMON_ERROR_STATUS_MAP } from '@orpc/client' + +export const JSON_RPC_VERSION = '2.0' + +/** + * Error codes the JSON-RPC 2.0 specification reserves for protocol errors. + */ +export const JSON_RPC_ERROR_CODES = { + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, +} as const + +/** + * Default mapping between common oRPC error codes and JSON-RPC error codes. + * Spread it to build a custom `errorCodeMap`. + * + * @see {@link https://orpc.dev/docs/openrpc/handler#error-codes | OpenRPC Handler - Error Codes} + */ +export const COMMON_JSON_RPC_ERROR_CODE_MAP = { + BAD_REQUEST: -32602, + UNAUTHORIZED: -32001, + PAYMENT_REQUIRED: -32002, + FORBIDDEN: -32003, + NOT_FOUND: -32004, + METHOD_NOT_SUPPORTED: -32005, + NOT_ACCEPTABLE: -32006, + TIMEOUT: -32008, + CONFLICT: -32009, + GONE: -32010, + LENGTH_REQUIRED: -32011, + PRECONDITION_FAILED: -32012, + PAYLOAD_TOO_LARGE: -32013, + UNSUPPORTED_MEDIA_TYPE: -32015, + UNPROCESSABLE_CONTENT: -32022, + PRECONDITION_REQUIRED: -32028, + TOO_MANY_REQUESTS: -32029, + CLIENT_CLOSED_REQUEST: -32099, + INTERNAL_SERVER_ERROR: -32603, + NOT_IMPLEMENTED: -32603, + BAD_GATEWAY: -32603, + SERVICE_UNAVAILABLE: -32603, + GATEWAY_TIMEOUT: -32603, +} satisfies Record + +/** + * The JSON-RPC error code of ORPCError codes missing from the error code map (an implementation-defined server error). + */ +export const DEFAULT_JSON_RPC_ERROR_CODE = -32000 + +export const DEFAULT_JSON_RPC_MAX_BATCH_SIZE = 100 diff --git a/packages/openrpc/src/index.test.ts b/packages/openrpc/src/index.test.ts new file mode 100644 index 0000000000..a810e22af3 --- /dev/null +++ b/packages/openrpc/src/index.test.ts @@ -0,0 +1,12 @@ +it('exports openrpc meta, OpenRPCGenerator, JSON-RPC helpers and constants', async () => { + await expect(import('.')).resolves.toMatchObject({ + openrpc: expect.any(Function), + getOpenRPCMeta: expect.any(Function), + OpenRPCGenerator: expect.any(Function), + OpenRPCGeneratorError: expect.any(Function), + isJsonRpcResponse: expect.any(Function), + encodeJsonRpcError: expect.any(Function), + fromJsonRpcErrorCode: expect.any(Function), + COMMON_JSON_RPC_ERROR_CODE_MAP: expect.any(Object), + }) +}) diff --git a/packages/openrpc/src/index.ts b/packages/openrpc/src/index.ts new file mode 100644 index 0000000000..197e7bed6f --- /dev/null +++ b/packages/openrpc/src/index.ts @@ -0,0 +1,5 @@ +export * from './constants' +export * from './json-rpc' +export * from './meta' +export * from './openrpc-generator' +export * from './types' diff --git a/packages/openrpc/src/json-rpc.test.ts b/packages/openrpc/src/json-rpc.test.ts new file mode 100644 index 0000000000..b8038ec399 --- /dev/null +++ b/packages/openrpc/src/json-rpc.test.ts @@ -0,0 +1,112 @@ +import { createORPCErrorFromJson, ORPCError } from '@orpc/client' +import { OpenAPIJsonSerializer } from '@orpc/openapi' +import { encodeJsonRpcError, fromJsonRpcErrorCode, isJsonRpcErrorObject, isJsonRpcId, isJsonRpcResponse } from './json-rpc' + +describe('isJsonRpcId', () => { + it.each([ + [null, true], + ['abc', true], + [1, true], + [1.5, true], + [Number.NaN, false], + [Number.POSITIVE_INFINITY, false], + [undefined, false], + [true, false], + [{}, false], + ])('isJsonRpcId(%j) is %s', (value, expected) => { + expect(isJsonRpcId(value)).toBe(expected) + }) +}) + +describe('isJsonRpcErrorObject', () => { + it.each([ + [{ code: -32600, message: 'Invalid Request' }, true], + [{ code: 1, message: '', data: { reason: 'x' } }, true], + [{ code: 1.5, message: 'x' }, false], + [{ code: '1', message: 'x' }, false], + [{ code: 1 }, false], + [[1, 'x'], false], + [null, false], + ])('isJsonRpcErrorObject(%j) is %s', (value, expected) => { + expect(isJsonRpcErrorObject(value)).toBe(expected) + }) + + it('reads own properties only', () => { + expect(isJsonRpcErrorObject(JSON.parse('{ "message": "x", "__proto__": { "code": 1 } }'))).toBe(false) + }) +}) + +describe('isJsonRpcResponse', () => { + it.each([ + [{ jsonrpc: '2.0', id: 1, result: null }, true], + [{ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }, true], + [{ jsonrpc: '2.0', id: 1 }, false], + [{ jsonrpc: '2.0', result: 1 }, false], + [{ jsonrpc: '1.0', id: 1, result: 1 }, false], + [{ jsonrpc: '2.0', id: 1, result: 1, error: { code: 1, message: 'x' } }, false], + [{ jsonrpc: '2.0', id: 1, error: { code: 1.5, message: 'x' } }, false], + [{ jsonrpc: '2.0', id: {}, result: 1 }, false], + ])('isJsonRpcResponse(%j) is %s', (value, expected) => { + expect(isJsonRpcResponse(value)).toBe(expected) + }) + + it('reads own properties only', () => { + expect(isJsonRpcResponse(JSON.parse('{ "jsonrpc": "2.0", "id": 1, "__proto__": { "result": 1 } }'))).toBe(false) + expect(isJsonRpcResponse(Object.assign(Object.create({ jsonrpc: '2.0' }), { id: 1, result: 1 }))).toBe(false) + }) +}) + +describe('encodeJsonRpcError', () => { + const serializer = new OpenAPIJsonSerializer() + + it('keeps the oRPC code, defined flag and serialized data in data', () => { + const error = createORPCErrorFromJson({ code: 'NOT_FOUND', message: 'Planet not found', defined: true, data: { at: new Date(0) } }) + + expect(encodeJsonRpcError(error, serializer)).toEqual({ + code: -32004, + message: 'Planet not found', + data: { code: 'NOT_FOUND', defined: true, data: { at: '1970-01-01T00:00:00.000Z' } }, + }) + }) + + it('omits undefined oRPC data', () => { + const { data } = encodeJsonRpcError(new ORPCError('BAD_GATEWAY'), serializer) + + expect(data).toEqual({ code: 'BAD_GATEWAY', defined: false }) + expect(data).not.toHaveProperty('data') + }) + + it('uses the given serializer', () => { + const serializeJson = vi.fn(() => ({ json: 'custom', meta: [], maps: [], blobs: [] })) + + expect(encodeJsonRpcError(new ORPCError('NOT_FOUND', { data: 1 }), { serializeJson } as any).data).toBe('custom') + expect(serializeJson).toHaveBeenCalledWith({ code: 'NOT_FOUND', defined: false, data: 1 }) + }) + + it('falls back to -32000 for codes missing from the map', () => { + expect(encodeJsonRpcError(new ORPCError('OUT_OF_STOCK'), serializer).code).toBe(-32000) + expect(encodeJsonRpcError(new ORPCError('toString'), serializer).code).toBe(-32000) + expect(encodeJsonRpcError(new ORPCError('__proto__'), serializer).code).toBe(-32000) + }) + + it('uses a custom error code map instead of the default one', () => { + expect(encodeJsonRpcError(new ORPCError('OUT_OF_STOCK'), serializer, { OUT_OF_STOCK: 1001 }).code).toBe(1001) + expect(encodeJsonRpcError(new ORPCError('NOT_FOUND'), serializer, { OUT_OF_STOCK: 1001 }).code).toBe(-32000) + }) +}) + +describe('fromJsonRpcErrorCode', () => { + it.each([ + [-32700, 'BAD_REQUEST'], + [-32600, 'BAD_REQUEST'], + [-32601, 'NOT_FOUND'], + [-32602, 'BAD_REQUEST'], + [-32603, 'INTERNAL_SERVER_ERROR'], + [-32004, 'NOT_FOUND'], + [-32029, 'TOO_MANY_REQUESTS'], + [-32000, 'INTERNAL_SERVER_ERROR'], + [3, 'INTERNAL_SERVER_ERROR'], + ])('fromJsonRpcErrorCode(%d) is %s', (code, expected) => { + expect(fromJsonRpcErrorCode(code)).toBe(expected) + }) +}) diff --git a/packages/openrpc/src/json-rpc.ts b/packages/openrpc/src/json-rpc.ts new file mode 100644 index 0000000000..e3b05e7087 --- /dev/null +++ b/packages/openrpc/src/json-rpc.ts @@ -0,0 +1,79 @@ +import type { AnyORPCError, ORPCErrorCode } from '@orpc/client' +import type { OpenAPIJsonSerializer } from '@orpc/openapi' +import type { Public } from '@orpc/shared' +import { getOwn, isPlainObject } from '@orpc/shared' +import { COMMON_JSON_RPC_ERROR_CODE_MAP, DEFAULT_JSON_RPC_ERROR_CODE, JSON_RPC_ERROR_CODES, JSON_RPC_VERSION } from './constants' + +export type JsonRpcId = string | number | null + +export interface JsonRpcRequest { + jsonrpc: '2.0' + method: string + params?: unknown[] | Record | undefined + /** + * Absent for notifications. + */ + id?: JsonRpcId | undefined +} + +export interface JsonRpcErrorObject { + code: number + message: string + data?: unknown +} + +export type JsonRpcResponse + = | { jsonrpc: '2.0', id: JsonRpcId, result: unknown } + | { jsonrpc: '2.0', id: JsonRpcId, error: JsonRpcErrorObject } + +export function isJsonRpcId(value: unknown): value is JsonRpcId { + return value === null || typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)) +} + +export function isJsonRpcErrorObject(value: unknown): value is JsonRpcErrorObject { + return isPlainObject(value) && Number.isInteger(getOwn(value, 'code')) && typeof getOwn(value, 'message') === 'string' +} + +/** + * Whether a value is a JSON-RPC response: exactly one own `result` or valid `error` member, and a valid `id`. + */ +export function isJsonRpcResponse(value: unknown): value is JsonRpcResponse { + if (!isPlainObject(value) || getOwn(value, 'jsonrpc') !== JSON_RPC_VERSION || !isJsonRpcId(getOwn(value, 'id'))) { + return false + } + + const hasResult = Object.hasOwn(value, 'result') + + return hasResult !== Object.hasOwn(value, 'error') && (hasResult || isJsonRpcErrorObject(value.error)) +} + +/** + * Encodes an ORPCError as a JSON-RPC error object, keeping its oRPC `code`, `defined` flag and `data` in `data`. + */ +export function encodeJsonRpcError( + error: AnyORPCError, + serializer: Public, + errorCodeMap: Record = COMMON_JSON_RPC_ERROR_CODE_MAP, +): JsonRpcErrorObject { + return { + code: getOwn(errorCodeMap, error.code) ?? DEFAULT_JSON_RPC_ERROR_CODE, + message: error.message, + data: serializer.serializeJson({ code: error.code, defined: error.defined, data: error.data }).json, + } +} + +const PROTOCOL_ERROR_ORPC_CODES = new Map([ + [JSON_RPC_ERROR_CODES.PARSE_ERROR, 'BAD_REQUEST'], + [JSON_RPC_ERROR_CODES.INVALID_REQUEST, 'BAD_REQUEST'], + [JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND, 'NOT_FOUND'], + [JSON_RPC_ERROR_CODES.INTERNAL_ERROR, 'INTERNAL_SERVER_ERROR'], +]) + +/** + * The ORPCError code of a JSON-RPC error code that carries no oRPC code, such as one from another server. + */ +export function fromJsonRpcErrorCode(code: number): ORPCErrorCode { + return PROTOCOL_ERROR_ORPC_CODES.get(code) + ?? Object.keys(COMMON_JSON_RPC_ERROR_CODE_MAP).find(key => getOwn(COMMON_JSON_RPC_ERROR_CODE_MAP, key) === code) + ?? 'INTERNAL_SERVER_ERROR' +} diff --git a/packages/openrpc/src/meta.test.ts b/packages/openrpc/src/meta.test.ts new file mode 100644 index 0000000000..e20affb0a8 --- /dev/null +++ b/packages/openrpc/src/meta.test.ts @@ -0,0 +1,125 @@ +import { oc } from '@orpc/contract' +import { getOpenAPIMeta, openapi } from '@orpc/openapi' +import { os } from '@orpc/server' +import { getOpenRPCMeta, openrpc } from './meta' + +describe('openrpc meta', () => { + describe('openrpc() function', () => { + it('returns a plugin with name ~openrpc', () => { + expect(openrpc({ summary: 'Find a planet' }).name).toBe('~openrpc') + }) + + it('init meta on first time use', () => { + const meta = { summary: 'Find a planet', tags: ['planets'], examples: [{ name: 'earth', input: { id: 1 } }] } + expect(getOpenRPCMeta(oc.meta(openrpc(meta)))).toEqual(meta) + }) + + it('prioritize later call and keeps fields it omits', () => { + const procedure = oc + .meta(openrpc({ summary: 'First', description: 'Kept', examples: [{ name: 'first' }] })) + .meta(openrpc({ summary: 'Second', examples: [{ name: 'second' }] })) + expect(getOpenRPCMeta(procedure)).toMatchObject({ + summary: 'Second', + description: 'Kept', + examples: [{ name: 'second' }], + }) + }) + + it('does not touch openapi meta', () => { + const procedure = oc.meta(openapi({ summary: 'OpenAPI' })).meta(openrpc({ summary: 'OpenRPC' })) + expect(getOpenAPIMeta(procedure)).toEqual({ summary: 'OpenAPI' }) + expect(getOpenRPCMeta(procedure)).toMatchObject({ summary: 'OpenRPC' }) + }) + + describe('tags merging', () => { + it('concatenates tags from multiple calls', () => { + const procedure = oc + .meta(openrpc({ tags: ['existing-tag'] })) + .meta(openrpc({ tags: ['new-tag'] })) + expect(getOpenRPCMeta(procedure)?.tags).toEqual(['existing-tag', 'new-tag']) + }) + + it('uses only existing tags when no incoming tags', () => { + const procedure = oc + .meta(openrpc({ tags: ['existing'] })) + .meta(openrpc({})) + expect(getOpenRPCMeta(procedure)?.tags).toEqual(['existing']) + }) + }) + + describe('spec merging', () => { + it('uses existing object spec when no incoming spec', () => { + const existingSpec = { name: 'find', params: [] } + const procedure = oc + .meta(openrpc({ spec: existingSpec })) + .meta(openrpc({})) + expect(getOpenRPCMeta(procedure)?.spec).toBe(existingSpec) + }) + + it('prioritizes the later object spec', () => { + const procedure = oc + .meta(openrpc({ spec: { name: 'first', params: [] } })) + .meta(openrpc({ spec: { name: 'second', params: [] } })) + expect(getOpenRPCMeta(procedure)?.spec).toEqual({ name: 'second', params: [] }) + }) + + it('composes two function specs: existing applied first, then incoming', () => { + const procedure = oc + .meta(openrpc({ spec: current => ({ ...current, 'x-a': 1, 'x-order': 1 }) })) + .meta(openrpc({ spec: current => ({ ...current, 'x-b': 2, 'x-order': 2 }) })) + const spec = getOpenRPCMeta(procedure)?.spec as any + expect(spec({ name: 'find', params: [] })).toEqual({ 'name': 'find', 'params': [], 'x-a': 1, 'x-b': 2, 'x-order': 2 }) + }) + + it('resolves existing function spec with incoming object spec eagerly', () => { + const procedure = oc + .meta(openrpc({ spec: current => ({ ...current, 'x-existing': true }) })) + .meta(openrpc({ spec: { name: 'override', params: [] } })) + expect(getOpenRPCMeta(procedure)?.spec).toEqual({ 'name': 'override', 'params': [], 'x-existing': true }) + }) + + it('applies incoming function spec to existing object spec eagerly', () => { + const procedure = oc + .meta(openrpc({ spec: { name: 'base', params: [] } })) + .meta(openrpc({ spec: current => ({ ...current, 'x-extra': true }) })) + expect(getOpenRPCMeta(procedure)?.spec).toEqual({ 'name': 'base', 'params': [], 'x-extra': true }) + }) + }) + + it('metadata resets to its default behavior when set to `undefined` in subsequent calls', () => { + const procedure = oc + .meta(openrpc({ + summary: 'Find a planet', + description: 'des', + deprecated: true, + tags: ['a', 'b'], + externalDocs: { url: 'https://example.com' }, + examples: [{ name: 'earth' }], + spec: current => current, + })) + .meta(openrpc({ + summary: undefined, + description: undefined, + deprecated: undefined, + tags: undefined, + externalDocs: undefined, + examples: undefined, + spec: undefined, + })) + + expect(getOpenRPCMeta(procedure)).toEqual({}) + }) + }) + + describe('getOpenRPCMeta', () => { + it('returns undefined when no openrpc meta has been applied', () => { + expect(getOpenRPCMeta(oc)).toBeUndefined() + }) + + it('returns the ~openrpc meta from contracts, procedures and lazy routers', () => { + expect(getOpenRPCMeta(oc.meta(openrpc({ summary: 'contract' })))).toEqual({ summary: 'contract' }) + expect(getOpenRPCMeta(os.meta(openrpc({ summary: 'procedure' })).handler(() => {}))).toMatchObject({ summary: 'procedure' }) + expect(getOpenRPCMeta(os.meta(openrpc({ summary: 'lazy' })).lazy(() => Promise.resolve({ default: {} })))).toMatchObject({ summary: 'lazy' }) + }) + }) +}) diff --git a/packages/openrpc/src/meta.ts b/packages/openrpc/src/meta.ts new file mode 100644 index 0000000000..732a14941b --- /dev/null +++ b/packages/openrpc/src/meta.ts @@ -0,0 +1,100 @@ +import type { AnyProcedureContract, AnySchema, ErrorMap, MetaPlugin } from '@orpc/contract' +import type { Lazy } from '@orpc/server' +import type { Value } from '@orpc/shared' +import type { OpenRPCExternalDocumentationObject, OpenRPCMethodObject } from './types' + +export interface OpenRPCMethodExample { + name: string + summary?: string | undefined + description?: string | undefined + + /** + * The procedure input, described as params the same way the method's params are. + */ + input?: unknown + + /** + * The procedure output. Omit it for an example without result. + */ + output?: unknown +} + +/** + * Options of the OpenRPC Method Object generated for a procedure. + * Fields left undefined fall back to the procedure's [OpenAPI meta](https://orpc.dev/docs/openapi/specification#metadata). + */ +export interface OpenRPCMeta { + summary?: string | undefined + description?: string | undefined + deprecated?: boolean | undefined + + /** + * **Merging**: When defined multiple times, tags are concatenated in definition order. + * Explicitly setting `undefined` resets the tags instead of merging. + */ + tags?: string[] | undefined + + externalDocs?: OpenRPCExternalDocumentationObject | undefined + examples?: OpenRPCMethodExample[] | undefined + + /** + * Replaces the generated Method Object when an object, or transforms it when a function. + * + * **Merging**: When defined multiple times, two functions are chained, a function combined with an object + * is applied to that object, and between two objects the most recent one wins. + * Explicitly setting `undefined` resets the spec instead of merging. + */ + spec?: Value | undefined +} + +export interface OpenRPCMetaPlugin< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, +> extends MetaPlugin { + name: '~openrpc' +} + +export interface OpenRPCFunction { + (meta: OpenRPCMeta): OpenRPCMetaPlugin +} + +/** + * Controls how a procedure is described in the generated OpenRPC document. + * When defined multiple times, fields are overridden by the most recent call, except `tags` and `spec`. + * + * @see {@link https://orpc.dev/docs/openrpc/specification#metadata | OpenRPC Specification - Metadata} + */ +export const openrpc: OpenRPCFunction = incoming => ({ + name: '~openrpc', + init(meta) { + const existing = meta['~openrpc'] as undefined | OpenRPCMeta + + const tags = existing?.tags && incoming.tags + ? [...existing.tags, ...incoming.tags] + : 'tags' in incoming ? incoming.tags : existing?.tags + + const existingSpec = existing?.spec + const incomingSpec = incoming.spec + + const spec: OpenRPCMeta['spec'] + = typeof existingSpec === 'function' && typeof incomingSpec === 'function' + ? current => incomingSpec(existingSpec(current)) + : typeof existingSpec === 'function' && typeof incomingSpec === 'object' + ? existingSpec(incomingSpec) + : typeof existingSpec === 'object' && typeof incomingSpec === 'function' + ? incomingSpec(existingSpec) + : 'spec' in incoming ? incomingSpec : existingSpec + + return { ...meta, '~openrpc': { ...existing, ...incoming, tags, spec } satisfies OpenRPCMeta } + }, +}) + +/** + * Reads the OpenRPC meta attached to a procedure or contract by `openrpc`. + * + * @see {@link https://orpc.dev/docs/openrpc/specification#metadata | OpenRPC Specification - Metadata} + */ +export function getOpenRPCMeta(procedureOrLazy: AnyProcedureContract | Lazy): OpenRPCMeta | undefined { + return procedureOrLazy['~orpc'].meta['~openrpc'] as OpenRPCMeta | undefined +} diff --git a/packages/openrpc/src/openrpc-generator.test-d.ts b/packages/openrpc/src/openrpc-generator.test-d.ts new file mode 100644 index 0000000000..5ba9922df7 --- /dev/null +++ b/packages/openrpc/src/openrpc-generator.test-d.ts @@ -0,0 +1,25 @@ +import type { AnyProcedureContract } from '@orpc/contract' +import type { AnyProcedure } from '@orpc/server' +import { OpenAPIJsonSerializer } from '@orpc/openapi' +import { OpenRPCGenerator } from './openrpc-generator' + +describe('openRPCGenerator.generate', () => { + const generator = new OpenRPCGenerator({ converters: [], serializer: new OpenAPIJsonSerializer() }) + + it('types the generate options', () => { + generator.generate({}, { + base: { info: { title: 'Planets', version: '1.0.0' }, servers: [{ url: 'https://example.com/rpc' }], methods: [] }, + errorCodeMap: { OUT_OF_STOCK: 1001 }, + filter: (procedure, path) => { + expectTypeOf(procedure).toEqualTypeOf() + expectTypeOf(path).toEqualTypeOf() + return true + }, + }) + + // @ts-expect-error the openrpc field is not configurable + generator.generate({}, { base: { openrpc: '1.3.2' } }) + // @ts-expect-error JSON-RPC error codes are numbers + generator.generate({}, { errorCodeMap: { OUT_OF_STOCK: '1001' } }) + }) +}) diff --git a/packages/openrpc/src/openrpc-generator.test.ts b/packages/openrpc/src/openrpc-generator.test.ts new file mode 100644 index 0000000000..4a55d5b024 --- /dev/null +++ b/packages/openrpc/src/openrpc-generator.test.ts @@ -0,0 +1,463 @@ +import type { JsonSchemaConverter } from '@orpc/json-schema' +import { oc } from '@orpc/contract' +import { openapi } from '@orpc/openapi' +import { os } from '@orpc/server' +import { z } from 'zod' +import { openrpc } from './meta' +import { OpenRPCGenerator, OpenRPCGeneratorError } from './openrpc-generator' + +const planet = z.object({ id: z.number(), name: z.string() }).meta({ id: 'Planet' }) + +describe('openRPCGenerator basic & options', () => { + const generator = new OpenRPCGenerator() + + it('starts from the default base document', async () => { + await expect(generator.generate({})).resolves.toEqual({ + openrpc: '1.3.2', + info: { title: 'API Reference', version: '0.0.0' }, + methods: [], + }) + }) + + it('merges the provided base document and serialize the result', async () => { + const serializer = { + serializeJson: vi.fn(document => ({ json: document })), + deserializeJson: vi.fn(), + } + + const generator = new OpenRPCGenerator({ serializer }) + + const doc = await generator.generate({ ping: oc }, { + base: { + info: { title: 'Planet API', version: '1.2.3' }, + servers: [{ url: 'https://api.example.com/rpc' }], + methods: [{ name: 'custom', params: [] }], + }, + }) + + const expected = { + openrpc: '1.3.2', + info: { title: 'Planet API', version: '1.2.3' }, + servers: [{ url: 'https://api.example.com/rpc' }], + methods: [ + { name: 'custom', params: [] }, + { name: 'ping', params: [], result: { name: 'result', schema: {} } }, + ], + } + + expect(serializer.serializeJson).toHaveBeenCalledWith(expected) + expect(doc).toEqual(expected) + }) + + it('serializes non-JSON values with the default serializer', async () => { + const doc = await generator.generate({}, { + base: { info: { 'title': 'Planet API', 'version': '1.0.0', 'x-generated-at': new Date('2020-01-01T00:00:00.000Z') } }, + }) + + expect(doc.info['x-generated-at']).toBe('2020-01-01T00:00:00.000Z') + }) + + it('does not mutate the provided base document', async () => { + const base = { + methods: [{ name: 'custom', params: [] }], + components: { schemas: { Existing: { type: 'string' } as const } }, + } + const snapshot = structuredClone(base) + + const doc = await generator.generate({ find: oc.output(planet) }, { base }) + + expect(doc.methods.map(method => 'name' in method && method.name)).toEqual(['custom', 'find']) + expect(doc.components?.schemas).toEqual({ Existing: { type: 'string' }, Planet: expect.any(Object) }) + expect(base).toEqual(snapshot) + }) + + it('invokes filter with the walked path and excludes filtered procedures', async () => { + const publicProcedure = oc + const privateProcedure = oc.input(z.string()) + + const filter = vi.fn((procedure, path) => procedure !== privateProcedure && path.join('.') !== 'admin.private') + + const doc = await generator.generate({ public: publicProcedure, admin: { private: privateProcedure } }, { filter }) + + expect(filter).toHaveBeenCalledTimes(2) + expect(filter).toHaveBeenNthCalledWith(1, publicProcedure, ['public']) + expect(filter).toHaveBeenNthCalledWith(2, privateProcedure, ['admin', 'private']) + expect(doc.methods).toEqual([expect.objectContaining({ name: 'public' })]) + }) + + it('names methods after router paths, in contract-first, implemented and lazy routers', async () => { + const contract = await generator.generate({ planet: { find: oc } }) + const router = await generator.generate({ + planet: { find: os.handler(() => {}) }, + lazy: os.lazy(() => Promise.resolve({ default: { planet: { find: os.handler(() => {}) } } })), + }) + + expect(contract.methods.map(method => 'name' in method && method.name)).toEqual(['planet.find']) + expect(router.methods.map(method => 'name' in method && method.name)).toEqual(['planet.find', 'lazy.planet.find']) + }) + + it('fallback to StandardJsonSchemaConverter', async () => { + const condition = vi.fn(() => false) + const generator = new OpenRPCGenerator({ converters: [{ condition, convert: vi.fn() }] }) + + const doc = await generator.generate({ find: oc.input(z.object({ id: z.number() })).output(z.string()) }) + + // ensure it prioritizes the provided converters + expect(condition).toHaveBeenCalledTimes(2) + expect(doc.methods[0]).toMatchObject({ + params: [{ name: 'id', required: true, schema: { type: 'number' } }], + result: { name: 'result', schema: { type: 'string' } }, + }) + }) + + it('converts inputs with the "input" direction and outputs with the "output" direction', async () => { + const converter: JsonSchemaConverter = { + condition: () => true, + convert: (_, direction) => [{ type: 'object', properties: { [direction]: { type: 'string' } }, required: [direction] }, false], + } + + const doc = await new OpenRPCGenerator({ converters: [converter] }).generate({ transform: oc.input(z.any()).output(z.any()) }) + + expect(doc.methods[0]).toMatchObject({ + params: [{ name: 'input', required: true, schema: { type: 'string' } }], + result: { name: 'result', schema: { type: 'object', properties: { output: { type: 'string' } }, required: ['output'] } }, + }) + }) + + it('aggregates all generator errors into a single OpenRPCGeneratorError', async () => { + const error = await generator.generate({ + 'rpc': { discover: oc.meta(openrpc({ spec: { name: 'rpc.discover', params: [] } })) }, + 'a.b': oc, + 'nested': { '': oc }, + 'upload': oc.input(z.object({ file: z.file() })), + 'download': oc.output(z.file()), + }).then( + () => { throw new Error('expected generate to reject') }, + e => e, + ) + + expect(error).toBeInstanceOf(OpenRPCGeneratorError) + expect(error.message).toContain('Failed to generate the OpenRPC document') + expect(error.message).toContain('Procedure at rpc.discover: "rpc.discover" is not a valid method name') + expect(error.message).toContain('Procedure at a.b: "a.b" is not a valid method name') + expect(error.message).toContain('Procedure at nested.: "nested." is not a valid method name') + expect(error.message).toContain('Procedure at upload: File/Blob schemas cannot be sent over JSON-RPC.') + expect(error.message).toContain('Procedure at download: File/Blob schemas cannot be sent over JSON-RPC.') + + await expect(generator.generate(oc)).rejects.toThrow('Procedure at (root): "" is not a valid method name') + }) + + it('rethrows non-generator errors immediately without wrapping', async () => { + const generator = new OpenRPCGenerator({ + converters: [{ condition: () => true, convert: () => { throw new Error('converter exploded') } }], + }) + + const error = await generator.generate({ ping: oc.input(z.string()) }).then( + () => { throw new Error('expected generate to reject') }, + e => e, + ) + + expect(error).not.toBeInstanceOf(OpenRPCGeneratorError) + expect(error.message).toBe('converter exploded') + }) +}) + +describe('openRPCGenerator params', () => { + const generator = new OpenRPCGenerator() + + it('describes an object input as by-name params, required ones first', async () => { + const doc = await generator.generate({ + list: oc.input(z.object({ limit: z.number().optional(), cursor: z.string().describe('Page cursor'), sort: z.string() })), + }) + + expect(doc.methods[0]).toEqual({ + name: 'list', + paramStructure: 'by-name', + params: [ + { name: 'cursor', description: 'Page cursor', required: true, schema: { type: 'string', description: 'Page cursor' } }, + { name: 'sort', required: true, schema: { type: 'string' } }, + { name: 'limit', schema: { type: 'number' } }, + ], + result: { name: 'result', schema: {} }, + }) + }) + + it('describes an optional object input by-name only when every property is optional', async () => { + const doc = await generator.generate({ + filter: oc.input(z.object({ name: z.string().optional() }).optional()), + lookup: oc.input(z.object({ id: z.number() }).optional()), + }) + + expect(doc.methods[0]).toMatchObject({ paramStructure: 'by-name', params: [{ name: 'name', schema: { type: 'string' } }] }) + expect(doc.methods[1]).toMatchObject({ paramStructure: 'by-position', params: [{ name: 'input', schema: { type: 'object', required: ['id'] } }] }) + expect(doc.methods[1]).not.toHaveProperty('params.0.required') + }) + + it('keeps objects with additional properties or annotations by-name', async () => { + const doc = await generator.generate({ + loose: oc.input(z.looseObject({ id: z.number() })), + strict: oc.input(z.strictObject({ id: z.number() })), + described: oc.input(z.object({ id: z.number() }).describe('Lookup')), + titled: oc.input(z.object({ id: z.number() }).meta({ 'title': 'Lookup', 'x-internal': true })), + }) + + expect(doc.methods.map(method => 'paramStructure' in method && method.paramStructure)).toEqual(['by-name', 'by-name', 'by-name', 'by-name']) + }) + + it.each([ + ['a primitive', z.number(), { type: 'number' }], + ['a record', z.record(z.string(), z.number()), { type: 'object', additionalProperties: { type: 'number' } }], + ['a union of objects', z.union([z.object({ a: z.string() }), z.object({ b: z.string() })]), { anyOf: [expect.any(Object), expect.any(Object)] }], + ['an object with an additionalProperties schema', z.object({ a: z.string() }).catchall(z.number()), { type: 'object', additionalProperties: { type: 'number' } }], + ['an object with an empty property name', z.object({ '': z.string() }), { type: 'object', required: [''] }], + ])('describes %s input as a single by-position param', async (_, schema, expected) => { + const doc = await generator.generate({ ping: oc.input(schema) }) + + expect(doc.methods[0]).toMatchObject({ + paramStructure: 'by-position', + params: [{ name: 'input', required: true, schema: expected }], + }) + }) + + it('has no params without input or when the input is unconstrained and optional', async () => { + const doc = await generator.generate({ + none: oc, + void: oc.input(z.void()), + any: oc.input(z.any()), + required: oc.input(z.any().nonoptional()), + }) + + expect(doc.methods.slice(0, 3)).toEqual([ + { name: 'none', params: [], result: { name: 'result', schema: {} } }, + { name: 'void', params: [], result: { name: 'result', schema: {} } }, + { name: 'any', params: [], result: { name: 'result', schema: {} } }, + ]) + expect(doc.methods[3]).toMatchObject({ paramStructure: 'by-position', params: [{ name: 'input', required: true, schema: {} }] }) + }) + + it('merges multiple input and output schemas with allOf', async () => { + const doc = await generator.generate({ + planet: oc + .input(z.object({ name: z.string() })) + .input(z.object({ note: z.string() }).optional()) + .input(z.any()) + .output(z.object({ id: z.string() })) + .output(z.object({ slug: z.string() })), + }) + + expect(doc.methods[0]).toMatchObject({ + paramStructure: 'by-position', + params: [{ name: 'input', required: true, schema: { allOf: [{ required: ['name'] }, { required: ['note'] }] } }], + result: { schema: { allOf: [{ required: ['id'] }, { required: ['slug'] }] } }, + }) + }) + + it('keeps merged params optional when every input schema is optional', async () => { + const doc = await generator.generate({ + planet: oc.input(z.object({ name: z.string() }).optional()).input(z.object({ note: z.string() }).optional()), + }) + + expect(doc.methods[0]).not.toHaveProperty('params.0.required') + }) +}) + +describe('openRPCGenerator result', () => { + const generator = new OpenRPCGenerator() + + it('describes the output schema as the result, allowing null when the output is optional', async () => { + const doc = await generator.generate({ + find: oc.output(z.string()), + maybe: oc.output(z.string().optional()), + }) + + expect(doc.methods[0]).toMatchObject({ result: { name: 'result', schema: { type: 'string' } } }) + expect(doc.methods[1]).toMatchObject({ result: { name: 'result', schema: { anyOf: [{ type: 'string' }, { type: 'null' }] } } }) + }) + + it('describes an unconstrained output as an empty schema', async () => { + const doc = await generator.generate({ none: oc, void: oc.output(z.void()), any: oc.output(z.any()) }) + + expect(doc.methods.map(method => 'result' in method && method.result)).toEqual(Array.from({ length: 3 }, () => ({ name: 'result', schema: {} }))) + }) +}) + +describe('openRPCGenerator errors', () => { + const generator = new OpenRPCGenerator() + + it('describes errors with their JSON-RPC code, message and oRPC code', async () => { + const doc = await generator.generate({ + find: oc.errors({ NOT_FOUND: { message: 'Planet not found', data: z.object({ id: z.number() }) }, UNAUTHORIZED: {} }), + }) + + expect(doc.methods[0]).toMatchObject({ + errors: [ + { code: -32004, message: 'Planet not found', data: { code: 'NOT_FOUND', defined: true } }, + { code: -32001, message: 'Unauthorized', data: { code: 'UNAUTHORIZED', defined: true } }, + ], + }) + }) + + it('groups errors mapped to the same JSON-RPC code', async () => { + const doc = await generator.generate({ + find: oc.errors({ BAD_GATEWAY: {}, SERVICE_UNAVAILABLE: { message: 'Try later' } }), + }) + + expect(doc.methods[0]).toMatchObject({ + errors: [{ code: -32603, message: 'Bad Gateway (BAD_GATEWAY) or Try later (SERVICE_UNAVAILABLE)' }], + }) + expect(doc.methods[0]).not.toHaveProperty('errors.0.data') + }) + + it('uses the errorCodeMap, falling back to -32000 for missing codes', async () => { + const doc = await generator.generate({ + buy: oc.errors({ OUT_OF_STOCK: {}, NOT_FOUND: {} }), + }, { errorCodeMap: { OUT_OF_STOCK: 1001 } }) + + expect(doc.methods[0]).toMatchObject({ + errors: [ + { code: 1001, message: 'Out Of Stock', data: { code: 'OUT_OF_STOCK', defined: true } }, + { code: -32000, message: 'Not Found', data: { code: 'NOT_FOUND', defined: true } }, + ], + }) + }) +}) + +describe('openRPCGenerator components', () => { + const generator = new OpenRPCGenerator() + + it('hoists reusable schemas into components.schemas', async () => { + const doc = await generator.generate({ + list: oc.output(z.array(planet)), + find: oc.output(planet), + }) + + expect(doc.methods[0]).toMatchObject({ result: { schema: { type: 'array', items: { $ref: '#/components/schemas/Planet' } } } }) + expect(doc.methods[1]).toMatchObject({ result: { schema: { $ref: '#/components/schemas/Planet' } } }) + expect(doc.components).toEqual({ + schemas: { + Planet: { + type: 'object', + properties: { id: { type: 'number' }, name: { type: 'string' } }, + required: ['id', 'name'], + additionalProperties: false, + }, + }, + }) + }) + + it('gives components valid names', async () => { + const doc = await generator.generate({ find: oc.output(z.string().meta({ id: 'Solar System/Planet' })) }) + + expect(doc.methods[0]).toMatchObject({ result: { schema: { $ref: '#/components/schemas/Solar_System_Planet' } } }) + expect(doc.components).toEqual({ schemas: { Solar_System_Planet: { type: 'string' } } }) + }) + + it('splits components that differ between input and output', async () => { + const page = z.object({ size: z.number().default(10) }).meta({ id: 'Page' }) + + const doc = await generator.generate({ list: oc.input(z.object({ page })).output(page) }) + + expect(doc.methods[0]).toMatchObject({ + params: [{ name: 'page', schema: { $ref: '#/components/schemas/PageInput' } }], + result: { schema: { $ref: '#/components/schemas/Page' } }, + }) + expect(doc.components?.schemas).toEqual({ + Page: expect.objectContaining({ required: ['size'] }), + PageInput: expect.not.objectContaining({ required: expect.anything() }), + }) + }) +}) + +describe('openRPCGenerator meta', () => { + const generator = new OpenRPCGenerator() + + it('applies openrpc meta: summary, description, deprecated, tags, externalDocs', async () => { + const doc = await generator.generate({ + find: oc.meta(openrpc({ + summary: 'Find a planet', + description: 'Returns a single planet.', + deprecated: true, + tags: ['planets'], + externalDocs: { url: 'https://example.com/planets' }, + })), + }) + + expect(doc.methods[0]).toEqual({ + name: 'find', + summary: 'Find a planet', + description: 'Returns a single planet.', + deprecated: true, + tags: [{ name: 'planets' }], + externalDocs: { url: 'https://example.com/planets' }, + params: [], + result: { name: 'result', schema: {} }, + }) + }) + + it('falls back to openapi meta for fields openrpc meta leaves undefined', async () => { + const doc = await generator.generate({ + find: oc + .meta(openapi({ summary: 'OpenAPI summary', description: 'OpenAPI description', deprecated: true, tags: ['openapi'] })) + .meta(openrpc({ summary: 'OpenRPC summary', tags: ['openrpc'] })), + }) + + expect(doc.methods[0]).toMatchObject({ + summary: 'OpenRPC summary', + description: 'OpenAPI description', + deprecated: true, + tags: [{ name: 'openrpc' }], + }) + }) + + it('replaces the whole method with an openrpc spec object, bypassing generation', async () => { + const doc = await generator.generate({ + // this procedure would normally throw: File schemas cannot be sent over JSON-RPC + upload: oc.meta(openrpc({ spec: { 'name': 'upload', 'params': [], 'x-custom': true } })).input(z.file()), + }) + + expect(doc.methods).toEqual([{ 'name': 'upload', 'params': [], 'x-custom': true }]) + }) + + it('extends the generated method with an openrpc spec function', async () => { + const doc = await generator.generate({ + find: oc.meta(openrpc({ spec: method => ({ ...method, 'x-rate-limit': 100 }) })).input(z.number()), + }) + + expect(doc.methods[0]).toMatchObject({ 'name': 'find', 'paramStructure': 'by-position', 'x-rate-limit': 100 }) + }) +}) + +describe('openRPCGenerator examples', () => { + const generator = new OpenRPCGenerator() + + it('describes example inputs as by-name params, omitting undefined ones', async () => { + const doc = await generator.generate({ + find: oc + .meta(openrpc({ examples: [{ name: 'earth', summary: 'Earth', description: 'Our home', input: { id: 1, name: undefined }, output: 'Earth' }] })) + .input(z.object({ id: z.number(), name: z.string().optional() })), + }) + + expect(doc.methods[0]).toMatchObject({ + examples: [{ name: 'earth', summary: 'Earth', description: 'Our home', params: [{ name: 'id', value: 1 }], result: { name: 'result', value: 'Earth' } }], + }) + }) + + it('describes example inputs as a by-position param, and an undefined output as null', async () => { + const doc = await generator.generate({ + find: oc.meta(openrpc({ examples: [{ name: 'earth', input: 1, output: undefined }] })).input(z.number()), + }) + + expect(doc.methods[0]).toMatchObject({ + examples: [{ name: 'earth', params: [{ name: 'input', value: 1 }], result: { name: 'result', value: null } }], + }) + }) + + it('describes examples without input or output with no params and no result', async () => { + const doc = await generator.generate({ + ping: oc.meta(openrpc({ examples: [{ name: 'empty' }] })), + }) + + expect(doc.methods[0]).toMatchObject({ examples: [{ name: 'empty', params: [] }] }) + expect(doc.methods[0]).not.toHaveProperty('examples.0.result') + }) +}) diff --git a/packages/openrpc/src/openrpc-generator.ts b/packages/openrpc/src/openrpc-generator.ts new file mode 100644 index 0000000000..498daea8c4 --- /dev/null +++ b/packages/openrpc/src/openrpc-generator.ts @@ -0,0 +1,268 @@ +import type { AnyProcedureContract, AnySchema, RouterContract } from '@orpc/contract' +import type { JsonObjectSchemaEntry, JsonSchema, JsonSchemaConverter, JsonSchemaConverterDirection } from '@orpc/json-schema' +import type { AnyProcedure, AnyRouter } from '@orpc/server' +import type { Public, Value } from '@orpc/shared' +import type { OpenRPCContentDescriptorObject, OpenRPCDocument, OpenRPCErrorObject, OpenRPCMethodObject } from './types' +import { ORPCError } from '@orpc/client' +import { + combineJsonSchemasWithComposition, + containsJsonFileSchema, + DelegatingJsonSchemaConverter, + extractJsonObjectSchemaEntries, + hoistRecursiveRefToDef, + isUnconstrainedSchema, + resolveJsonSchemaRootLocalRef, + StandardJsonSchemaConverter, +} from '@orpc/json-schema' +import { getOpenAPIMeta, OpenAPIComponentRegistry, OpenAPIJsonSerializer } from '@orpc/openapi' +import { walkProcedureContractsAsync } from '@orpc/server' +import { clone, getOwn, toArray, value } from '@orpc/shared' +import { COMMON_JSON_RPC_ERROR_CODE_MAP, DEFAULT_JSON_RPC_ERROR_CODE } from './constants' +import { getOpenRPCMeta } from './meta' +import { isEventIteratorProcedure } from './utils' + +export class OpenRPCGeneratorError extends TypeError {} + +export interface OpenRPCGeneratorOptions { + converters?: JsonSchemaConverter[] | undefined + + /** + * The serializer used to serialize the generated document. + */ + serializer?: Public | undefined +} + +export interface OpenRPCGeneratorGenerateOptions { + /** + * Document fields to start from, such as `info`, `servers`, or `components`. + */ + base?: Partial> | undefined + + /** + * Filter procedures. Return `false` to exclude a procedure. Pass the same filter to `OpenRPCHandler`. + * + * @default true + */ + filter?: Value | undefined + + /** + * Mapping ORPCError Code -> JSON-RPC error code. Pass the same map to `OpenRPCHandler`. + * + * @default COMMON_JSON_RPC_ERROR_CODE_MAP + */ + errorCodeMap?: Record | undefined +} + +/** + * Root keywords of an object input that by-name params can express, so splitting it into one param per property loses nothing. + */ +const BY_NAME_ROOT_KEYWORDS = new Set(['type', 'properties', 'required', 'additionalProperties', 'title', 'description', '$defs', '$comment', 'examples', 'default']) + +/** + * Generates an OpenRPC 1.3 document from a contract or router: each procedure becomes a method + * named after its path joined by `.`, and procedures with event iterators are skipped. + * + * @see {@link https://orpc.dev/docs/openrpc/specification | OpenRPC Specification} + */ +export class OpenRPCGenerator { + private readonly converter: DelegatingJsonSchemaConverter + private readonly serializer: Public + + constructor(options: OpenRPCGeneratorOptions = {}) { + this.converter = new DelegatingJsonSchemaConverter([...toArray(options.converters), new StandardJsonSchemaConverter()]) + this.serializer = options.serializer ?? new OpenAPIJsonSerializer() + } + + async generate(router: RouterContract | AnyRouter, options: OpenRPCGeneratorGenerateOptions = {}): Promise { + const base = clone(options.base) ?? {} + const doc: OpenRPCDocument = { + ...base, + openrpc: '1.3.2', + info: base.info ?? { title: 'API Reference', version: '0.0.0' }, + methods: base.methods ?? [], + } + + // OpenRPC requires component names to match ^[a-zA-Z0-9.\-_]+$ + const registry = new OpenAPIComponentRegistry(doc, name => name.replace(/[^\w.-]/g, '_') || '_') + const errors: string[] = [] + + await walkProcedureContractsAsync(router, (contract, path) => { + if (value(options.filter, contract, path) === false || isEventIteratorProcedure(contract)) { + return + } + + try { + doc.methods.push(this.buildMethod(registry, contract, path, options.errorCodeMap)) + } + catch (e) { + if (!(e instanceof OpenRPCGeneratorError)) { + throw e + } + + errors.push(`Procedure at ${path.join('.') || '(root)'}: ${e.message}`) + } + }) + + if (errors.length) { + throw new OpenRPCGeneratorError(`[OpenRPCGenerator] Failed to generate the OpenRPC document:\n\n${errors.join('\n\n')}`) + } + + return this.serializer.serializeJson(doc).json as OpenRPCDocument + } + + private buildMethod( + registry: OpenAPIComponentRegistry, + contract: AnyProcedureContract, + path: string[], + errorCodeMap: Record = COMMON_JSON_RPC_ERROR_CODE_MAP, + ): OpenRPCMethodObject { + const name = path.join('.') + + if (!path.length || path.some(key => key === '' || key.includes('.')) || name.startsWith('rpc.')) { + throw new OpenRPCGeneratorError( + `"${name}" is not a valid method name: router keys must be non-empty without ".", and names starting with "rpc." are reserved.`, + ) + } + + const spec = getOpenRPCMeta(contract)?.spec + const method = typeof spec === 'object' ? spec : this.generateMethod(registry, contract, name, errorCodeMap) + + return typeof spec === 'function' ? spec(method) : method + } + + private generateMethod( + registry: OpenAPIComponentRegistry, + contract: AnyProcedureContract, + name: string, + errorCodeMap: Record, + ): OpenRPCMethodObject { + const def = contract['~orpc'] + const [inputSchema, inputOptional] = this.convertSchemas(def.inputSchemas, 'input') + const [outputSchema, outputOptional] = this.convertSchemas(def.outputSchemas, 'output') + + if (containsJsonFileSchema(inputSchema) || containsJsonFileSchema(outputSchema)) { + throw new OpenRPCGeneratorError('File/Blob schemas cannot be sent over JSON-RPC.') + } + + const meta = getOpenRPCMeta(contract) + const openapiMeta = getOpenAPIMeta(contract) + const method: OpenRPCMethodObject = { + name, + summary: meta?.summary ?? openapiMeta?.summary, + description: meta?.description ?? openapiMeta?.description, + deprecated: meta?.deprecated ?? openapiMeta?.deprecated, + tags: (meta?.tags ?? openapiMeta?.tags)?.map(tag => ({ name: tag })), + externalDocs: meta?.externalDocs, + params: [], + result: { + name: 'result', + // `undefined` is sent as `null` + schema: outputOptional && !isUnconstrainedSchema(outputSchema) + ? { anyOf: [registry.toOpenAPISchema(outputSchema, 'output'), { type: 'null' }] } + : registry.toOpenAPISchema(outputSchema, 'output'), + }, + errors: buildErrors(def.errorMap, errorCodeMap), + } + + const entries = toByNameEntries(inputSchema) + + // an optional object can be sent by-name only when no param is required + if (entries && (!inputOptional || entries.every(([, , optional]) => optional))) { + method.paramStructure = 'by-name' + method.params = entries + .sort(([, , a], [, , b]) => Number(a) - Number(b)) + .map(([name, schema, optional]) => toContentDescriptor(registry, name, schema, optional)) + } + // no input, or an input like `z.void()` or `z.any()`, needs no params + else if (!isUnconstrainedSchema(inputSchema) || !inputOptional) { + method.paramStructure = 'by-position' + method.params = [toContentDescriptor(registry, 'input', inputSchema, inputOptional)] + } + + // Example Objects require a `value`, so examples describe what is sent: undefined params are omitted, and an undefined result is `null` + method.examples = meta?.examples?.map(example => ({ + name: example.name, + summary: example.summary, + description: example.description, + params: method.paramStructure === 'by-name' + ? Object.entries(example.input ?? {}).filter(([, value]) => value !== undefined).map(([name, value]) => ({ name, value })) + : example.input === undefined ? [] : [{ name: 'input', value: example.input }], + result: Object.hasOwn(example, 'output') ? { name: 'result', value: example.output ?? null } : undefined, + })) + + return method + } + + private convertSchemas(schemas: readonly AnySchema[] | undefined, direction: JsonSchemaConverterDirection): [JsonSchema, boolean] { + const converted = (schemas ?? []).map((schema): [JsonSchema, boolean] => { + const [jsonSchema, optional] = this.converter.convert(schema, direction) + + if (typeof jsonSchema !== 'object') { + return [jsonSchema, optional] + } + + const { $schema: _$schema, ...rest } = jsonSchema + // composing would retarget root-relative refs, so move a schema that points into itself under a def first + return [hoistRecursiveRefToDef(rest), optional] + }) + + if (converted.length <= 1) { + return converted[0] ?? [{}, true] + } + + return [ + combineJsonSchemasWithComposition('allOf', converted.map(([jsonSchema]) => jsonSchema).filter(s => !isUnconstrainedSchema(s))), + converted.every(([, optional]) => optional), + ] + } +} + +/** + * The properties of an object input, or `undefined` when it is not a plain object schema. + */ +function toByNameEntries(schema: JsonSchema): JsonObjectSchemaEntry[] | undefined { + const root = resolveJsonSchemaRootLocalRef(schema) + + if ( + typeof root !== 'object' + || root.type !== 'object' + || Object.keys(root).some(key => !BY_NAME_ROOT_KEYWORDS.has(key) && !key.startsWith('x-')) + || !(root.additionalProperties === undefined || typeof root.additionalProperties === 'boolean' || isUnconstrainedSchema(root.additionalProperties)) + || Object.hasOwn(root.properties ?? {}, '') + ) { + return undefined + } + + return extractJsonObjectSchemaEntries(schema) +} + +function toContentDescriptor(registry: OpenAPIComponentRegistry, name: string, schema: JsonSchema, optional: boolean): OpenRPCContentDescriptorObject { + return { + name, + description: typeof schema === 'object' ? schema.description : undefined, + required: optional ? undefined : true, + schema: registry.toOpenAPISchema(schema, 'input') as JsonSchema, + } +} + +/** + * OpenRPC requires unique error codes per method, so ORPCErrors mapped to the same code share an entry. + */ +function buildErrors(errorMap: AnyProcedureContract['~orpc']['errorMap'], errorCodeMap: Record): OpenRPCErrorObject[] | undefined { + const groups = new Map() + + for (const code of Object.keys(errorMap)) { + const config = errorMap[code] + + if (config) { + const jsonRpcCode = getOwn(errorCodeMap, code) ?? DEFAULT_JSON_RPC_ERROR_CODE + groups.set(jsonRpcCode, [...groups.get(jsonRpcCode) ?? [], { code, message: new ORPCError(code, { message: config.message }).message }]) + } + } + + return groups.size === 0 + ? undefined + : Array.from(groups, ([code, group]): OpenRPCErrorObject => group.length === 1 + ? { code, message: group[0]!.message, data: { code: group[0]!.code, defined: true } } + : { code, message: group.map(error => `${error.message} (${error.code})`).join(' or ') }) +} diff --git a/packages/openrpc/src/plugins/index.test.ts b/packages/openrpc/src/plugins/index.test.ts new file mode 100644 index 0000000000..aa2b198c4a --- /dev/null +++ b/packages/openrpc/src/plugins/index.test.ts @@ -0,0 +1,5 @@ +it('exports OpenRPCDiscoveryHandlerPlugin', async () => { + await expect(import('.')).resolves.toMatchObject({ + OpenRPCDiscoveryHandlerPlugin: expect.any(Function), + }) +}) diff --git a/packages/openrpc/src/plugins/index.ts b/packages/openrpc/src/plugins/index.ts new file mode 100644 index 0000000000..b4124734ae --- /dev/null +++ b/packages/openrpc/src/plugins/index.ts @@ -0,0 +1 @@ +export * from './openrpc-discovery' diff --git a/packages/openrpc/src/plugins/openrpc-discovery.test.ts b/packages/openrpc/src/plugins/openrpc-discovery.test.ts new file mode 100644 index 0000000000..37efbb3211 --- /dev/null +++ b/packages/openrpc/src/plugins/openrpc-discovery.test.ts @@ -0,0 +1,194 @@ +import type { StandardHandlerRoutingInterceptor } from '@orpc/server/standard' +import type { JsonRpcCall } from '../adapters/standard/utils' +import type { OpenRPCDocument } from '../types' +import { os } from '@orpc/server' +import { RPCHandler } from '@orpc/server/fetch' +import { OpenRPCHandler } from '../adapters/fetch' +import { getJsonRpcCall, JSON_RPC_CALL_SYMBOL } from '../adapters/standard/utils' +import { OpenRPCDiscoveryHandlerPlugin } from './openrpc-discovery' + +describe('openRPCDiscoveryHandlerPlugin', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function createSpec(title = 'Planets'): OpenRPCDocument { + return { openrpc: '1.3.2', info: { title, version: '1.0.0' }, methods: [] } + } + + function getInterceptor(plugin: OpenRPCDiscoveryHandlerPlugin, options: Record = {}) { + const initialized = plugin.init(options as any) + const interceptor = initialized.routingInterceptors?.at(-1) + + expect(interceptor).toBeDefined() + + return { initialized, interceptor: interceptor! } + } + + async function invoke( + interceptor: StandardHandlerRoutingInterceptor, + { url, method = 'GET', prefix, call, nextResult = { matched: false as const } }: { + url: `/${string}` + method?: string + prefix?: `/${string}` + call?: JsonRpcCall + nextResult?: any + }, + ) { + const next = vi.fn().mockResolvedValue(nextResult) + const request = { method, url, headers: {}, signal: undefined, resolveBody: vi.fn(), ...call && { [JSON_RPC_CALL_SYMBOL]: call } } + const result = await interceptor({ next, context: {}, prefix, request } as any) + + return { next, request, result } + } + + it('preserves existing routing interceptors and returns the matched result from next untouched', async () => { + const spec = vi.fn(() => createSpec()) + const existing = vi.fn(({ next }) => next()) + const { initialized, interceptor } = getInterceptor(new OpenRPCDiscoveryHandlerPlugin({ spec }), { routingInterceptors: [existing] }) + const nextResult = { matched: true, response: { status: 200, headers: {}, body: 'handled' } } + + expect(initialized.routingInterceptors).toEqual([existing, interceptor]) + + const { next, result } = await invoke(interceptor, { url: '/openrpc.json', nextResult }) + + expect(next).toHaveBeenCalledOnce() + expect(result).toBe(nextResult) + expect(spec).not.toHaveBeenCalled() + }) + + it('answers rpc.discover calls without calling next, passing the routing options to spec', async () => { + const spec = vi.fn(() => createSpec()) + const { interceptor } = getInterceptor(new OpenRPCDiscoveryHandlerPlugin({ spec })) + + const { next, request, result } = await invoke(interceptor, { + method: 'POST', + url: '/rpc/rpc/discover', + prefix: '/rpc', + call: { method: 'rpc.discover', id: 1 }, + }) + + expect(result).toEqual({ matched: true, response: { status: 200, headers: {}, body: { result: createSpec() } } }) + expect(next).not.toHaveBeenCalled() + expect(spec).toHaveBeenCalledExactlyOnceWith({ context: {}, prefix: '/rpc', request }) + }) + + it('passes other JSON-RPC calls through', async () => { + const spec = vi.fn(() => createSpec()) + const { interceptor } = getInterceptor(new OpenRPCDiscoveryHandlerPlugin({ spec })) + const nextResult = { matched: true, response: { status: 200, headers: {}, body: { result: 'Earth' } } } + + const { result } = await invoke(interceptor, { method: 'POST', url: '/planet/find', call: { method: 'planet.find', id: 1 }, nextResult }) + + expect(result).toBe(nextResult) + expect(spec).not.toHaveBeenCalled() + }) + + it('serves a static document over GET at /openrpc.json once nothing else matched', async () => { + const { interceptor } = getInterceptor(new OpenRPCDiscoveryHandlerPlugin({ spec: createSpec() })) + + const { next, result } = await invoke(interceptor, { url: '/openrpc.json' }) + + expect(next).toHaveBeenCalledOnce() + expect(result).toEqual({ matched: true, response: { status: 200, headers: {}, body: createSpec() } }) + }) + + it('serves the document from a custom spec path relative to the prefix', async () => { + const spec = vi.fn(async () => createSpec('Custom')) + const { interceptor } = getInterceptor(new OpenRPCDiscoveryHandlerPlugin({ spec, specPath: '/spec.json' })) + + const { request, result } = await invoke(interceptor, { url: '/rpc/spec.json?pretty', prefix: '/rpc' }) + + expect(result.response?.body).toEqual(createSpec('Custom')) + expect(spec).toHaveBeenCalledExactlyOnceWith({ context: {}, prefix: '/rpc', request }) + + for (const url of ['/spec.json', '/rpc/openrpc.json'] as const) { + expect((await invoke(interceptor, { url, prefix: '/rpc' })).result).toEqual({ matched: false }) + } + }) + + it.each([ + ['POST', '/openrpc.json'], + ['GET', '/other.json'], + ])('does not serve %s %s', async (method, url) => { + const spec = vi.fn(() => createSpec()) + const { interceptor } = getInterceptor(new OpenRPCDiscoveryHandlerPlugin({ spec })) + const nextResult = { matched: false } + + expect((await invoke(interceptor, { method, url: url as `/${string}`, nextResult })).result).toBe(nextResult) + expect(spec).not.toHaveBeenCalled() + }) + + it('disables the GET endpoint with specPath false but still answers rpc.discover', async () => { + const { interceptor } = getInterceptor(new OpenRPCDiscoveryHandlerPlugin({ spec: createSpec(), specPath: false })) + + expect((await invoke(interceptor, { url: '/openrpc.json' })).result).toEqual({ matched: false }) + expect((await invoke(interceptor, { method: 'POST', url: '/', call: { method: 'rpc.discover', id: 1 } })).result.matched).toBe(true) + }) + + describe('with handlers', () => { + const spec = createSpec() + const discover = vi.fn() + + function post(handler: OpenRPCHandler | RPCHandler, body: unknown) { + return handler.handle(new Request('http://localhost/rpc', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }), { prefix: '/rpc' }) + } + + it('answers rpc.discover in single, batch and notification requests, never calling a user rpc.discover procedure', async () => { + const handler = new OpenRPCHandler({ rpc: { discover: os.handler(discover) }, planet: { find: os.handler(() => 'Earth') } }, { + plugins: [new OpenRPCDiscoveryHandlerPlugin({ spec: () => spec })], + }) + + await expect((await post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).response?.json()) + .resolves + .toEqual({ jsonrpc: '2.0', id: 1, result: spec }) + await expect((await post(handler, [{ jsonrpc: '2.0', method: 'rpc.discover', id: 1 }, { jsonrpc: '2.0', method: 'planet.find', id: 2 }])).response?.json()) + .resolves + .toEqual([{ jsonrpc: '2.0', id: 1, result: spec }, { jsonrpc: '2.0', id: 2, result: 'Earth' }]) + expect((await post(handler, { jsonrpc: '2.0', method: 'rpc.discover' })).response?.status).toBe(204) + expect(discover).not.toHaveBeenCalled() + }) + + it('serves the document over GET under the prefix', async () => { + const handler = new OpenRPCHandler({}, { plugins: [new OpenRPCDiscoveryHandlerPlugin({ spec })] }) + + const { response } = await handler.handle(new Request('http://localhost/rpc/openrpc.json'), { prefix: '/rpc' }) + + expect(response?.status).toBe(200) + expect(response?.headers.get('content-type')).toContain('application/json') + await expect(response?.json()).resolves.toEqual(spec) + }) + + it('runs after user routing interceptors, so they can guard discovery', async () => { + const guard = vi.fn>(async ({ next, request }) => + request.headers.authorization ? next() : { matched: false }) + const handler = new OpenRPCHandler({}, { + routingInterceptors: [guard], + plugins: [new OpenRPCDiscoveryHandlerPlugin({ spec })], + }) + + await expect((await post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).response?.json()) + .resolves + .toEqual({ jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found' } }) + expect(getJsonRpcCall(guard.mock.calls[0]![0].request)).toEqual({ method: 'rpc.discover', id: 1 }) + expect((await handler.handle(new Request('http://localhost/openrpc.json'))).matched).toBe(false) + + const { response } = await handler.handle(new Request('http://localhost/openrpc.json', { headers: { authorization: 'token' } })) + + await expect(response?.json()).resolves.toEqual(spec) + }) + + it('serves only the GET document when mounted on RPCHandler', async () => { + const handler = new RPCHandler({}, { plugins: [new OpenRPCDiscoveryHandlerPlugin({ spec, specPath: '/spec.json' })] }) + + const { response } = await handler.handle(new Request('http://localhost/rpc/spec.json'), { prefix: '/rpc' }) + + await expect(response?.json()).resolves.toEqual(spec) + expect((await post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).matched).toBe(false) + }) + }) +}) diff --git a/packages/openrpc/src/plugins/openrpc-discovery.ts b/packages/openrpc/src/plugins/openrpc-discovery.ts new file mode 100644 index 0000000000..41f268d4a8 --- /dev/null +++ b/packages/openrpc/src/plugins/openrpc-discovery.ts @@ -0,0 +1,68 @@ +import type { Context } from '@orpc/server' +import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptorOptions } from '@orpc/server/standard' +import type { Promisable, Value } from '@orpc/shared' +import type { StandardUrl } from '@standard-server/core' +import type { OpenRPCDocument } from '../types' +import { matchesHttpPath, mergeHttpPath, toArray, value } from '@orpc/shared' +import { getJsonRpcCall } from '../adapters/standard/utils' + +export interface OpenRPCDiscoveryHandlerPluginOptions { + /** + * The document to serve, for example a memoized `() => generator.generate(router)`. + */ + spec: Value, [options: StandardHandlerRoutingInterceptorOptions]> + + /** + * The path (relative to the prefix) at which to also serve the document over `GET`, or `false` to disable it. + * + * @default '/openrpc.json' + */ + specPath?: StandardUrl | false | undefined +} + +/** + * Answers the OpenRPC Service Discovery method `rpc.discover` with the document, and serves it over `GET`. + * + * @see {@link https://orpc.dev/docs/openrpc/specification#service-discovery | OpenRPC Specification - Service Discovery} + */ +export class OpenRPCDiscoveryHandlerPlugin implements StandardHandlerPlugin { + name = '~openrpc-discovery' + + private readonly spec: OpenRPCDiscoveryHandlerPluginOptions['spec'] + private readonly specPath: Exclude['specPath'], undefined> + + constructor(options: OpenRPCDiscoveryHandlerPluginOptions) { + this.spec = options.spec + this.specPath = options.specPath ?? '/openrpc.json' + } + + init(options: StandardHandlerOptions): StandardHandlerOptions { + return { + ...options, + routingInterceptors: [ + ...toArray(options.routingInterceptors), + async ({ next, ...interceptorOptions }) => { + const { request, prefix } = interceptorOptions + + // only `OpenRPCHandler` creates JSON-RPC calls + if (getJsonRpcCall(request)?.method === 'rpc.discover') { + return { matched: true, response: { status: 200, headers: {}, body: { result: await value(this.spec, interceptorOptions) } } } + } + + const result = await next() + + if ( + result.matched + || request.method !== 'GET' + || this.specPath === false + || !matchesHttpPath(request.url, prefix ? mergeHttpPath(prefix, this.specPath) : this.specPath) + ) { + return result + } + + return { matched: true, response: { status: 200, headers: {}, body: await value(this.spec, interceptorOptions) } } + }, + ], + } + } +} diff --git a/packages/openrpc/src/types.test-d.ts b/packages/openrpc/src/types.test-d.ts new file mode 100644 index 0000000000..4399aded18 --- /dev/null +++ b/packages/openrpc/src/types.test-d.ts @@ -0,0 +1,15 @@ +import type { OpenRPCDocument } from './types' + +describe('OpenRPCDocument', () => { + it('allows x- extensions only', () => { + expectTypeOf({ + 'openrpc': '1.3.2', + 'info': { 'title': 'Planets', 'version': '1.0.0', 'x-logo': { url: 'https://example.com/logo.png' } }, + 'methods': [{ 'name': 'find', 'params': [{ 'name': 'id', 'schema': {}, 'x-hint': 1 }], 'x-rate-limit': 100 }], + 'x-generated-at': '2020-01-01', + } satisfies OpenRPCDocument).toExtend() + + // @ts-expect-error unknown fields must be prefixed with x- + expectTypeOf({ openrpc: '1.3.2', info: { title: 'Planets', version: '1.0.0' }, methods: [], custom: true } satisfies OpenRPCDocument) + }) +}) diff --git a/packages/openrpc/src/types.ts b/packages/openrpc/src/types.ts new file mode 100644 index 0000000000..eb76bc91f4 --- /dev/null +++ b/packages/openrpc/src/types.ts @@ -0,0 +1,91 @@ +import type { JsonSchema } from '@orpc/json-schema' + +/** + * `x-` specification extensions, allowed on most OpenRPC objects. + */ +export type OpenRPCExtensions = { [K in `x-${string}`]?: unknown } + +export interface OpenRPCReferenceObject { + $ref: string +} + +export interface OpenRPCExternalDocumentationObject extends OpenRPCExtensions { + url: string + description?: string | undefined +} + +export interface OpenRPCTagObject extends OpenRPCExtensions { + name: string + description?: string | undefined + externalDocs?: OpenRPCExternalDocumentationObject | undefined +} + +export interface OpenRPCContentDescriptorObject extends OpenRPCExtensions { + name: string + summary?: string | undefined + description?: string | undefined + required?: boolean | undefined + schema: JsonSchema + deprecated?: boolean | undefined +} + +export interface OpenRPCErrorObject { + code: number + message: string + data?: unknown +} + +export interface OpenRPCServerObject extends OpenRPCExtensions { + url: string + name?: string | undefined + summary?: string | undefined + description?: string | undefined + variables?: Record | undefined +} + +export interface OpenRPCMethodObject extends OpenRPCExtensions { + name: string + tags?: (OpenRPCTagObject | OpenRPCReferenceObject)[] | undefined + summary?: string | undefined + description?: string | undefined + externalDocs?: OpenRPCExternalDocumentationObject | undefined + params: (OpenRPCContentDescriptorObject | OpenRPCReferenceObject)[] + result?: OpenRPCContentDescriptorObject | OpenRPCReferenceObject | undefined + deprecated?: boolean | undefined + servers?: OpenRPCServerObject[] | undefined + errors?: (OpenRPCErrorObject | OpenRPCReferenceObject)[] | undefined + paramStructure?: 'by-name' | 'by-position' | 'either' | undefined + links?: unknown[] | undefined + examples?: unknown[] | undefined +} + +export interface OpenRPCComponentsObject extends OpenRPCExtensions { + schemas?: Record | undefined + contentDescriptors?: Record | undefined + errors?: Record | undefined + tags?: Record | undefined + examples?: Record | undefined + links?: Record | undefined + examplePairings?: Record | undefined +} + +export interface OpenRPCInfoObject extends OpenRPCExtensions { + title: string + version: string + description?: string | undefined + termsOfService?: string | undefined + contact?: { name?: string | undefined, url?: string | undefined, email?: string | undefined } | undefined + license?: { name?: string | undefined, url?: string | undefined } | undefined +} + +/** + * An [OpenRPC](https://spec.open-rpc.org) document. + */ +export interface OpenRPCDocument extends OpenRPCExtensions { + openrpc: string + info: OpenRPCInfoObject + servers?: OpenRPCServerObject[] | undefined + methods: (OpenRPCMethodObject | OpenRPCReferenceObject)[] + components?: OpenRPCComponentsObject | undefined + externalDocs?: OpenRPCExternalDocumentationObject | undefined +} diff --git a/packages/openrpc/src/utils.test.ts b/packages/openrpc/src/utils.test.ts new file mode 100644 index 0000000000..b4e0a3c92a --- /dev/null +++ b/packages/openrpc/src/utils.test.ts @@ -0,0 +1,28 @@ +import { eventIterator, oc } from '@orpc/contract' +import { os } from '@orpc/server' +import { z } from 'zod' +import { isEventIteratorProcedure } from './utils' + +describe('isEventIteratorProcedure', () => { + it('detects an event iterator input', () => { + expect(isEventIteratorProcedure(oc.input(eventIterator(z.string())))).toBe(true) + }) + + it('detects an event iterator output', () => { + expect(isEventIteratorProcedure(oc.output(eventIterator(z.string(), z.number())))).toBe(true) + }) + + it('detects an event iterator among multiple schemas', () => { + expect(isEventIteratorProcedure(oc.input(z.object({})).output(z.unknown()).output(eventIterator(z.string())))).toBe(true) + }) + + it('returns false without event iterator schemas', () => { + expect(isEventIteratorProcedure(oc)).toBe(false) + expect(isEventIteratorProcedure(oc.input(z.object({ id: z.number() })).output(z.string()))).toBe(false) + }) + + it('accepts implemented procedures', () => { + expect(isEventIteratorProcedure(os.output(eventIterator(z.string())).handler(async function* () {}))).toBe(true) + expect(isEventIteratorProcedure(os.handler(() => 'ok'))).toBe(false) + }) +}) diff --git a/packages/openrpc/src/utils.ts b/packages/openrpc/src/utils.ts new file mode 100644 index 0000000000..4adee8aa4e --- /dev/null +++ b/packages/openrpc/src/utils.ts @@ -0,0 +1,14 @@ +import type { AnyProcedureContract } from '@orpc/contract' +import { getAsyncIteratorObjectSchemasDetails } from '@orpc/contract' + +/** + * Whether a procedure takes or returns an event iterator, which JSON-RPC over HTTP cannot stream. + * The handler and the generator both exclude such procedures. + */ +export function isEventIteratorProcedure(procedure: AnyProcedureContract): boolean { + const { inputSchemas, outputSchemas } = procedure['~orpc'] + + // checked per schema: chaining an event iterator with other schemas still streams + return [...(inputSchemas ?? []), ...(outputSchemas ?? [])] + .some(schema => getAsyncIteratorObjectSchemasDetails([schema]) !== undefined) +} diff --git a/packages/openrpc/tests/__shared__/schema.ts b/packages/openrpc/tests/__shared__/schema.ts new file mode 100644 index 0000000000..e3db42b0f6 --- /dev/null +++ b/packages/openrpc/tests/__shared__/schema.ts @@ -0,0 +1,56 @@ +import type { AnySchema } from '@orpc/contract' +import type { JsonSchema, JsonSchemaConverter } from '@orpc/json-schema' +import z from 'zod' + +/** + * Converts zod schemas during tests. + * The generator itself is schema-agnostic: it only relies on the provided converters, + * so any standard schema library (zod, arktype, valibot, ...) or even plain JSON schemas + * (see {@link testSchema}) work the same way. + */ +export const zodJsonSchemaConverter: JsonSchemaConverter = { + condition: schema => schema?.['~standard'].vendor === 'zod', + convert(schema, direction) { + const jsonSchema = z.toJSONSchema(schema as any, { io: direction }) + const output = schema?.['~standard'].validate(undefined) + return [jsonSchema as any, !(output instanceof Promise) && !output?.issues] + }, +} + +export interface TestSchemaOptions { + /** + * Marks the schema as optional (accepts `undefined`). + */ + optional?: boolean + /** + * JSON schema used for the `output` direction. Defaults to the `input` JSON schema. + */ + output?: JsonSchema +} + +/** + * Creates a schema backed by a plain JSON schema, converted via {@link testSchemaConverter}. + * Used to test the generator against arbitrary JSON schemas without a schema library. + */ +export function testSchema(jsonSchema: JsonSchema, options: TestSchemaOptions = {}): AnySchema { + return { + '~standard': { + version: 1, + vendor: 'orpc-test', + validate: () => ({ issues: [{ message: 'testSchema does not support validation' }] }), + }, + '~test': { + input: jsonSchema, + output: options.output ?? jsonSchema, + optional: options.optional ?? false, + }, + } as any +} + +export const testSchemaConverter: JsonSchemaConverter = { + condition: schema => (schema as any)?.['~standard'].vendor === 'orpc-test', + convert(schema, direction) { + const detail = (schema as any)['~test'] + return [detail[direction], detail.optional] + }, +} diff --git a/packages/openrpc/tests/openrpc-generator/composed-schemas.test.ts b/packages/openrpc/tests/openrpc-generator/composed-schemas.test.ts new file mode 100644 index 0000000000..f01602e0d6 --- /dev/null +++ b/packages/openrpc/tests/openrpc-generator/composed-schemas.test.ts @@ -0,0 +1,103 @@ +import type { JsonSchema } from '@orpc/json-schema' +import { oc } from '@orpc/contract' +import z from 'zod' +import { OpenRPCGenerator } from '../../src' +import { testSchema, testSchemaConverter, zodJsonSchemaConverter } from '../__shared__/schema' + +describe('openRPCGenerator e2e: composed schemas', () => { + const generator = new OpenRPCGenerator({ converters: [testSchemaConverter, zodJsonSchemaConverter] }) + + it('documents polymorphic payloads as a single by-position param and a polymorphic result', async () => { + const doc = await generator.generate({ + ingestEvent: oc + .input(z.discriminatedUnion('type', [ + z.object({ type: z.literal('click'), x: z.number() }), + z.object({ type: z.literal('view'), duration: z.number() }), + ])) + .output(z.union([ + z.object({ accepted: z.literal(true) }), + z.object({ accepted: z.literal(false), reason: z.string() }), + ])), + }) + + expect(doc.methods).toEqual([{ + name: 'ingestEvent', + paramStructure: 'by-position', + params: [{ + name: 'input', + required: true, + schema: { + oneOf: [ + expect.objectContaining({ properties: { type: { type: 'string', const: 'click' }, x: { type: 'number' } } }), + expect.objectContaining({ properties: { type: { type: 'string', const: 'view' }, duration: { type: 'number' } } }), + ], + }, + }], + result: { + name: 'result', + schema: { + anyOf: [ + expect.objectContaining({ required: ['accepted'] }), + expect.objectContaining({ required: ['accepted', 'reason'] }), + ], + }, + }, + }]) + }) + + it('documents intersection inputs as by-name params and intersection outputs as a merged object', async () => { + const doc = await generator.generate({ + searchPlanets: oc + .input(z.intersection( + z.looseObject({ search: z.string() }), + z.looseObject({ page: z.number().optional() }), + )) + .output(z.intersection( + z.looseObject({ items: z.array(z.string()) }), + z.looseObject({ total: z.number() }), + )), + }) + + expect(doc.methods).toEqual([{ + name: 'searchPlanets', + paramStructure: 'by-name', + params: [ + { name: 'search', required: true, schema: { type: 'string' } }, + { name: 'page', schema: { type: 'number' } }, + ], + result: { + name: 'result', + schema: expect.objectContaining({ type: 'object', required: ['items', 'total'] }), + }, + }]) + }) + + it('keeps an object input by-position when its root composition cannot be split into params', async () => { + const FindPlanetInput: JsonSchema = { + type: 'object', + properties: { id: { type: 'number' }, slug: { type: 'string' } }, + anyOf: [{ required: ['id'] }, { required: ['slug'] }], + } + + const doc = await generator.generate({ + findPlanet: oc.input(testSchema(FindPlanetInput)), + // the root constraint lives in a property, so splitting keeps it + findMoon: oc.input(testSchema({ type: 'object', properties: { planet: FindPlanetInput }, required: ['planet'] })), + }) + + expect(doc.methods).toEqual([ + { + name: 'findPlanet', + paramStructure: 'by-position', + params: [{ name: 'input', required: true, schema: FindPlanetInput }], + result: { name: 'result', schema: {} }, + }, + { + name: 'findMoon', + paramStructure: 'by-name', + params: [{ name: 'planet', required: true, schema: FindPlanetInput }], + result: { name: 'result', schema: {} }, + }, + ]) + }) +}) diff --git a/packages/openrpc/tests/openrpc-generator/crud.test.ts b/packages/openrpc/tests/openrpc-generator/crud.test.ts new file mode 100644 index 0000000000..10328c049c --- /dev/null +++ b/packages/openrpc/tests/openrpc-generator/crud.test.ts @@ -0,0 +1,114 @@ +import { oc } from '@orpc/contract' +import { openapi } from '@orpc/openapi' +import z from 'zod' +import { openrpc, OpenRPCGenerator } from '../../src' +import { zodJsonSchemaConverter } from '../__shared__/schema' + +describe('openRPCGenerator e2e: crud api', () => { + const generator = new OpenRPCGenerator({ converters: [zodJsonSchemaConverter] }) + + const Planet = z.object({ + id: z.number(), + name: z.string(), + description: z.string().optional(), + }).meta({ id: 'Planet' }) + + const router = { + planet: { + list: oc + .meta(openrpc({ tags: ['planets'], summary: 'List planets' })) + .input(z.object({ cursor: z.string().optional(), limit: z.number().optional() }).optional()) + .output(z.array(Planet)), + find: oc + .meta(openrpc({ + tags: ['planets'], + summary: 'Find a planet', + examples: [{ name: 'Earth', input: { id: 1 }, output: { id: 1, name: 'Earth' } }], + })) + .input(z.object({ id: z.number() })) + .output(Planet.optional()), + create: oc + // documented for OpenAPI only + .meta(openapi({ method: 'POST', path: '/planets', tags: ['planets'], summary: 'Create a planet' })) + .input(z.object({ name: z.string(), description: z.string().optional() })) + .output(Planet), + update: oc + .meta(openrpc({ tags: ['planets'] })) + .input(z.object({ id: z.number(), name: z.string().optional(), description: z.string().optional() })) + .output(Planet), + remove: oc + .meta(openrpc({ tags: ['planets'], deprecated: true, description: 'Use planet.archive instead.' })) + .input(z.number()), + }, + } + + it('documents every procedure as a method named after its router path', async () => { + const doc = await generator.generate(router, { + base: { + info: { title: 'Planet API', version: '1.0.0' }, + servers: [{ url: 'https://example.com/rpc' }], + }, + }) + + expect(doc).toEqual({ + openrpc: '1.3.2', + info: { title: 'Planet API', version: '1.0.0' }, + servers: [{ url: 'https://example.com/rpc' }], + methods: [ + expect.objectContaining({ name: 'planet.list', summary: 'List planets', tags: [{ name: 'planets' }] }), + expect.objectContaining({ name: 'planet.find', summary: 'Find a planet', tags: [{ name: 'planets' }] }), + expect.objectContaining({ name: 'planet.create' }), + expect.objectContaining({ name: 'planet.update' }), + expect.objectContaining({ name: 'planet.remove', deprecated: true }), + ], + components: { + schemas: { + Planet: { + type: 'object', + properties: { + id: { type: 'number' }, + name: { type: 'string' }, + description: { type: 'string' }, + }, + required: ['id', 'name'], + additionalProperties: false, + }, + }, + }, + }) + }) + + it('maps list inputs to by-name params that can all be omitted', async () => { + const [list] = (await generator.generate(router)).methods + + expect(list).toEqual({ + name: 'planet.list', + summary: 'List planets', + tags: [{ name: 'planets' }], + paramStructure: 'by-name', + params: [ + { name: 'cursor', schema: { type: 'string' } }, + { name: 'limit', schema: { type: 'number' } }, + ], + result: { name: 'result', schema: { type: 'array', items: { $ref: '#/components/schemas/Planet' } } }, + }) + }) + + it('allows a null result when nothing is found, and describes examples as by-name params', async () => { + const [, find] = (await generator.generate(router)).methods + + expect(find).toEqual({ + name: 'planet.find', + summary: 'Find a planet', + tags: [{ name: 'planets' }], + paramStructure: 'by-name', + params: [{ name: 'id', required: true, schema: { type: 'number' } }], + result: { name: 'result', schema: { anyOf: [{ $ref: '#/components/schemas/Planet' }, { type: 'null' }] } }, + examples: [{ + name: 'Earth', + params: [{ name: 'id', value: 1 }], + result: { name: 'result', value: { id: 1, name: 'Earth' } }, + }], + }) + }) +}) diff --git a/packages/openrpc/tests/openrpc-generator/event-streaming.test.ts b/packages/openrpc/tests/openrpc-generator/event-streaming.test.ts new file mode 100644 index 0000000000..9a2a61ce30 --- /dev/null +++ b/packages/openrpc/tests/openrpc-generator/event-streaming.test.ts @@ -0,0 +1,68 @@ +import { asyncIteratorObject, eventIterator, oc } from '@orpc/contract' +import z from 'zod' +import { openrpc, OpenRPCGenerator } from '../../src' +import { zodJsonSchemaConverter } from '../__shared__/schema' + +describe('openRPCGenerator e2e: event iterator streaming', () => { + const generator = new OpenRPCGenerator({ converters: [zodJsonSchemaConverter] }) + + it('skips streaming procedures, which JSON-RPC over HTTP cannot serve, and keeps request/response ones', async () => { + const doc = await generator.generate({ + chat: { + stream: oc + .input(asyncIteratorObject(z.object({ prompt: z.string() }), z.object({ done: z.boolean() }))) + .output(asyncIteratorObject(z.object({ delta: z.string() }), z.object({ usage: z.number() }))), + send: oc + .input(z.object({ prompt: z.string() })) + .output(z.object({ reply: z.string() })), + }, + notifications: oc.output(eventIterator(z.object({ title: z.string() }))), + // skipped before validation, so its file chunks are not reported + upload: oc.input(eventIterator(z.object({ chunk: z.file() }))), + }) + + expect(doc).toEqual({ + openrpc: '1.3.2', + info: { title: 'API Reference', version: '0.0.0' }, + methods: [{ + name: 'chat.send', + paramStructure: 'by-name', + params: [{ name: 'prompt', required: true, schema: { type: 'string' } }], + result: { + name: 'result', + schema: { type: 'object', properties: { reply: { type: 'string' } }, required: ['reply'], additionalProperties: false }, + }, + }], + }) + }) + + it('skips streaming procedures even when an openrpc spec object describes them', async () => { + const doc = await generator.generate({ + live: oc + .meta(openrpc({ spec: { name: 'live', params: [] } })) + .output(eventIterator(z.string())), + }) + + expect(doc.methods).toEqual([]) + }) + + it('does not register components used only by skipped streaming procedures', async () => { + const Message = z.object({ text: z.string() }).meta({ id: 'Message' }) + const Notification = z.object({ title: z.string() }).meta({ id: 'Notification' }) + + const doc = await generator.generate({ + messages: oc.output(eventIterator(Message)), + notifications: oc.output(eventIterator(Notification)), + lastMessage: oc.output(Message), + }) + + expect(doc.methods).toEqual([ + expect.objectContaining({ name: 'lastMessage', result: { name: 'result', schema: { $ref: '#/components/schemas/Message' } } }), + ]) + expect(doc.components).toEqual({ + schemas: { + Message: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'], additionalProperties: false }, + }, + }) + }) +}) diff --git a/packages/openrpc/tests/openrpc-generator/file-transfer.test.ts b/packages/openrpc/tests/openrpc-generator/file-transfer.test.ts new file mode 100644 index 0000000000..1a8890713c --- /dev/null +++ b/packages/openrpc/tests/openrpc-generator/file-transfer.test.ts @@ -0,0 +1,58 @@ +import { oc } from '@orpc/contract' +import z from 'zod' +import { openrpc, OpenRPCGenerator, OpenRPCGeneratorError } from '../../src' +import { zodJsonSchemaConverter } from '../__shared__/schema' + +describe('openRPCGenerator e2e: file upload and download', () => { + const generator = new OpenRPCGenerator({ converters: [zodJsonSchemaConverter] }) + + async function generateError(...args: Parameters): Promise { + return generator.generate(...args).then( + () => { throw new Error('expected generate to reject') }, + e => e, + ) + } + + it('reports form uploads and mixed union downloads in one aggregated error', async () => { + const error = await generateError({ + avatar: { + upload: oc.input(z.object({ userId: z.string(), image: z.file().mime(['image/png', 'image/jpeg']) })), + }, + exportPlanets: oc.output(z.union([z.file().mime('text/csv'), z.array(z.object({ id: z.string() }))])), + }) + + expect(error).toBeInstanceOf(OpenRPCGeneratorError) + expect((error as Error).message).toBe([ + '[OpenRPCGenerator] Failed to generate the OpenRPC document:', + 'Procedure at avatar.upload: File/Blob schemas cannot be sent over JSON-RPC.', + 'Procedure at exportPlanets: File/Blob schemas cannot be sent over JSON-RPC.', + ].join('\n\n')) + }) + + it('finds files hidden in reusable components and recursive schemas', async () => { + const Attachment = z.file().meta({ id: 'Attachment' }) + const Folder: z.ZodTypeAny = z.looseObject({ + name: z.string(), + get files() { return z.array(Attachment) }, + get folders() { return z.array(Folder) }, + }).meta({ id: 'Folder' }) + + const error = await generateError({ + sendMessage: oc.input(z.object({ text: z.string(), attachments: z.array(Attachment).optional() })), + getFolder: oc.output(Folder), + }) + + expect((error as Error).message).toContain('Procedure at sendMessage: File/Blob schemas cannot be sent over JSON-RPC.') + expect((error as Error).message).toContain('Procedure at getFolder: File/Blob schemas cannot be sent over JSON-RPC.') + }) + + it('still rejects file procedures whose openrpc spec function only transforms the generated method', async () => { + const error = await generateError({ + upload: oc + .meta(openrpc({ spec: method => ({ ...method, 'x-internal': true }) })) + .input(z.file()), + }) + + expect((error as Error).message).toContain('Procedure at upload: File/Blob schemas cannot be sent over JSON-RPC.') + }) +}) diff --git a/packages/openrpc/tests/openrpc-generator/reusable-components.test.ts b/packages/openrpc/tests/openrpc-generator/reusable-components.test.ts new file mode 100644 index 0000000000..8f81f54f3d --- /dev/null +++ b/packages/openrpc/tests/openrpc-generator/reusable-components.test.ts @@ -0,0 +1,110 @@ +import { oc } from '@orpc/contract' +import z from 'zod' +import { OpenRPCGenerator } from '../../src' +import { zodJsonSchemaConverter } from '../__shared__/schema' + +describe('openRPCGenerator e2e: reusable component schemas', () => { + const generator = new OpenRPCGenerator({ converters: [zodJsonSchemaConverter] }) + + it('hoists a recursive entity into a single component referenced from params and results', async () => { + const Category: z.ZodTypeAny = z.lazy(() => z.looseObject({ + name: z.string(), + children: z.array(Category).optional(), + })).meta({ id: 'Category' }) + + const doc = await generator.generate({ + getCategory: oc.input(z.object({ name: z.string() })).output(Category), + createCategory: oc.input(z.object({ category: Category })).output(z.object({ category: Category })), + }) + + expect(doc.methods).toEqual([ + expect.objectContaining({ + name: 'getCategory', + result: { name: 'result', schema: { $ref: '#/components/schemas/Category' } }, + }), + expect.objectContaining({ + name: 'createCategory', + params: [{ name: 'category', required: true, schema: { $ref: '#/components/schemas/Category' } }], + result: { name: 'result', schema: expect.objectContaining({ properties: { category: { $ref: '#/components/schemas/Category' } } }) }, + }), + ]) + expect(doc.components).toEqual({ + schemas: { + Category: { + type: 'object', + properties: { + name: { type: 'string' }, + children: { type: 'array', items: { $ref: '#/components/schemas/Category' } }, + }, + required: ['name'], + additionalProperties: {}, + }, + }, + }) + }) + + it('hoists mutually recursive entities, and splits one used as the whole input into by-name params', async () => { + const User: z._ZodType = z.looseObject({ + name: z.string(), + // eslint-disable-next-line ts/no-use-before-define + posts: z.array(z.lazy(() => Post)).optional(), + }).meta({ id: 'User' }) + + const Post: z.ZodTypeAny = z.looseObject({ + title: z.string(), + author: z.lazy(() => User).optional(), + }).meta({ id: 'Post' }) + + const doc = await generator.generate({ + getUser: oc.input(z.object({ user: User })).output(z.object({ user: User })), + updatePost: oc.input(Post).output(Post), + }) + + expect(doc.methods[1]).toEqual({ + name: 'updatePost', + paramStructure: 'by-name', + params: [ + { name: 'title', required: true, schema: { type: 'string' } }, + { name: 'author', schema: { $ref: '#/components/schemas/User' } }, + ], + result: { name: 'result', schema: { $ref: '#/components/schemas/Post' } }, + }) + expect(doc.components?.schemas).toEqual({ + User: expect.objectContaining({ + properties: expect.objectContaining({ posts: { type: 'array', items: { $ref: '#/components/schemas/Post' } } }), + }), + Post: expect.objectContaining({ + properties: expect.objectContaining({ author: { $ref: '#/components/schemas/User' } }), + }), + }) + }) + + it('hoists an entity that recurses to its own root out of params and results', async () => { + const Tree: z.ZodTypeAny = z.looseObject({ + name: z.string(), + get children() { return z.array(Tree) }, + }) + + const doc = await generator.generate({ + createTree: oc.input(Tree).output(Tree.optional()), + }) + + expect(doc.methods).toEqual([{ + name: 'createTree', + paramStructure: 'by-name', + params: [ + { name: 'name', required: true, schema: { type: 'string' } }, + { name: 'children', required: true, schema: { type: 'array', items: { $ref: '#/components/schemas/__schema0' } } }, + ], + result: { name: 'result', schema: { anyOf: [{ $ref: '#/components/schemas/__schema0' }, { type: 'null' }] } }, + }]) + expect(doc.components?.schemas).toEqual({ + __schema0: expect.objectContaining({ + properties: { + name: { type: 'string' }, + children: { type: 'array', items: { $ref: '#/components/schemas/__schema0' } }, + }, + }), + }) + }) +}) diff --git a/packages/openrpc/tests/openrpc-generator/schema-libraries.test.ts b/packages/openrpc/tests/openrpc-generator/schema-libraries.test.ts new file mode 100644 index 0000000000..644dcb4a14 --- /dev/null +++ b/packages/openrpc/tests/openrpc-generator/schema-libraries.test.ts @@ -0,0 +1,87 @@ +import { oc } from '@orpc/contract' +import * as arktype from 'arktype' +import z from 'zod' +import { OpenRPCGenerator } from '../../src' +import { testSchema, testSchemaConverter, zodJsonSchemaConverter } from '../__shared__/schema' + +describe('openRPCGenerator e2e: schema library agnosticism', () => { + const generator = new OpenRPCGenerator({ converters: [testSchemaConverter, zodJsonSchemaConverter] }) + + it('generates the same method shape from zod, arktype, and plain JSON schemas', async () => { + const doc = await generator.generate({ + withZod: oc + .input(z.object({ name: z.string(), radius: z.number().optional() })) + .output(z.object({ id: z.string() })), + withArktype: oc + .input(arktype.type({ 'name': 'string', 'radius?': 'number' })) + .output(arktype.type({ id: 'string' })), + withJsonSchema: oc + .input(testSchema({ type: 'object', properties: { name: { type: 'string' }, radius: { type: 'number' } }, required: ['name'] })) + .output(testSchema({ type: 'object', properties: { id: { type: 'string' } }, required: ['id'] })), + }) + + expect(doc.methods.map(method => 'name' in method && method.name)).toEqual(['withZod', 'withArktype', 'withJsonSchema']) + + for (const method of doc.methods) { + expect(method).toEqual({ + name: expect.any(String), + paramStructure: 'by-name', + params: [ + { name: 'name', required: true, schema: { type: 'string' } }, + { name: 'radius', schema: { type: 'number' } }, + ], + result: { + name: 'result', + schema: expect.objectContaining({ type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }), + }, + }) + } + }) + + it('relies on each library to tell optional inputs and outputs apart', async () => { + const doc = await generator.generate({ + withZod: oc.input(z.array(z.string()).optional()).output(z.string().optional()), + withJsonSchema: oc + .input(testSchema({ type: 'array', items: { type: 'string' } }, { optional: true })) + .output(testSchema({ type: 'string' }, { optional: true })), + }) + + for (const method of doc.methods) { + expect(method).toEqual({ + name: expect.any(String), + paramStructure: 'by-position', + params: [{ name: 'input', schema: { type: 'array', items: { type: 'string' } } }], + result: { name: 'result', schema: { anyOf: [{ type: 'string' }, { type: 'null' }] } }, + }) + } + }) + + it('supports plain JSON schemas with $defs, hoisting them into components', async () => { + const doc = await generator.generate({ + createPlanet: oc.input(testSchema({ + type: 'object', + properties: { planet: { $ref: '#/$defs/Planet' } }, + required: ['planet'], + $defs: { + Planet: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + }, + })), + }) + + expect(doc).toEqual({ + openrpc: '1.3.2', + info: { title: 'API Reference', version: '0.0.0' }, + methods: [{ + name: 'createPlanet', + paramStructure: 'by-name', + params: [{ name: 'planet', required: true, schema: { $ref: '#/components/schemas/Planet' } }], + result: { name: 'result', schema: {} }, + }], + components: { + schemas: { + Planet: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + }, + }, + }) + }) +}) diff --git a/packages/openrpc/tests/openrpc-generator/typed-errors.test.ts b/packages/openrpc/tests/openrpc-generator/typed-errors.test.ts new file mode 100644 index 0000000000..c801a852fd --- /dev/null +++ b/packages/openrpc/tests/openrpc-generator/typed-errors.test.ts @@ -0,0 +1,72 @@ +import { oc } from '@orpc/contract' +import z from 'zod' +import { OpenRPCGenerator } from '../../src' +import { zodJsonSchemaConverter } from '../__shared__/schema' + +describe('openRPCGenerator e2e: typed errors', () => { + const generator = new OpenRPCGenerator({ converters: [zodJsonSchemaConverter] }) + + const router = { + createPlanet: oc + .errors({ + CONFLICT: { + message: 'Planet already exists', + data: z.object({ existingId: z.string() }).meta({ id: 'ConflictData' }), + }, + UNPROCESSABLE_CONTENT: {}, + BAD_REQUEST: {}, + }) + .input(z.object({ name: z.string() })), + getPlanet: oc + .errors({ NOT_FOUND: {} }) + .input(z.object({ id: z.string() })), + } + + it('documents each error with its JSON-RPC code, message, and the oRPC code carried in data', async () => { + const doc = await generator.generate(router) + + expect(doc.methods).toEqual([ + expect.objectContaining({ + name: 'createPlanet', + errors: [ + { code: -32009, message: 'Planet already exists', data: { code: 'CONFLICT', defined: true } }, + { code: -32022, message: 'Unprocessable Content', data: { code: 'UNPROCESSABLE_CONTENT', defined: true } }, + // input validation errors share the "Invalid params" code + { code: -32602, message: 'Bad Request', data: { code: 'BAD_REQUEST', defined: true } }, + ], + }), + expect.objectContaining({ + name: 'getPlanet', + errors: [{ code: -32004, message: 'Not Found', data: { code: 'NOT_FOUND', defined: true } }], + }), + ]) + + // error data schemas are not part of the OpenRPC error object + expect(doc.components).toBeUndefined() + }) + + it('documents errors defined on a contract router for every procedure under it', async () => { + const doc = await generator.generate( + oc.errors({ UNAUTHORIZED: {} }).router({ + planet: { + find: oc.errors({ NOT_FOUND: {} }), + list: oc, + }, + }), + ) + + expect(doc.methods).toEqual([ + expect.objectContaining({ + name: 'planet.find', + errors: [ + { code: -32001, message: 'Unauthorized', data: { code: 'UNAUTHORIZED', defined: true } }, + { code: -32004, message: 'Not Found', data: { code: 'NOT_FOUND', defined: true } }, + ], + }), + expect.objectContaining({ + name: 'planet.list', + errors: [{ code: -32001, message: 'Unauthorized', data: { code: 'UNAUTHORIZED', defined: true } }], + }), + ]) + }) +}) diff --git a/packages/openrpc/tsconfig.json b/packages/openrpc/tsconfig.json new file mode 100644 index 0000000000..7a3f25403a --- /dev/null +++ b/packages/openrpc/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.lib.json", + "references": [ + { "path": "../client" }, + { "path": "../contract" }, + { "path": "../json-schema" }, + { "path": "../openapi" }, + { "path": "../server" }, + { "path": "../shared" } + ], + "include": ["package.json", "src"], + "exclude": [ + "**/*.test.*", + "**/*.test-d.ts", + "**/__tests__/**", + "**/__mocks__/**", + "**/__snapshots__/**" + ] +} diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index f8d783e0c8..4bb675b3d8 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/pinia-colada/README.md b/packages/pinia-colada/README.md index aa545967af..279d1d909a 100644 --- a/packages/pinia-colada/README.md +++ b/packages/pinia-colada/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/pino/README.md b/packages/pino/README.md index 7bbed07f8a..ad2dfa1638 100644 --- a/packages/pino/README.md +++ b/packages/pino/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/publisher/README.md b/packages/publisher/README.md index 3d5c6379e6..98689d4de7 100644 --- a/packages/publisher/README.md +++ b/packages/publisher/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/ratelimit/README.md b/packages/ratelimit/README.md index 706974f413..402e9b70b8 100644 --- a/packages/ratelimit/README.md +++ b/packages/ratelimit/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/server/README.md b/packages/server/README.md index d4f3efd40c..61a91157b3 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/shared/README.md b/packages/shared/README.md index 637c24e092..e9d73bc89f 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/swr/README.md b/packages/swr/README.md index 0f6d094e32..17977e12ac 100644 --- a/packages/swr/README.md +++ b/packages/swr/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/tanstack-query/README.md b/packages/tanstack-query/README.md index 20a2d083f0..13439686bb 100644 --- a/packages/tanstack-query/README.md +++ b/packages/tanstack-query/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/trpc/README.md b/packages/trpc/README.md index 17e1137322..34f63113ec 100644 --- a/packages/trpc/README.md +++ b/packages/trpc/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/valibot/README.md b/packages/valibot/README.md index 3b5a06e370..36b63d5798 100644 --- a/packages/valibot/README.md +++ b/packages/valibot/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/packages/zod/README.md b/packages/zod/README.md index ae9e5c0c08..92e2746e4a 100644 --- a/packages/zod/README.md +++ b/packages/zod/README.md @@ -66,6 +66,7 @@ Read the full documentation at [orpc.dev](https://orpc.dev), starting with the [ | [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | | [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | | [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | +| [@orpc/openrpc](https://www.npmjs.com/package/@orpc/openrpc) | Serve JSON-RPC 2.0 and generate OpenRPC documents. | [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler), [OpenRPC Link](https://orpc.dev/docs/openrpc/link), [Specification](https://orpc.dev/docs/openrpc/specification) | **Schema validation** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f55523e499..56c9fc416f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,6 +217,9 @@ importers: '@orpc/openapi': specifier: workspace:* version: link:packages/openapi + '@orpc/openrpc': + specifier: workspace:* + version: link:packages/openrpc '@orpc/opentelemetry': specifier: workspace:* version: link:packages/opentelemetry @@ -918,6 +921,49 @@ importers: specifier: ^4.6.7 version: 4.6.7 + packages/openrpc: + dependencies: + '@orpc/client': + specifier: workspace:* + version: link:../client + '@orpc/contract': + specifier: workspace:* + version: link:../contract + '@orpc/json-schema': + specifier: workspace:* + version: link:../json-schema + '@orpc/openapi': + specifier: workspace:* + version: link:../openapi + '@orpc/server': + specifier: workspace:* + version: link:../server + '@orpc/shared': + specifier: workspace:* + version: link:../shared + '@standard-server/core': + specifier: ~0.13.0 + version: 0.13.0 + '@standard-server/fetch': + specifier: ~0.13.0 + version: 0.13.0 + devDependencies: + '@orpc/zod': + specifier: workspace:* + version: link:../zod + '@standard-server/aws-lambda': + specifier: ~0.13.0 + version: 0.13.0 + fastify: + specifier: ^5.12.7 + version: 5.12.7 + ws: + specifier: ^8.22.0 + version: 8.22.0 + zod: + specifier: ^4.6.7 + version: 4.6.7 + packages/opentelemetry: dependencies: '@orpc/shared': diff --git a/skills/orpc/SKILL.md b/skills/orpc/SKILL.md index 1e26c0534e..53e7169294 100644 --- a/skills/orpc/SKILL.md +++ b/skills/orpc/SKILL.md @@ -18,6 +18,7 @@ Package map: - `@orpc/client`: `createORPCClient`, `RPCLink`, `safe`, `createSafeClient`, `isDefinedError` - `@orpc/contract`: contract-first API definitions implemented separately from their logic (see the `orpc-contract` skill) - `@orpc/openapi`: `OpenAPIHandler`, `OpenAPILink`, OpenAPI 3.2 spec generation +- `@orpc/openrpc`: `OpenRPCHandler`, `OpenRPCLink`, OpenRPC document generation (JSON-RPC 2.0) - Integration packages such as `@orpc/tanstack-query` and `@orpc/nest` ## Define procedures @@ -211,6 +212,7 @@ const safeClient = createSafeClient(orpc) // every call returns [error, data] - Streaming / SSE: return an async generator from `.handler`, validate events with `asyncIteratorObject`, resume via `lastEventId`: [AsyncIteratorObject](https://orpc.dev/docs/async-iterator-object) - OpenAPI: serve the same router as REST with routing metadata plus `OpenAPIHandler`, and generate a spec (covered in depth by the `orpc-openapi` skill): [OpenAPI Handler](https://orpc.dev/docs/openapi/handler) +- JSON-RPC 2.0: serve the same router with `OpenRPCHandler` (each procedure is a method named by its dot-joined path, such as `planet.find`), call it with `OpenRPCLink`, and generate an OpenRPC document with `OpenRPCGenerator`; streaming and files are not supported: [OpenRPC Handler](https://orpc.dev/docs/openrpc/handler) - Contract-first: define contracts with `@orpc/contract`, implement with `implement` (covered in depth by the `orpc-contract` skill): [Contracts](https://orpc.dev/docs/contract/procedure) - Plugins for handler and link: batch, CORS, dedupe, retry, compression, request limits, smart coercion, static files, timeout, tmp file upload, and more. Fetch a plugin's docs page before configuring it; option names are not guessable: [Plugins](https://orpc.dev/docs/plugins/batch) - Integrations: [TanStack Query](https://orpc.dev/docs/integrations/tanstack-query) (`createTanstackQueryUtils`), [SWR](https://orpc.dev/docs/integrations/swr), [Pinia Colada](https://orpc.dev/docs/integrations/pinia-colada), [Next.js](https://orpc.dev/docs/integrations/next), [NestJS](https://orpc.dev/docs/integrations/nest), [AI SDK](https://orpc.dev/docs/integrations/ai-sdk), [MCP](https://orpc.dev/docs/integrations/mcp) (`McpRegistrar` exposes procedures as MCP tools), [OpenTelemetry](https://orpc.dev/docs/integrations/opentelemetry) @@ -229,7 +231,7 @@ This skill is an overview; fetch exact docs instead of guessing APIs, and if thi Doc map, all under `https://orpc.dev/docs/`: - Top level: `procedure`, `router`, `middleware`, `context`, `error-handling`, `metadata`, plus `binary-data` (file uploads) and `async-iterator-object` (streaming/SSE) -- `rpc/*`, `openapi/*`: protocol details, handlers, links; `contract/*`: contract-first (the `orpc-contract` skill) +- `rpc/*`, `openapi/*`, `openrpc/*`: protocol details, handlers, links; `contract/*`: contract-first (the `orpc-contract` skill) - `client/*`: server- and client-side clients, error handling, `DynamicLink` - `adapters/*`: per-runtime serving quirks (fetch-api, node-http, aws-lambda, fastify, websocket, message-port, expo) - `plugins/*`: twenty handler/link plugins; `helpers/*`: cookie, encryption, form-data, lock, publisher, ratelimit, signing, base64url diff --git a/tests/openrpc/__shared__/client-server.hono-fetch.ts b/tests/openrpc/__shared__/client-server.hono-fetch.ts new file mode 100644 index 0000000000..2dcd6aa62b --- /dev/null +++ b/tests/openrpc/__shared__/client-server.hono-fetch.ts @@ -0,0 +1,45 @@ +import type { AddressInfo } from 'node:net' +import type { CreateOpenRPCClientServerTest } from './client-server' +import { serve } from '@hono/node-server' +import { createORPCClient } from '@orpc/client' +import { OpenRPCHandler, OpenRPCLink } from '@orpc/openrpc/fetch' + +export const createHonoFetchClientServerTest: CreateOpenRPCClientServerTest = ( + router, + { context = {}, serializer, handlerPlugins, linkPlugins } = {}, +) => { + const handler = new OpenRPCHandler(router, { + serializer, + plugins: handlerPlugins, + }) + + const server = serve({ + fetch: async (request: Request) => { + const { response } = await handler.handle(request, { + context, + prefix: '/rpc', + }) + + return response ?? new Response('Not Found', { status: 404 }) + }, + port: 0, + }) + + afterAll(() => { + server.close() + }) + + const addressInfo = server.address() as AddressInfo + + const link = new OpenRPCLink({ + url: '/rpc', + origin: `http://localhost:${addressInfo.port}`, + serializer, + plugins: linkPlugins, + fetch(url, init) { + return fetch(url, init) + }, + }) + + return createORPCClient(link) +} diff --git a/tests/openrpc/__shared__/client-server.node-http.ts b/tests/openrpc/__shared__/client-server.node-http.ts new file mode 100644 index 0000000000..39f52b03b3 --- /dev/null +++ b/tests/openrpc/__shared__/client-server.node-http.ts @@ -0,0 +1,43 @@ +import type { AddressInfo } from 'node:net' +import type { CreateOpenRPCClientServerTest } from './client-server' +import * as http from 'node:http' +import { createORPCClient } from '@orpc/client' +import { OpenRPCLink } from '@orpc/openrpc/fetch' +import { OpenRPCHandler } from '@orpc/openrpc/node' + +export const createNodeHttpClientServerTest: CreateOpenRPCClientServerTest = ( + router, + { context = {}, serializer, handlerPlugins, linkPlugins } = {}, +) => { + const handler = new OpenRPCHandler(router, { + serializer, + plugins: handlerPlugins, + }) + + const server = http.createServer(async (req, res) => { + await handler.handle(req, res, { + context, + prefix: '/rpc', + }) + }) + + server.listen(0) + + afterAll(() => { + server.close() + }) + + const addressInfo = server.address() as AddressInfo + + const link = new OpenRPCLink({ + url: '/rpc', + origin: `http://localhost:${addressInfo.port}`, + serializer, + plugins: linkPlugins, + fetch(url, init) { + return fetch(url, init) + }, + }) + + return createORPCClient(link) +} diff --git a/tests/openrpc/__shared__/client-server.node-ws.ts b/tests/openrpc/__shared__/client-server.node-ws.ts new file mode 100644 index 0000000000..36531f8513 --- /dev/null +++ b/tests/openrpc/__shared__/client-server.node-ws.ts @@ -0,0 +1,39 @@ +import type { AddressInfo } from 'node:net' +import type { CreateOpenRPCClientServerTest } from './client-server' +import { createORPCClient } from '@orpc/client' +import { OpenRPCHandler, OpenRPCLink } from '@orpc/openrpc/websocket' +import WebSocket, { WebSocketServer } from 'ws' + +export const createNodeWsClientServerTest: CreateOpenRPCClientServerTest = ( + router, + { context = {}, serializer, handlerPlugins, linkPlugins } = {}, +) => { + const handler = new OpenRPCHandler(router, { + serializer, + plugins: handlerPlugins, + }) + + const wss = new WebSocketServer({ port: 0 }) + + wss.on('connection', (ws) => { + handler.upgrade(ws, { + context, + prefix: '/rpc', + }) + }) + + afterAll(() => { + wss.clients.forEach(ws => ws.close()) + wss.close() + }) + + const addressInfo = wss.address() as AddressInfo + + const link = new OpenRPCLink({ + connect: () => new WebSocket(`ws://localhost:${addressInfo.port}`), + serializer, + plugins: linkPlugins, + }) + + return createORPCClient(link) +} diff --git a/tests/openrpc/__shared__/client-server.ts b/tests/openrpc/__shared__/client-server.ts new file mode 100644 index 0000000000..a7da4fe693 --- /dev/null +++ b/tests/openrpc/__shared__/client-server.ts @@ -0,0 +1,16 @@ +import type { StandardLinkPlugin } from '@orpc/client/standard' +import type { JsonifiedClient, OpenAPIJsonSerializer } from '@orpc/openapi' +import type { AnyRouter, Context, RouterClient } from '@orpc/server' +import type { StandardHandlerPlugin } from '@orpc/server/standard' +import type { Public } from '@orpc/shared' + +export interface OpenRPCClientServerTestOptions { + context?: Context + serializer?: Public + handlerPlugins?: StandardHandlerPlugin[] + linkPlugins?: StandardLinkPlugin[] +} + +export interface CreateOpenRPCClientServerTest { + (router: T, options?: OpenRPCClientServerTestOptions): JsonifiedClient> +} diff --git a/tests/openrpc/cancel-and-abort.test.ts b/tests/openrpc/cancel-and-abort.test.ts new file mode 100644 index 0000000000..69d9098b7b --- /dev/null +++ b/tests/openrpc/cancel-and-abort.test.ts @@ -0,0 +1,78 @@ +import { os } from '@orpc/server' +import { sleep } from '@standard-server/shared' +import WebSocket from 'ws' +import { z } from 'zod' +import { createHonoFetchClientServerTest } from './__shared__/client-server.hono-fetch' +import { createNodeHttpClientServerTest } from './__shared__/client-server.node-http' +import { createNodeWsClientServerTest } from './__shared__/client-server.node-ws' + +describe.each([ + ['hono-fetch', createHonoFetchClientServerTest], + ['node-http', createNodeHttpClientServerTest], +])('cancel and abort: %s', async (_name, createClientServer) => { + const handler = vi.fn() + const router = { + ping: os + .input(z.any()) + .handler(handler), + } + const client = createClientServer(router) + + it('server signal should abort when client signal is aborted', async () => { + handler.mockImplementationOnce(async ({ signal }) => { + await sleep(200) + + return signal.aborted + }) + + const controller = new AbortController() + + const promise = expect(client.ping(null, { signal: controller.signal })).rejects.toThrowError('abort') + + await sleep(100) + expect(handler).toHaveBeenCalledTimes(1) + expect(handler.mock.calls[0]![0].signal.aborted).toBe(false) + + controller.abort() + await promise + await sleep(100) + expect(handler.mock.calls[0]![0].signal.aborted).toBe(true) + }) +}) + +// JSON-RPC has no cancel message, so over WebSocket a call is only aborted on the server when its socket closes +describe('cancel and abort: node-ws', () => { + const handler = vi.fn() + const router = { + ping: os + .input(z.any()) + .handler(handler), + } + const client = createNodeWsClientServerTest(router) + + it('server signal should abort when the socket closes, not when client signal is aborted', async () => { + handler.mockImplementationOnce(async () => { + await sleep(300) + }) + + const send = vi.spyOn(WebSocket.prototype, 'send') + const controller = new AbortController() + + const promise = expect(client.ping(null, { signal: controller.signal })).rejects.toThrowError('abort') + + await sleep(100) + const socket = send.mock.contexts[0] as WebSocket // the client socket, which sent the request + send.mockRestore() + expect(handler).toHaveBeenCalledTimes(1) + expect(handler.mock.calls[0]![0].signal.aborted).toBe(false) + + controller.abort() + await promise + await sleep(100) + expect(handler.mock.calls[0]![0].signal.aborted).toBe(false) + + socket.close() + await sleep(100) + expect(handler.mock.calls[0]![0].signal.aborted).toBe(true) + }) +}) diff --git a/tests/openrpc/data-transfer.test.ts b/tests/openrpc/data-transfer.test.ts new file mode 100644 index 0000000000..3e0afd525d --- /dev/null +++ b/tests/openrpc/data-transfer.test.ts @@ -0,0 +1,204 @@ +import type { RouterClient } from '@orpc/server' +import { ORPCError } from '@orpc/client' +import { oc } from '@orpc/contract' +import { SmartCoercionHandlerPlugin, SmartCoercionLinkPlugin } from '@orpc/json-schema' +import { OpenAPIJsonSerializer } from '@orpc/openapi' +import { implement, os } from '@orpc/server' +import { ZodToJsonSchemaConverter } from '@orpc/zod' +import { z } from 'zod' +import { Person } from '../rpc/__shared__/client-server' +import { createHonoFetchClientServerTest } from './__shared__/client-server.hono-fetch' +import { createNodeHttpClientServerTest } from './__shared__/client-server.node-http' +import { createNodeWsClientServerTest } from './__shared__/client-server.node-ws' + +describe.each([ + ['hono-fetch', createHonoFetchClientServerTest], + ['node-http', createNodeHttpClientServerTest], + ['node-ws', createNodeWsClientServerTest], +])('openrpc data transfer: %s', async (_name, createClientServer) => { + const date = new Date('2024-01-02T03:04:05.000Z') + const customSerializer = new OpenAPIJsonSerializer({ + handlers: { + person: { + condition: value => value instanceof Person, + serialize: value => ({ + __person__: { + name: value.name, + age: value.age, + }, + }), + }, + }, + }) + + const router = { + echo: os.input(z.any()).handler(({ input }) => input), + nothing: os.handler(() => undefined), + whoami: os + .$context<{ userId: string }>() + .handler(({ context }) => context.userId), + error: os.handler(() => { + throw new ORPCError('NOT_FOUND', { + message: 'Missing item', + data: { id: 'missing-item' }, + }) + }), + definedError: os + .errors({ + FORBIDDEN: { + data: z.object({ reason: z.string(), at: z.date() }), + }, + }) + .handler(({ errors }) => { + throw errors.FORBIDDEN({ + message: 'Access denied', + data: { reason: 'no-permission', at: date }, + }) + }), + customSerializer: os.input(z.any()).handler(({ input }) => ({ client: input, server: new Person('server', 12) })), + } + + const client = createClientServer(router, { + context: { userId: 'u_123' }, + }) + + const customClient = createClientServer(router, { + serializer: customSerializer, + }) + + it('supports JSON objects by-name and other values by-position', async () => { + const value = { + string: 'héllo 👋 世界', + number: 1.5, + boolean: false, + null: null, + array: [1, 'two', { three: [3] }], + nested: { deep: { deeper: 'yes' } }, + } + + await expect(client.echo(value)).resolves.toEqual(value) + await expect(client.echo([1, 'two', { three: 3 }])).resolves.toEqual([1, 'two', { three: 3 }]) + await expect(client.echo(0)).resolves.toBe(0) + await expect(client.echo(false)).resolves.toBe(false) + await expect(client.echo(null)).resolves.toBe(null) + }) + + it('sends Date, BigInt, Set, Map, and URL as JSON', async () => { + await expect(client.echo({ + date, + bigint: 12345678901234567890n, + set: new Set(['a', 'b']), + map: new Map([['a', 1]]), + url: new URL('https://orpc.dev/docs'), + nan: Number.NaN, + })).resolves.toEqual({ + date: date.toISOString(), + bigint: '12345678901234567890', + set: ['a', 'b'], + map: [['a', 1]], + url: 'https://orpc.dev/docs', + nan: null, + }) + }) + + it('receives undefined as null, except object properties which are omitted', async () => { + await expect(client.nothing()).resolves.toBe(null) + await expect(client.echo()).resolves.toBe(null) + await expect(client.echo([undefined])).resolves.toEqual([null]) + await expect(client.echo({ omitted: undefined })).resolves.toEqual({}) + }) + + it('passes the initial context to procedures', async () => { + await expect(client.whoami()).resolves.toBe('u_123') + }) + + it('supports custom serializer', async () => { + const person = new Person('Alice', 30) + + await expect(customClient.customSerializer(person)).resolves.toEqual({ + client: { + __person__: { + age: 30, + name: 'Alice', + }, + }, + server: { + __person__: { + age: 12, + name: 'server', + }, + }, + }) + }) + + it('propagates ORPC errors through JSON-RPC errors', async () => { + await expect(client.error()).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'Missing item', + data: { id: 'missing-item' }, + defined: false, + }) + }) + + it('propagates typesafe errors with the defined flag', async () => { + await expect(client.definedError()).rejects.toMatchObject({ + code: 'FORBIDDEN', + message: 'Access denied', + data: { reason: 'no-permission', at: date.toISOString() }, + defined: true, + }) + }) + + describe('with smart coercion plugins', () => { + const native = z.object({ + date: z.date(), + bigint: z.bigint(), + set: z.set(z.string()), + map: z.map(z.string(), z.number()), + }) + + const contract = { + native: oc.input(native).output(native), + nativeError: oc.errors({ CONFLICT: { data: z.object({ at: z.date() }) } }), + } + + const implementer = implement(contract) + const received = vi.fn() + + const nativeRouter = implementer.router({ + native: implementer.native.handler(({ input }) => { + received(input) + return input + }), + nativeError: implementer.nativeError.handler(({ errors }) => { + throw errors.CONFLICT({ data: { at: date } }) + }), + }) + + // native types are restored, so the client is not jsonified + const nativeClient = createClientServer(nativeRouter, { + handlerPlugins: [new SmartCoercionHandlerPlugin({ converters: [new ZodToJsonSchemaConverter()] })], + linkPlugins: [new SmartCoercionLinkPlugin(contract, { converters: [new ZodToJsonSchemaConverter()] })], + }) as unknown as RouterClient + + it('restores native types of inputs and outputs', async () => { + const value = { + date, + bigint: 12345678901234567890n, + set: new Set(['a', 'b']), + map: new Map([['a', 1], ['b', 2]]), + } + + await expect(nativeClient.native(value)).resolves.toEqual(value) + expect(received).toHaveBeenCalledWith(value) + }) + + it('restores native types of defined error data', async () => { + await expect(nativeClient.nativeError()).rejects.toMatchObject({ + code: 'CONFLICT', + defined: true, + data: { at: date }, + }) + }) + }) +}) diff --git a/tests/plugins/all-plugins.test.ts b/tests/plugins/all-plugins.test.ts index 0785c126f3..b08f467053 100644 --- a/tests/plugins/all-plugins.test.ts +++ b/tests/plugins/all-plugins.test.ts @@ -1,5 +1,6 @@ import type { RetryLinkPluginContext } from '@orpc/client/plugins' import type { OpenAPIDocument } from '@orpc/openapi' +import type { OpenRPCDocument } from '@orpc/openrpc' import type { RouterClient } from '@orpc/server' import type { RequestHeadersHandlerPluginContext, ResponseHeadersHandlerPluginContext } from '@orpc/server/plugins' import { createORPCClient } from '@orpc/client' @@ -20,6 +21,7 @@ import { HibernationHandlerPlugin } from '@orpc/hibernation' import { SmartCoercionHandlerPlugin, SmartCoercionLinkPlugin } from '@orpc/json-schema' import { BatchResponseCompressionHandlerPlugin, StaticFileHandlerPlugin, TmpFileUploadHandlerPlugin } from '@orpc/node' import { OpenAPIReferenceHandlerPlugin } from '@orpc/openapi/plugins' +import { OpenRPCDiscoveryHandlerPlugin } from '@orpc/openrpc/plugins' import { PinoHandlerPlugin } from '@orpc/pino' import { RateLimitHandlerPlugin } from '@orpc/ratelimit' import { implement } from '@orpc/server' @@ -67,6 +69,12 @@ const spec: OpenAPIDocument<'3.2.0'> = { info: { title: 'All Plugins', version: '1.0.0' }, } +const openrpcSpec: OpenRPCDocument = { + openrpc: '1.3.2', + info: { title: 'All Plugins', version: '1.0.0' }, + methods: [], +} + const implementer = implement(contract).$context() const router = implementer.router({ @@ -88,6 +96,7 @@ function createHandlerPlugins() { new HibernationHandlerPlugin(), new MethodOverrideHandlerPlugin(), new OpenAPIReferenceHandlerPlugin({ spec, docsPath: '/docs', specPath: '/spec.json' }), + new OpenRPCDiscoveryHandlerPlugin({ spec: openrpcSpec, specPath: '/openrpc.json' }), new PinoHandlerPlugin({ logger: pino({ level: 'silent' }) }), new RateLimitHandlerPlugin(), new RequestCompressionHandlerPlugin(), @@ -219,4 +228,16 @@ describe('all plugins on one handler and one link', () => { expect(response?.status).toEqual(200) await expect(response?.json()).resolves.toEqual(spec) }) + + it('serves the openrpc document behind the other routing plugins', async () => { + const { handler } = createClientServer() + + const { response } = await handler.handle(new Request('http://localhost/rpc/openrpc.json'), { + context: {}, + prefix: '/rpc', + }) + + expect(response?.status).toEqual(200) + await expect(response?.json()).resolves.toEqual(openrpcSpec) + }) }) From 41b08e8e66e3f2a407abafa03ef5cae61af64645 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 02:10:54 +0000 Subject: [PATCH 2/7] refactor(openrpc): simplify handler wiring, codec, and generator - Add StandardOpenRPCHandler, so the five adapters share the codec and envelope wiring - Work out once per procedure whether its input accepts undefined or {} for empty params - Share the result-or-error check between the envelope and isJsonRpcResponse - Name the protocol codes in COMMON_JSON_RPC_ERROR_CODE_MAP, and drop a dead fallback entry - Convert the output schema and read the openrpc() meta once per method - Drop the unused @standard-server/fetch and @orpc/zod dependencies Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ETTyGHAmagRW7GK4kevsX5 --- packages/openrpc/package.json | 4 +-- .../adapters/aws-lambda/openrpc-handler.ts | 14 +++------ .../src/adapters/fastify/openrpc-handler.ts | 14 +++------ .../src/adapters/fetch/openrpc-handler.ts | 14 +++------ .../src/adapters/node/openrpc-handler.ts | 14 +++------ .../src/adapters/standard/index.test.ts | 3 +- .../openrpc/src/adapters/standard/index.ts | 1 + .../openrpc-envelope-handler-plugin.ts | 16 ++++------ .../standard/openrpc-handler-codec.test.ts | 12 ++++++++ .../standard/openrpc-handler-codec.ts | 29 ++++++++++++++++--- .../src/adapters/standard/openrpc-handler.ts | 23 +++++++++++++++ .../src/adapters/websocket/openrpc-handler.ts | 19 +++++------- packages/openrpc/src/constants.ts | 12 ++++---- packages/openrpc/src/index.test.ts | 1 + packages/openrpc/src/json-rpc.test.ts | 15 +++++++++- packages/openrpc/src/json-rpc.ts | 14 ++++++--- packages/openrpc/src/openrpc-generator.ts | 25 ++++++++-------- packages/openrpc/src/types.ts | 7 ++--- pnpm-lock.yaml | 6 ---- 19 files changed, 140 insertions(+), 103 deletions(-) create mode 100644 packages/openrpc/src/adapters/standard/openrpc-handler.ts diff --git a/packages/openrpc/package.json b/packages/openrpc/package.json index 6568e48230..ce1e02d40f 100644 --- a/packages/openrpc/package.json +++ b/packages/openrpc/package.json @@ -96,11 +96,9 @@ "@orpc/openapi": "workspace:*", "@orpc/server": "workspace:*", "@orpc/shared": "workspace:*", - "@standard-server/core": "~0.13.0", - "@standard-server/fetch": "~0.13.0" + "@standard-server/core": "~0.13.0" }, "devDependencies": { - "@orpc/zod": "workspace:*", "@standard-server/aws-lambda": "~0.13.0", "fastify": "^5.12.7", "ws": "^8.22.0", diff --git a/packages/openrpc/src/adapters/aws-lambda/openrpc-handler.ts b/packages/openrpc/src/adapters/aws-lambda/openrpc-handler.ts index e1f1e84773..f8f8d338e2 100644 --- a/packages/openrpc/src/adapters/aws-lambda/openrpc-handler.ts +++ b/packages/openrpc/src/adapters/aws-lambda/openrpc-handler.ts @@ -1,14 +1,10 @@ import type { Context, Router } from '@orpc/server' import type { AwsLambdaHandlerOptions } from '@orpc/server/aws-lambda' -import type { StandardHandlerOptions } from '@orpc/server/standard' -import type { OpenRPCEnvelopeHandlerPluginOptions, OpenRPCHandlerCodecOptions } from '../standard' +import type { StandardOpenRPCHandlerOptions } from '../standard' import { AwsLambdaHandler } from '@orpc/server/aws-lambda' -import { StandardHandler } from '@orpc/server/standard' -import { toArray } from '@orpc/shared' -import { OpenRPCEnvelopeHandlerPlugin, OpenRPCHandlerCodec } from '../standard' +import { StandardOpenRPCHandler } from '../standard' -export interface OpenRPCHandlerOptions - extends AwsLambdaHandlerOptions, StandardHandlerOptions, OpenRPCHandlerCodecOptions, OpenRPCEnvelopeHandlerPluginOptions {} +export interface OpenRPCHandlerOptions extends AwsLambdaHandlerOptions, StandardOpenRPCHandlerOptions {} /** * Serves oRPC procedures as a JSON-RPC 2.0 API over HTTP POST using AWS Lambda. @@ -20,8 +16,6 @@ export interface OpenRPCHandlerOptions */ export class OpenRPCHandler extends AwsLambdaHandler { constructor(router: Router, options: NoInfer> = {}) { - const codec = new OpenRPCHandlerCodec(router, options) - const plugins = [...toArray(options.plugins), new OpenRPCEnvelopeHandlerPlugin(options)] - super(new StandardHandler(codec, { ...options, plugins }), options) + super(new StandardOpenRPCHandler(router, options), options) } } diff --git a/packages/openrpc/src/adapters/fastify/openrpc-handler.ts b/packages/openrpc/src/adapters/fastify/openrpc-handler.ts index 77efeaa7fa..04a470c440 100644 --- a/packages/openrpc/src/adapters/fastify/openrpc-handler.ts +++ b/packages/openrpc/src/adapters/fastify/openrpc-handler.ts @@ -1,14 +1,10 @@ import type { Context, Router } from '@orpc/server' import type { FastifyHandlerOptions } from '@orpc/server/fastify' -import type { StandardHandlerOptions } from '@orpc/server/standard' -import type { OpenRPCEnvelopeHandlerPluginOptions, OpenRPCHandlerCodecOptions } from '../standard' +import type { StandardOpenRPCHandlerOptions } from '../standard' import { FastifyHandler } from '@orpc/server/fastify' -import { StandardHandler } from '@orpc/server/standard' -import { toArray } from '@orpc/shared' -import { OpenRPCEnvelopeHandlerPlugin, OpenRPCHandlerCodec } from '../standard' +import { StandardOpenRPCHandler } from '../standard' -export interface OpenRPCHandlerOptions - extends FastifyHandlerOptions, StandardHandlerOptions, OpenRPCHandlerCodecOptions, OpenRPCEnvelopeHandlerPluginOptions {} +export interface OpenRPCHandlerOptions extends FastifyHandlerOptions, StandardOpenRPCHandlerOptions {} /** * Serves oRPC procedures as a JSON-RPC 2.0 API over HTTP POST using Fastify. @@ -17,8 +13,6 @@ export interface OpenRPCHandlerOptions */ export class OpenRPCHandler extends FastifyHandler { constructor(router: Router, options: NoInfer> = {}) { - const codec = new OpenRPCHandlerCodec(router, options) - const plugins = [...toArray(options.plugins), new OpenRPCEnvelopeHandlerPlugin(options)] - super(new StandardHandler(codec, { ...options, plugins }), options) + super(new StandardOpenRPCHandler(router, options), options) } } diff --git a/packages/openrpc/src/adapters/fetch/openrpc-handler.ts b/packages/openrpc/src/adapters/fetch/openrpc-handler.ts index 1d77fcb0d4..bd7153ce49 100644 --- a/packages/openrpc/src/adapters/fetch/openrpc-handler.ts +++ b/packages/openrpc/src/adapters/fetch/openrpc-handler.ts @@ -1,14 +1,10 @@ import type { Context, Router } from '@orpc/server' import type { FetchHandlerOptions } from '@orpc/server/fetch' -import type { StandardHandlerOptions } from '@orpc/server/standard' -import type { OpenRPCEnvelopeHandlerPluginOptions, OpenRPCHandlerCodecOptions } from '../standard' +import type { StandardOpenRPCHandlerOptions } from '../standard' import { FetchHandler } from '@orpc/server/fetch' -import { StandardHandler } from '@orpc/server/standard' -import { toArray } from '@orpc/shared' -import { OpenRPCEnvelopeHandlerPlugin, OpenRPCHandlerCodec } from '../standard' +import { StandardOpenRPCHandler } from '../standard' -export interface OpenRPCHandlerOptions - extends FetchHandlerOptions, StandardHandlerOptions, OpenRPCHandlerCodecOptions, OpenRPCEnvelopeHandlerPluginOptions {} +export interface OpenRPCHandlerOptions extends FetchHandlerOptions, StandardOpenRPCHandlerOptions {} /** * Serves oRPC procedures as a JSON-RPC 2.0 API over HTTP POST using Fetch API `Request`/`Response`. @@ -17,8 +13,6 @@ export interface OpenRPCHandlerOptions */ export class OpenRPCHandler extends FetchHandler { constructor(router: Router, options: NoInfer> = {}) { - const codec = new OpenRPCHandlerCodec(router, options) - const plugins = [...toArray(options.plugins), new OpenRPCEnvelopeHandlerPlugin(options)] - super(new StandardHandler(codec, { ...options, plugins }), options) + super(new StandardOpenRPCHandler(router, options), options) } } diff --git a/packages/openrpc/src/adapters/node/openrpc-handler.ts b/packages/openrpc/src/adapters/node/openrpc-handler.ts index f78a0a7542..b708ffd2fa 100644 --- a/packages/openrpc/src/adapters/node/openrpc-handler.ts +++ b/packages/openrpc/src/adapters/node/openrpc-handler.ts @@ -1,14 +1,10 @@ import type { Context, Router } from '@orpc/server' import type { NodeHttpHandlerOptions } from '@orpc/server/node' -import type { StandardHandlerOptions } from '@orpc/server/standard' -import type { OpenRPCEnvelopeHandlerPluginOptions, OpenRPCHandlerCodecOptions } from '../standard' +import type { StandardOpenRPCHandlerOptions } from '../standard' import { NodeHttpHandler } from '@orpc/server/node' -import { StandardHandler } from '@orpc/server/standard' -import { toArray } from '@orpc/shared' -import { OpenRPCEnvelopeHandlerPlugin, OpenRPCHandlerCodec } from '../standard' +import { StandardOpenRPCHandler } from '../standard' -export interface OpenRPCHandlerOptions - extends NodeHttpHandlerOptions, StandardHandlerOptions, OpenRPCHandlerCodecOptions, OpenRPCEnvelopeHandlerPluginOptions {} +export interface OpenRPCHandlerOptions extends NodeHttpHandlerOptions, StandardOpenRPCHandlerOptions {} /** * Serves oRPC procedures as a JSON-RPC 2.0 API over HTTP POST using Node.js built-in HTTP request/response. @@ -17,8 +13,6 @@ export interface OpenRPCHandlerOptions */ export class OpenRPCHandler extends NodeHttpHandler { constructor(router: Router, options: NoInfer> = {}) { - const codec = new OpenRPCHandlerCodec(router, options) - const plugins = [...toArray(options.plugins), new OpenRPCEnvelopeHandlerPlugin(options)] - super(new StandardHandler(codec, { ...options, plugins }), options) + super(new StandardOpenRPCHandler(router, options), options) } } diff --git a/packages/openrpc/src/adapters/standard/index.test.ts b/packages/openrpc/src/adapters/standard/index.test.ts index a028a3a479..7940f1a50e 100644 --- a/packages/openrpc/src/adapters/standard/index.test.ts +++ b/packages/openrpc/src/adapters/standard/index.test.ts @@ -1,5 +1,6 @@ -it('exports OpenRPCHandlerCodec, OpenRPCEnvelopeHandlerPlugin, OpenRPCLinkCodec, getJsonRpcCall', async () => { +it('exports StandardOpenRPCHandler, OpenRPCHandlerCodec, OpenRPCEnvelopeHandlerPlugin, OpenRPCLinkCodec, getJsonRpcCall', async () => { await expect(import('.')).resolves.toMatchObject({ + StandardOpenRPCHandler: expect.any(Function), OpenRPCHandlerCodec: expect.any(Function), OpenRPCEnvelopeHandlerPlugin: expect.any(Function), OpenRPCLinkCodec: expect.any(Function), diff --git a/packages/openrpc/src/adapters/standard/index.ts b/packages/openrpc/src/adapters/standard/index.ts index 2347a374d1..a86d9ec67d 100644 --- a/packages/openrpc/src/adapters/standard/index.ts +++ b/packages/openrpc/src/adapters/standard/index.ts @@ -1,4 +1,5 @@ export * from './openrpc-envelope-handler-plugin' +export * from './openrpc-handler' export * from './openrpc-handler-codec' export * from './openrpc-link-codec' export { getJsonRpcCall } from './utils' diff --git a/packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.ts b/packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.ts index dd86813eaa..043de575f5 100644 --- a/packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.ts +++ b/packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.ts @@ -12,7 +12,7 @@ import { DEFAULT_ERROR_STATUS } from '@orpc/server' import { getOwn, isPlainObject, matchesHttpPath, mergeHttpPath, pathToHttpPath, toArray, value } from '@orpc/shared' import { mergeStandardHeaders } from '@standard-server/core' import { DEFAULT_JSON_RPC_MAX_BATCH_SIZE, JSON_RPC_ERROR_CODES, JSON_RPC_VERSION } from '../../constants' -import { encodeJsonRpcError, isJsonRpcErrorObject, isJsonRpcId } from '../../json-rpc' +import { encodeJsonRpcError, hasJsonRpcResultOrError, isJsonRpcId } from '../../json-rpc' import { getJsonRpcCall, isJsonRpcMediaType, JSON_RPC_CALL_SYMBOL } from './utils' export interface OpenRPCEnvelopeHandlerPluginOptions @@ -155,7 +155,6 @@ export class OpenRPCEnvelopeHandlerPlugin implements Standard } const id = rawId as JsonRpcId | undefined - const call: JsonRpcCall = { method, id } const request: StandardLazyRequest & { [JSON_RPC_CALL_SYMBOL]: JsonRpcCall } = { method: 'POST', // only for logs and spans, the codec routes by the call @@ -163,7 +162,7 @@ export class OpenRPCEnvelopeHandlerPlugin implements Standard headers: { ...options.request.headers, 'content-length': undefined }, signal: options.request.signal, resolveBody: async () => params ?? undefined, - [JSON_RPC_CALL_SYMBOL]: call, + [JSON_RPC_CALL_SYMBOL]: { method, id }, } let result: StandardHandlerHandleResult @@ -194,13 +193,10 @@ export class OpenRPCEnvelopeHandlerPlugin implements Standard const body = result.response.body - if (isPlainObject(body) && Object.hasOwn(body, 'result') !== Object.hasOwn(body, 'error')) { - if (Object.hasOwn(body, 'result')) { - return { response: { jsonrpc: JSON_RPC_VERSION, id, result: body.result }, headers } - } - - if (isJsonRpcErrorObject(body.error)) { - return { response: { jsonrpc: JSON_RPC_VERSION, id, error: body.error }, headers } + if (hasJsonRpcResultOrError(body)) { + return { + response: 'result' in body ? { jsonrpc: JSON_RPC_VERSION, id, result: body.result } : { jsonrpc: JSON_RPC_VERSION, id, error: body.error }, + headers, } } diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler-codec.test.ts b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.test.ts index d27aba6af6..b1716554b8 100644 --- a/packages/openrpc/src/adapters/standard/openrpc-handler-codec.test.ts +++ b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.test.ts @@ -164,6 +164,18 @@ describe('openRPCHandlerCodec', () => { it('treats a schema that throws while probing as not accepting the value', async () => { await expect(decode('throwing', undefined)).resolves.toEqual({}) }) + + it('probes the input schemas once per procedure', async () => { + const validate = vi.fn(async (value: unknown) => value === undefined ? { issues: [{ message: 'required' }] } : { value }) + const codec = new OpenRPCHandlerCodec({ probed: os.input({ '~standard': { version: 1, vendor: 'test', validate } }).handler(vi.fn()) }) + + for (const params of [undefined, [], {}]) { + const result = await codec.resolveProcedure(createRequest('probed', params)) + await expect(result!.decodeInput()).resolves.toEqual({}) + } + + expect(validate).toHaveBeenCalledTimes(2) + }) }) }) }) diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts index 0cfe6bdb3c..5639390dbe 100644 --- a/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts +++ b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts @@ -62,10 +62,10 @@ export class OpenRPCHandlerCodec implements StandardHandlerCo async resolveProcedure(request: StandardLazyRequest): Promise { const method = getJsonRpcCall(request)?.method - const path = method?.split('.') + const path = method?.split('.') ?? [] // `rpc.*` names are reserved, and an empty segment would alias another path (`a.` -> `/a/` -> `/a`) - if (!method || method.startsWith('rpc.') || !path || path.includes('')) { + if (!method || method.startsWith('rpc.') || path.includes('')) { return undefined } @@ -129,9 +129,30 @@ async function decodeParams(params: unknown, procedure: AnyProcedure): Promise>() + +function acceptsEmptyInputs(procedure: AnyProcedure): Promise<{ undefined: boolean, object: boolean }> { + let accepts = emptyInputsAcceptance.get(procedure) + + if (!accepts) { + accepts = Promise.all([acceptsInput(procedure, undefined), acceptsInput(procedure, {})]) + .then(([acceptsUndefined, acceptsObject]) => ({ undefined: acceptsUndefined, object: acceptsObject })) + emptyInputsAcceptance.set(procedure, accepts) + } + + return accepts } async function acceptsInput(procedure: AnyProcedure, input: unknown): Promise { diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler.ts b/packages/openrpc/src/adapters/standard/openrpc-handler.ts new file mode 100644 index 0000000000..73192e52f8 --- /dev/null +++ b/packages/openrpc/src/adapters/standard/openrpc-handler.ts @@ -0,0 +1,23 @@ +import type { Context, Router } from '@orpc/server' +import type { StandardHandlerOptions } from '@orpc/server/standard' +import type { OpenRPCEnvelopeHandlerPluginOptions } from './openrpc-envelope-handler-plugin' +import type { OpenRPCHandlerCodecOptions } from './openrpc-handler-codec' +import { StandardHandler } from '@orpc/server/standard' +import { toArray } from '@orpc/shared' +import { OpenRPCEnvelopeHandlerPlugin } from './openrpc-envelope-handler-plugin' +import { OpenRPCHandlerCodec } from './openrpc-handler-codec' + +export interface StandardOpenRPCHandlerOptions + extends StandardHandlerOptions, OpenRPCHandlerCodecOptions, OpenRPCEnvelopeHandlerPluginOptions {} + +/** + * The `StandardHandler` every `OpenRPCHandler` adapter is built on: the OpenRPC codec, paired with the envelope plugin it needs. + */ +export class StandardOpenRPCHandler extends StandardHandler { + constructor(router: Router, options: NoInfer> = {}) { + super(new OpenRPCHandlerCodec(router, options), { + ...options, + plugins: [...toArray(options.plugins), new OpenRPCEnvelopeHandlerPlugin(options)], + }) + } +} diff --git a/packages/openrpc/src/adapters/websocket/openrpc-handler.ts b/packages/openrpc/src/adapters/websocket/openrpc-handler.ts index e1bd7ed97e..2e3008f7d3 100644 --- a/packages/openrpc/src/adapters/websocket/openrpc-handler.ts +++ b/packages/openrpc/src/adapters/websocket/openrpc-handler.ts @@ -1,15 +1,14 @@ import type { Context, Router } from '@orpc/server' -import type { FriendlyStandardHandlerHandleOptions, StandardHandlerOptions } from '@orpc/server/standard' +import type { FriendlyStandardHandlerHandleOptions } from '@orpc/server/standard' import type { WebSocketLike } from '@orpc/server/websocket' import type { Arrayable, MaybeOptionalOptions } from '@orpc/shared' -import type { OpenRPCEnvelopeHandlerPluginOptions, OpenRPCHandlerCodecOptions } from '../standard' -import { resolveFriendlyStandardHandlerHandleOptions, StandardHandler } from '@orpc/server/standard' -import { AbortError, loadBytes, resolveMaybeOptionalOptions, sequential, stringifyJSON, toArray, toStringOrBytes } from '@orpc/shared' -import { OpenRPCEnvelopeHandlerPlugin, OpenRPCHandlerCodec } from '../standard' +import type { StandardOpenRPCHandlerOptions } from '../standard' +import { resolveFriendlyStandardHandlerHandleOptions } from '@orpc/server/standard' +import { AbortError, loadBytes, resolveMaybeOptionalOptions, sequential, stringifyJSON, toStringOrBytes } from '@orpc/shared' +import { StandardOpenRPCHandler } from '../standard' import { parseJsonMessage } from './utils' -export interface OpenRPCHandlerOptions - extends StandardHandlerOptions, OpenRPCHandlerCodecOptions, OpenRPCEnvelopeHandlerPluginOptions {} +export interface OpenRPCHandlerOptions extends StandardOpenRPCHandlerOptions {} /** * Serves oRPC procedures as a JSON-RPC 2.0 API over a WebSocket connection: each message is a request or a batch, @@ -19,7 +18,7 @@ export interface OpenRPCHandlerOptions * @see {@link https://orpc.dev/docs/openrpc/handler#websocket | OpenRPC Handler - WebSocket} */ export class OpenRPCHandler { - private readonly handler: StandardHandler + private readonly handler: StandardOpenRPCHandler /** * Aborts the in-flight calls of a socket once it closes. @@ -27,9 +26,7 @@ export class OpenRPCHandler { private readonly controllers = new WeakMap() constructor(router: Router, options: NoInfer> = {}) { - const codec = new OpenRPCHandlerCodec(router, options) - const plugins = [...toArray(options.plugins), new OpenRPCEnvelopeHandlerPlugin(options)] - this.handler = new StandardHandler(codec, { ...options, plugins }) + this.handler = new StandardOpenRPCHandler(router, options) } /** diff --git a/packages/openrpc/src/constants.ts b/packages/openrpc/src/constants.ts index 67b1d4d636..b1ae38f76b 100644 --- a/packages/openrpc/src/constants.ts +++ b/packages/openrpc/src/constants.ts @@ -20,7 +20,7 @@ export const JSON_RPC_ERROR_CODES = { * @see {@link https://orpc.dev/docs/openrpc/handler#error-codes | OpenRPC Handler - Error Codes} */ export const COMMON_JSON_RPC_ERROR_CODE_MAP = { - BAD_REQUEST: -32602, + BAD_REQUEST: JSON_RPC_ERROR_CODES.INVALID_PARAMS, UNAUTHORIZED: -32001, PAYMENT_REQUIRED: -32002, FORBIDDEN: -32003, @@ -38,11 +38,11 @@ export const COMMON_JSON_RPC_ERROR_CODE_MAP = { PRECONDITION_REQUIRED: -32028, TOO_MANY_REQUESTS: -32029, CLIENT_CLOSED_REQUEST: -32099, - INTERNAL_SERVER_ERROR: -32603, - NOT_IMPLEMENTED: -32603, - BAD_GATEWAY: -32603, - SERVICE_UNAVAILABLE: -32603, - GATEWAY_TIMEOUT: -32603, + INTERNAL_SERVER_ERROR: JSON_RPC_ERROR_CODES.INTERNAL_ERROR, + NOT_IMPLEMENTED: JSON_RPC_ERROR_CODES.INTERNAL_ERROR, + BAD_GATEWAY: JSON_RPC_ERROR_CODES.INTERNAL_ERROR, + SERVICE_UNAVAILABLE: JSON_RPC_ERROR_CODES.INTERNAL_ERROR, + GATEWAY_TIMEOUT: JSON_RPC_ERROR_CODES.INTERNAL_ERROR, } satisfies Record /** diff --git a/packages/openrpc/src/index.test.ts b/packages/openrpc/src/index.test.ts index a810e22af3..67346b49ff 100644 --- a/packages/openrpc/src/index.test.ts +++ b/packages/openrpc/src/index.test.ts @@ -4,6 +4,7 @@ it('exports openrpc meta, OpenRPCGenerator, JSON-RPC helpers and constants', asy getOpenRPCMeta: expect.any(Function), OpenRPCGenerator: expect.any(Function), OpenRPCGeneratorError: expect.any(Function), + hasJsonRpcResultOrError: expect.any(Function), isJsonRpcResponse: expect.any(Function), encodeJsonRpcError: expect.any(Function), fromJsonRpcErrorCode: expect.any(Function), diff --git a/packages/openrpc/src/json-rpc.test.ts b/packages/openrpc/src/json-rpc.test.ts index b8038ec399..35707cede3 100644 --- a/packages/openrpc/src/json-rpc.test.ts +++ b/packages/openrpc/src/json-rpc.test.ts @@ -1,6 +1,6 @@ import { createORPCErrorFromJson, ORPCError } from '@orpc/client' import { OpenAPIJsonSerializer } from '@orpc/openapi' -import { encodeJsonRpcError, fromJsonRpcErrorCode, isJsonRpcErrorObject, isJsonRpcId, isJsonRpcResponse } from './json-rpc' +import { encodeJsonRpcError, fromJsonRpcErrorCode, hasJsonRpcResultOrError, isJsonRpcErrorObject, isJsonRpcId, isJsonRpcResponse } from './json-rpc' describe('isJsonRpcId', () => { it.each([ @@ -36,6 +36,19 @@ describe('isJsonRpcErrorObject', () => { }) }) +describe('hasJsonRpcResultOrError', () => { + it.each([ + [{ result: undefined }, true], + [{ error: { code: 1, message: 'x' } }, true], + [{}, false], + [{ result: 1, error: { code: 1, message: 'x' } }, false], + [{ error: { code: 1 } }, false], + [[1], false], + ])('hasJsonRpcResultOrError(%j) is %s', (value, expected) => { + expect(hasJsonRpcResultOrError(value)).toBe(expected) + }) +}) + describe('isJsonRpcResponse', () => { it.each([ [{ jsonrpc: '2.0', id: 1, result: null }, true], diff --git a/packages/openrpc/src/json-rpc.ts b/packages/openrpc/src/json-rpc.ts index e3b05e7087..66e165cebb 100644 --- a/packages/openrpc/src/json-rpc.ts +++ b/packages/openrpc/src/json-rpc.ts @@ -35,10 +35,10 @@ export function isJsonRpcErrorObject(value: unknown): value is JsonRpcErrorObjec } /** - * Whether a value is a JSON-RPC response: exactly one own `result` or valid `error` member, and a valid `id`. + * Whether a value has exactly one own `result` or valid `error` member, like a JSON-RPC response. */ -export function isJsonRpcResponse(value: unknown): value is JsonRpcResponse { - if (!isPlainObject(value) || getOwn(value, 'jsonrpc') !== JSON_RPC_VERSION || !isJsonRpcId(getOwn(value, 'id'))) { +export function hasJsonRpcResultOrError(value: unknown): value is { result: unknown } | { error: JsonRpcErrorObject } { + if (!isPlainObject(value)) { return false } @@ -47,6 +47,13 @@ export function isJsonRpcResponse(value: unknown): value is JsonRpcResponse { return hasResult !== Object.hasOwn(value, 'error') && (hasResult || isJsonRpcErrorObject(value.error)) } +/** + * Whether a value is a JSON-RPC response: exactly one own `result` or valid `error` member, and a valid `id`. + */ +export function isJsonRpcResponse(value: unknown): value is JsonRpcResponse { + return hasJsonRpcResultOrError(value) && getOwn(value, 'jsonrpc') === JSON_RPC_VERSION && isJsonRpcId(getOwn(value, 'id')) +} + /** * Encodes an ORPCError as a JSON-RPC error object, keeping its oRPC `code`, `defined` flag and `data` in `data`. */ @@ -66,7 +73,6 @@ const PROTOCOL_ERROR_ORPC_CODES = new Map([ [JSON_RPC_ERROR_CODES.PARSE_ERROR, 'BAD_REQUEST'], [JSON_RPC_ERROR_CODES.INVALID_REQUEST, 'BAD_REQUEST'], [JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND, 'NOT_FOUND'], - [JSON_RPC_ERROR_CODES.INTERNAL_ERROR, 'INTERNAL_SERVER_ERROR'], ]) /** diff --git a/packages/openrpc/src/openrpc-generator.ts b/packages/openrpc/src/openrpc-generator.ts index 498daea8c4..540734f1fe 100644 --- a/packages/openrpc/src/openrpc-generator.ts +++ b/packages/openrpc/src/openrpc-generator.ts @@ -2,6 +2,7 @@ import type { AnyProcedureContract, AnySchema, RouterContract } from '@orpc/cont import type { JsonObjectSchemaEntry, JsonSchema, JsonSchemaConverter, JsonSchemaConverterDirection } from '@orpc/json-schema' import type { AnyProcedure, AnyRouter } from '@orpc/server' import type { Public, Value } from '@orpc/shared' +import type { OpenRPCMeta } from './meta' import type { OpenRPCContentDescriptorObject, OpenRPCDocument, OpenRPCErrorObject, OpenRPCMethodObject } from './types' import { ORPCError } from '@orpc/client' import { @@ -10,6 +11,7 @@ import { DelegatingJsonSchemaConverter, extractJsonObjectSchemaEntries, hoistRecursiveRefToDef, + isJsonObjectSchema, isUnconstrainedSchema, resolveJsonSchemaRootLocalRef, StandardJsonSchemaConverter, @@ -85,6 +87,7 @@ export class OpenRPCGenerator { // OpenRPC requires component names to match ^[a-zA-Z0-9.\-_]+$ const registry = new OpenAPIComponentRegistry(doc, name => name.replace(/[^\w.-]/g, '_') || '_') const errors: string[] = [] + const errorCodeMap = options.errorCodeMap ?? COMMON_JSON_RPC_ERROR_CODE_MAP await walkProcedureContractsAsync(router, (contract, path) => { if (value(options.filter, contract, path) === false || isEventIteratorProcedure(contract)) { @@ -92,7 +95,7 @@ export class OpenRPCGenerator { } try { - doc.methods.push(this.buildMethod(registry, contract, path, options.errorCodeMap)) + doc.methods.push(this.buildMethod(registry, contract, path, errorCodeMap)) } catch (e) { if (!(e instanceof OpenRPCGeneratorError)) { @@ -114,7 +117,7 @@ export class OpenRPCGenerator { registry: OpenAPIComponentRegistry, contract: AnyProcedureContract, path: string[], - errorCodeMap: Record = COMMON_JSON_RPC_ERROR_CODE_MAP, + errorCodeMap: Record, ): OpenRPCMethodObject { const name = path.join('.') @@ -124,15 +127,16 @@ export class OpenRPCGenerator { ) } - const spec = getOpenRPCMeta(contract)?.spec - const method = typeof spec === 'object' ? spec : this.generateMethod(registry, contract, name, errorCodeMap) + const meta = getOpenRPCMeta(contract) + const method = typeof meta?.spec === 'object' ? meta.spec : this.generateMethod(registry, contract, meta, name, errorCodeMap) - return typeof spec === 'function' ? spec(method) : method + return typeof meta?.spec === 'function' ? meta.spec(method) : method } private generateMethod( registry: OpenAPIComponentRegistry, contract: AnyProcedureContract, + meta: OpenRPCMeta | undefined, name: string, errorCodeMap: Record, ): OpenRPCMethodObject { @@ -144,8 +148,8 @@ export class OpenRPCGenerator { throw new OpenRPCGeneratorError('File/Blob schemas cannot be sent over JSON-RPC.') } - const meta = getOpenRPCMeta(contract) const openapiMeta = getOpenAPIMeta(contract) + const resultSchema = registry.toOpenAPISchema(outputSchema, 'output') const method: OpenRPCMethodObject = { name, summary: meta?.summary ?? openapiMeta?.summary, @@ -157,9 +161,7 @@ export class OpenRPCGenerator { result: { name: 'result', // `undefined` is sent as `null` - schema: outputOptional && !isUnconstrainedSchema(outputSchema) - ? { anyOf: [registry.toOpenAPISchema(outputSchema, 'output'), { type: 'null' }] } - : registry.toOpenAPISchema(outputSchema, 'output'), + schema: outputOptional && !isUnconstrainedSchema(outputSchema) ? { anyOf: [resultSchema, { type: 'null' }] } : resultSchema, }, errors: buildErrors(def.errorMap, errorCodeMap), } @@ -224,8 +226,7 @@ function toByNameEntries(schema: JsonSchema): JsonObjectSchemaEntry[] | undefine const root = resolveJsonSchemaRootLocalRef(schema) if ( - typeof root !== 'object' - || root.type !== 'object' + !isJsonObjectSchema(root) || Object.keys(root).some(key => !BY_NAME_ROOT_KEYWORDS.has(key) && !key.startsWith('x-')) || !(root.additionalProperties === undefined || typeof root.additionalProperties === 'boolean' || isUnconstrainedSchema(root.additionalProperties)) || Object.hasOwn(root.properties ?? {}, '') @@ -241,7 +242,7 @@ function toContentDescriptor(registry: OpenAPIComponentRegistry, name: string, s name, description: typeof schema === 'object' ? schema.description : undefined, required: optional ? undefined : true, - schema: registry.toOpenAPISchema(schema, 'input') as JsonSchema, + schema: registry.toOpenAPISchema(schema, 'input'), } } diff --git a/packages/openrpc/src/types.ts b/packages/openrpc/src/types.ts index eb76bc91f4..a6da8ad97f 100644 --- a/packages/openrpc/src/types.ts +++ b/packages/openrpc/src/types.ts @@ -1,4 +1,5 @@ import type { JsonSchema } from '@orpc/json-schema' +import type { JsonRpcErrorObject } from './json-rpc' /** * `x-` specification extensions, allowed on most OpenRPC objects. @@ -29,11 +30,7 @@ export interface OpenRPCContentDescriptorObject extends OpenRPCExtensions { deprecated?: boolean | undefined } -export interface OpenRPCErrorObject { - code: number - message: string - data?: unknown -} +export type OpenRPCErrorObject = JsonRpcErrorObject export interface OpenRPCServerObject extends OpenRPCExtensions { url: string diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56c9fc416f..df0dbd9888 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -944,13 +944,7 @@ importers: '@standard-server/core': specifier: ~0.13.0 version: 0.13.0 - '@standard-server/fetch': - specifier: ~0.13.0 - version: 0.13.0 devDependencies: - '@orpc/zod': - specifier: workspace:* - version: link:../zod '@standard-server/aws-lambda': specifier: ~0.13.0 version: 0.13.0 From 5106de1da58e44b0a9426679cbb2d933f9772456 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:34:59 +0000 Subject: [PATCH 3/7] refactor(openrpc): generate link request ids with SequentialIdGenerator Request ids are now base-36 strings of a BigInt counter, like @standard-server/peer, so they never lose precision after Number.MAX_SAFE_INTEGER calls. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ETTyGHAmagRW7GK4kevsX5 --- apps/content/docs/openrpc/link.mdx | 2 +- .../src/adapters/fetch/openrpc-link.test.ts | 10 ++-- .../standard/openrpc-link-codec.test.ts | 56 ++++++++++--------- .../adapters/standard/openrpc-link-codec.ts | 12 ++-- .../adapters/websocket/openrpc-link.test.ts | 28 +++++----- 5 files changed, 57 insertions(+), 51 deletions(-) diff --git a/apps/content/docs/openrpc/link.mdx b/apps/content/docs/openrpc/link.mdx index 2c07fede3d..e13cf2892f 100644 --- a/apps/content/docs/openrpc/link.mdx +++ b/apps/content/docs/openrpc/link.mdx @@ -21,7 +21,7 @@ const link = new OpenRPCLink({ const client: JsonifiedClient> = createORPCClient(link) -const planet = await client.planet.find({ id: 1 }) // { "jsonrpc": "2.0", "id": 1, "method": "planet.find", "params": { "id": 1 } } +const planet = await client.planet.find({ id: 1 }) // { "jsonrpc": "2.0", "id": "1", "method": "planet.find", "params": { "id": 1 } } ``` An object input is sent as by-name `params`, any other input as `[input]`, and an `undefined` input without `params`. diff --git a/packages/openrpc/src/adapters/fetch/openrpc-link.test.ts b/packages/openrpc/src/adapters/fetch/openrpc-link.test.ts index 06abb88a77..3fd25022dc 100644 --- a/packages/openrpc/src/adapters/fetch/openrpc-link.test.ts +++ b/packages/openrpc/src/adapters/fetch/openrpc-link.test.ts @@ -77,7 +77,7 @@ describe('openRPCLink', () => { expect.objectContaining({ context: {} }), ['planet', 'find'], ) - expect(bodies).toEqual([{ jsonrpc: '2.0', id: 1, method: 'planet.find', params: { id: 1 } }]) + expect(bodies).toEqual([{ jsonrpc: '2.0', id: '1', method: 'planet.find', params: { id: 1 } }]) }) it('sends inputs by-name, by-position, or without params', async () => { @@ -87,10 +87,10 @@ describe('openRPCLink', () => { await client.planet.echo(undefined) expect(bodies).toEqual([ - { jsonrpc: '2.0', id: 1, method: 'planet.echo', params: { nested: { date: date.toISOString(), arr: [3, date.toISOString()] }, big: '1' } }, - { jsonrpc: '2.0', id: 2, method: 'planet.echo', params: ['text'] }, - { jsonrpc: '2.0', id: 3, method: 'planet.echo', params: [[1, 2]] }, - { jsonrpc: '2.0', id: 4, method: 'planet.echo' }, + { jsonrpc: '2.0', id: '1', method: 'planet.echo', params: { nested: { date: date.toISOString(), arr: [3, date.toISOString()] }, big: '1' } }, + { jsonrpc: '2.0', id: '2', method: 'planet.echo', params: ['text'] }, + { jsonrpc: '2.0', id: '3', method: 'planet.echo', params: [[1, 2]] }, + { jsonrpc: '2.0', id: '4', method: 'planet.echo' }, ]) }) diff --git a/packages/openrpc/src/adapters/standard/openrpc-link-codec.test.ts b/packages/openrpc/src/adapters/standard/openrpc-link-codec.test.ts index 989328470b..55607cc369 100644 --- a/packages/openrpc/src/adapters/standard/openrpc-link-codec.test.ts +++ b/packages/openrpc/src/adapters/standard/openrpc-link-codec.test.ts @@ -41,7 +41,7 @@ describe('openRPCLinkCodec', () => { method: 'POST', url: '/', headers: {}, - body: { jsonrpc: '2.0', id: 1, method: 'planet.find', params: { id: 1 } }, + body: { jsonrpc: '2.0', id: '1', method: 'planet.find', params: { id: 1 } }, signal: undefined, }) }) @@ -57,17 +57,19 @@ describe('openRPCLinkCodec', () => { expect(request.signal).toBe(signal) }) - it('increments the request id per codec', async () => { + it('generates sequential base-36 string ids per codec', async () => { const codec = new OpenRPCLinkCodec() const other = new OpenRPCLinkCodec() + const encodeId = async (codec: OpenRPCLinkCodec) => ((await codec.encodeInput('a', ['ping'], { context: {} })).body as { id: string }).id - const ids = [ - (await codec.encodeInput('a', ['ping'], { context: {} })).body, - (await codec.encodeInput('b', ['ping'], { context: {} })).body, - (await other.encodeInput('c', ['ping'], { context: {} })).body, - ].map(body => (body as { id: number }).id) + const ids: string[] = [] - expect(ids).toEqual([1, 2, 1]) + for (let i = 0; i < 36; i++) { + ids.push(await encodeId(codec)) + } + + expect([ids[0], ids[1], ids[9], ids[35]]).toEqual(['1', '2', 'a', '10']) + await expect(encodeId(other)).resolves.toBe('1') }) describe('params', () => { @@ -76,7 +78,7 @@ describe('openRPCLinkCodec', () => { const request = await codec.encodeInput({ id: 1, name: 'earth' }, ['ping'], { context: {} }) - expect(request.body).toEqual({ jsonrpc: '2.0', id: 1, method: 'ping', params: { id: 1, name: 'earth' } }) + expect(request.body).toEqual({ jsonrpc: '2.0', id: '1', method: 'ping', params: { id: 1, name: 'earth' } }) }) it.each([ @@ -89,7 +91,7 @@ describe('openRPCLinkCodec', () => { const request = await codec.encodeInput(input, ['ping'], { context: {} }) - expect(request.body).toEqual({ jsonrpc: '2.0', id: 1, method: 'ping', params }) + expect(request.body).toEqual({ jsonrpc: '2.0', id: '1', method: 'ping', params }) }) it('omits params for undefined input', async () => { @@ -97,7 +99,7 @@ describe('openRPCLinkCodec', () => { const request = await codec.encodeInput(undefined, ['ping'], { context: {} }) - expect(request.body).toEqual({ jsonrpc: '2.0', id: 1, method: 'ping' }) + expect(request.body).toEqual({ jsonrpc: '2.0', id: '1', method: 'ping' }) expect(request.body).not.toHaveProperty('params') }) @@ -113,7 +115,7 @@ describe('openRPCLinkCodec', () => { expect(request.body).toEqual({ jsonrpc: '2.0', - id: 1, + id: '1', method: 'ping', params: { date: '2024-01-02T03:04:05.000Z', @@ -138,7 +140,7 @@ describe('openRPCLinkCodec', () => { ) const request = await codec.encodeInput('next', ['ping'], { context: {} }) - expect(request.body).toMatchObject({ id: 1 }) + expect(request.body).toMatchObject({ id: '1' }) }) describe('option handling', () => { @@ -182,7 +184,7 @@ describe('openRPCLinkCodec', () => { describe('.decodeResponse', () => { /** - * Encodes a call first, so the codec knows the request id (`1`) the response must answer. + * Encodes a call first, so the codec knows the request id (`'1'`) the response must answer. */ async function decode(body: unknown, { status = 200, headers = {} }: { status?: number, headers?: Record } = {}) { const codec = new OpenRPCLinkCodec() @@ -198,7 +200,7 @@ describe('openRPCLinkCodec', () => { } it('returns the result as output', async () => { - const { result, resolveBody } = await decode({ jsonrpc: '2.0', id: 1, result: { ok: true } }) + const { result, resolveBody } = await decode({ jsonrpc: '2.0', id: '1', result: { ok: true } }) expect(result).toEqual({ kind: 'output', output: { ok: true } }) expect(resolveBody).toHaveBeenCalledOnce() @@ -208,7 +210,7 @@ describe('openRPCLinkCodec', () => { it('restores ORPCErrors of oRPC servers, including their defined flag', async () => { const body = { jsonrpc: '2.0', - id: 1, + id: '1', error: { code: -32004, message: 'Planet not found', data: { code: 'NOT_FOUND', defined: true, data: { id: 2 } } }, } @@ -222,7 +224,7 @@ describe('openRPCLinkCodec', () => { }) it('maps errors of other servers by their JSON-RPC code', async () => { - const { result } = await decode({ jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found', data: '0x' } }) + const { result } = await decode({ jsonrpc: '2.0', id: '1', error: { code: -32601, message: 'Method not found', data: '0x' } }) expectORPCErrorResult(result, 'NOT_FOUND', { defined: false, message: 'Method not found', data: '0x' }) }) @@ -230,7 +232,7 @@ describe('openRPCLinkCodec', () => { it('keeps data of other servers that only partly looks like an oRPC error', async () => { const data = { code: 'NOT_FOUND', data: { id: 2 } } - const { result } = await decode({ jsonrpc: '2.0', id: 1, error: { code: -32603, message: 'Failed', data } }) + const { result } = await decode({ jsonrpc: '2.0', id: '1', error: { code: -32603, message: 'Failed', data } }) expectORPCErrorResult(result, 'INTERNAL_SERVER_ERROR', { defined: false, data }) }) @@ -241,17 +243,17 @@ describe('openRPCLinkCodec', () => { ['the real error status', 503, { code: 'NOT_FOUND', defined: false }, 503], ['400 for an unknown code', 200, { code: 'OUT_OF_STOCK', defined: true }, 400], ])('gives the error cause %s', async (_, status, data, expected) => { - const { result } = await decode({ jsonrpc: '2.0', id: 1, error: { code: -32000, message: 'Failed', data } }, { status }) as { result: any } + const { result } = await decode({ jsonrpc: '2.0', id: '1', error: { code: -32000, message: 'Failed', data } }, { status }) as { result: any } expect(result.error.cause.response.status).toBe(expected) }) describe('request ids', () => { it.each([ - ['another id', { jsonrpc: '2.0', id: 2, result: 1 }], - ['the id as a string', { jsonrpc: '2.0', id: '1', result: 1 }], + ['another id', { jsonrpc: '2.0', id: '2', result: 1 }], + ['the id as a number', { jsonrpc: '2.0', id: 1, result: 1 }], ['a null id', { jsonrpc: '2.0', id: null, result: 1 }], - ['another id on an error', { jsonrpc: '2.0', id: 2, error: { code: -32603, message: 'Failed' } }], + ['another id on an error', { jsonrpc: '2.0', id: '2', error: { code: -32603, message: 'Failed' } }], ])('rejects a response with %s', async (_, body) => { await expect(decode(body)).rejects.toThrow(new MalformedResponseError({ message: 'Invalid JSON-RPC response.', @@ -273,16 +275,16 @@ describe('openRPCLinkCodec', () => { await codec.encodeInput('a', ['ping'], first) await codec.encodeInput('b', ['ping'], second) - const respond = (id: number) => ({ status: 200, headers: {}, resolveBody: async () => ({ jsonrpc: '2.0', id, result: id }) }) + const respond = (id: string) => ({ status: 200, headers: {}, resolveBody: async () => ({ jsonrpc: '2.0', id, result: id }) }) - await expect(codec.decodeResponse(respond(2), ['ping'], second)).resolves.toEqual({ kind: 'output', output: 2 }) - await expect(codec.decodeResponse(respond(1), ['ping'], first)).resolves.toEqual({ kind: 'output', output: 1 }) - await expect(codec.decodeResponse(respond(2), ['ping'], first)).rejects.toThrow('Invalid JSON-RPC response.') + await expect(codec.decodeResponse(respond('2'), ['ping'], second)).resolves.toEqual({ kind: 'output', output: '2' }) + await expect(codec.decodeResponse(respond('1'), ['ping'], first)).resolves.toEqual({ kind: 'output', output: '1' }) + await expect(codec.decodeResponse(respond('2'), ['ping'], first)).rejects.toThrow('Invalid JSON-RPC response.') }) }) it.each([ - ['a batch', [{ jsonrpc: '2.0', id: 1, result: 1 }]], + ['a batch', [{ jsonrpc: '2.0', id: '1', result: 1 }]], ['a string', ''], ])('throws MalformedResponseError on %s', async (_, body) => { const error: any = await decode(body, { status: 502 }).then(() => null, e => e) diff --git a/packages/openrpc/src/adapters/standard/openrpc-link-codec.ts b/packages/openrpc/src/adapters/standard/openrpc-link-codec.ts index 04a07602ff..e70e4f6ccb 100644 --- a/packages/openrpc/src/adapters/standard/openrpc-link-codec.ts +++ b/packages/openrpc/src/adapters/standard/openrpc-link-codec.ts @@ -6,7 +6,7 @@ import type { JsonRpcRequest } from '../../json-rpc' import { COMMON_ERROR_STATUS_MAP, createORPCErrorFromJson, MalformedResponseError, ResponseError } from '@orpc/client' import { OpenAPIJsonSerializer } from '@orpc/openapi' import { DEFAULT_ERROR_STATUS } from '@orpc/server' -import { getOwn, isAsyncIteratorObject, isPlainObject, value } from '@orpc/shared' +import { getOwn, isAsyncIteratorObject, isPlainObject, SequentialIdGenerator, value } from '@orpc/shared' import { JSON_RPC_VERSION } from '../../constants' import { fromJsonRpcErrorCode, isJsonRpcResponse } from '../../json-rpc' @@ -43,8 +43,12 @@ export class OpenRPCLinkCodec implements StandardLinkCo /** * The request id of each call. `StandardLink` passes the same `options` object to `encodeInput` and `decodeResponse`. */ - private readonly ids = new WeakMap, number>() - private lastId = 0 + private readonly ids = new WeakMap, string>() + + /** + * Base-36 strings of a `BigInt` counter, like `@standard-server/peer`, so ids never lose precision. + */ + private readonly idGenerator = new SequentialIdGenerator() constructor(options: OpenRPCLinkCodecOptions = {}) { this.url = options.url ?? '/' @@ -59,7 +63,7 @@ export class OpenRPCLinkCodec implements StandardLinkCo throw new TypeError(`[OpenRPCLink] JSON-RPC cannot send streams or binary data in call to procedure (${path.join('.')}).`) } - const id = ++this.lastId + const id = this.idGenerator.generate() this.ids.set(options, id) const body: JsonRpcRequest = { jsonrpc: JSON_RPC_VERSION, id, method: path.join('.') } diff --git a/packages/openrpc/src/adapters/websocket/openrpc-link.test.ts b/packages/openrpc/src/adapters/websocket/openrpc-link.test.ts index 2f6ad264b8..635bc2bb8c 100644 --- a/packages/openrpc/src/adapters/websocket/openrpc-link.test.ts +++ b/packages/openrpc/src/adapters/websocket/openrpc-link.test.ts @@ -49,7 +49,7 @@ describe('openRPCLink', () => { } const getSentRequest = (ws: ReturnType) => JSON.parse(ws.send.mock.calls[0]![0]) - const result = (id: number, value: unknown = 'pong') => ({ jsonrpc: '2.0', id, result: value }) + const result = (id: string, value: unknown = 'pong') => ({ jsonrpc: '2.0', id, result: value }) const respond = (ws: ReturnType, body: unknown) => ws.receive(JSON.stringify(body)) it.each([ @@ -63,9 +63,9 @@ describe('openRPCLink', () => { const promise = orpc.planet.find({ id: 1 }) await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(1)) - expect(getSentRequest(ws)).toEqual({ jsonrpc: '2.0', id: 1, method: 'planet.find', params: { id: 1 } }) + expect(getSentRequest(ws)).toEqual({ jsonrpc: '2.0', id: '1', method: 'planet.find', params: { id: 1 } }) - await ws.receive(toData(JSON.stringify(result(1)))) + await ws.receive(toData(JSON.stringify(result('1')))) await expect(promise).resolves.toBe('pong') }) @@ -78,12 +78,12 @@ describe('openRPCLink', () => { const first = orpc.ping('first') await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(1)) - await respond(ws, result(1, 'pong-1')) + await respond(ws, result('1', 'pong-1')) await expect(first).resolves.toBe('pong-1') const second = orpc.ping('second') await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(2)) - await respond(ws, result(2, 'pong-2')) + await respond(ws, result('2', 'pong-2')) await expect(second).resolves.toBe('pong-2') expect(connect).toHaveBeenCalledTimes(1) @@ -101,7 +101,7 @@ describe('openRPCLink', () => { await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(2)) - for (const message of toMessages([result(2, 'pong-2'), result(1, 'pong-1')])) { + for (const message of toMessages([result('2', 'pong-2'), result('1', 'pong-1')])) { await respond(ws, message) } @@ -117,9 +117,9 @@ describe('openRPCLink', () => { await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(1)) await ws.receive('not json') - await respond(ws, result(999, 'unknown')) + await respond(ws, result('999', 'unknown')) await respond(ws, [null, 'text', { jsonrpc: '2.0', result: 'no id' }]) - await respond(ws, result(1)) + await respond(ws, result('1')) await expect(promise).resolves.toBe('pong') }) @@ -135,7 +135,7 @@ describe('openRPCLink', () => { ws.open() await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(1)) - await respond(ws, result(1)) + await respond(ws, result('1')) await expect(promise).resolves.toBe('pong') }) @@ -165,7 +165,7 @@ describe('openRPCLink', () => { const promise = orpc.ping('second') await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(1)) - await respond(ws, result(2)) + await respond(ws, result('2')) await expect(promise).resolves.toBe('pong') expect(connect).toHaveBeenCalledTimes(2) @@ -185,7 +185,7 @@ describe('openRPCLink', () => { const promise = orpc.ping('second') await vi.waitFor(() => expect(secondWs.send).toHaveBeenCalledTimes(1)) - await respond(secondWs, result(2)) + await respond(secondWs, result('2')) await expect(promise).resolves.toBe('pong') expect(connect).toHaveBeenCalledTimes(2) @@ -224,8 +224,8 @@ describe('openRPCLink', () => { const promise = orpc.ping('next') await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(2)) - await respond(ws, result(1, 'late')) - await respond(ws, result(2, 'next')) + await respond(ws, result('1', 'late')) + await respond(ws, result('2', 'next')) await expect(promise).resolves.toBe('next') }) @@ -283,7 +283,7 @@ describe('openRPCLink', () => { const promise = orpc.ping('input') await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(1)) - await respond(ws, result(1)) + await respond(ws, result('1')) await expect(promise).resolves.toBe('pong') expect(serializeJson).toHaveBeenCalledWith('input') From 276c48c9c0f9407a819b45cabddbf8ba0f9dc22f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 02:42:37 +0000 Subject: [PATCH 4/7] refactor(openrpc): handle the JSON-RPC wire in StandardOpenRPCHandler, not a plugin Routing interceptors now run once per HTTP request or WebSocket message, and each call still runs through StandardHandler as its own sub-request, with its own tracing span, interceptors, and client interceptors. This replaces OpenRPCEnvelopeHandlerPlugin and its hard-coded list of plugin names, so plugins without ordering hints, and the routingInterceptors option, no longer run once per call. - A routing interceptor can answer or reject the whole request - Discovery is the handler's discovery option instead of OpenRPCDiscoveryHandlerPlugin - WebSocket messages go through handleMessage instead of a synthetic POST request, which also answers plugin errors while reading a message Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ETTyGHAmagRW7GK4kevsX5 --- apps/content/docs/openrpc/handler.mdx | 4 +- apps/content/docs/openrpc/specification.mdx | 16 +- packages/openrpc/package.json | 6 - .../src/adapters/standard/index.test.ts | 4 +- .../openrpc/src/adapters/standard/index.ts | 2 - .../openrpc-envelope-handler-plugin.ts | 219 ------------ .../standard/openrpc-handler-codec.ts | 5 +- .../openrpc-handler-discovery.test.ts | 161 +++++++++ ...plugin.test.ts => openrpc-handler.test.ts} | 98 ++++-- .../src/adapters/standard/openrpc-handler.ts | 315 +++++++++++++++++- .../openrpc/src/adapters/standard/utils.ts | 4 +- .../websocket/openrpc-handler.test.ts | 31 +- .../src/adapters/websocket/openrpc-handler.ts | 13 +- packages/openrpc/src/plugins/index.test.ts | 5 - packages/openrpc/src/plugins/index.ts | 1 - .../src/plugins/openrpc-discovery.test.ts | 194 ----------- .../openrpc/src/plugins/openrpc-discovery.ts | 68 ---- tests/openrpc/plugins.test.ts | 144 ++++++++ tests/plugins/all-plugins.test.ts | 21 -- 19 files changed, 725 insertions(+), 586 deletions(-) delete mode 100644 packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.ts create mode 100644 packages/openrpc/src/adapters/standard/openrpc-handler-discovery.test.ts rename packages/openrpc/src/adapters/standard/{openrpc-envelope-handler-plugin.test.ts => openrpc-handler.test.ts} (83%) delete mode 100644 packages/openrpc/src/plugins/index.test.ts delete mode 100644 packages/openrpc/src/plugins/index.ts delete mode 100644 packages/openrpc/src/plugins/openrpc-discovery.test.ts delete mode 100644 packages/openrpc/src/plugins/openrpc-discovery.ts create mode 100644 tests/openrpc/plugins.test.ts diff --git a/apps/content/docs/openrpc/handler.mdx b/apps/content/docs/openrpc/handler.mdx index e6a658fd6e..ef622a7edf 100644 --- a/apps/content/docs/openrpc/handler.mdx +++ b/apps/content/docs/openrpc/handler.mdx @@ -131,7 +131,7 @@ const ws = crossws({ ## Interceptors -[Interceptors](/docs/rpc/handler#interceptors) and plugins run once per JSON-RPC call, and see a sub-request per call. Use `getJsonRpcCall` to read the call: +Each JSON-RPC call runs as its own sub-request: [interceptors](/docs/rpc/handler#interceptors) and client interceptors, including those of plugins, run once per call, and each call gets its own tracing span. Use `getJsonRpcCall` to read the call: ```ts import { getJsonRpcCall } from '@orpc/openrpc/standard' @@ -146,4 +146,4 @@ const handler = new OpenRPCHandler(router, { }) ``` -Plugins that work on the HTTP request itself, such as [CORS](/docs/plugins/cors), [Request Limit](/docs/plugins/request-limit), and [Response Headers](/docs/plugins/response-headers), run once per HTTP request. The response headers of the calls in a batch are merged. The [Batch Plugin](/docs/plugins/batch) is not needed, since JSON-RPC batches requests natively. +Routing interceptors run once per HTTP request (or WebSocket message), around all the calls of a batch, and can answer the whole request, such as to require authentication. Plugins built on them apply to the HTTP request itself, such as [CORS](/docs/plugins/cors), [Request Limit](/docs/plugins/request-limit), and [Response Headers](/docs/plugins/response-headers), and logging plugins such as [Pino](/docs/integrations/pino) log once per HTTP request. The response headers of the calls in a batch are merged. The [Batch Plugin](/docs/plugins/batch) is not needed, since JSON-RPC batches requests natively. diff --git a/apps/content/docs/openrpc/specification.mdx b/apps/content/docs/openrpc/specification.mdx index d2a55493b5..f6810371dd 100644 --- a/apps/content/docs/openrpc/specification.mdx +++ b/apps/content/docs/openrpc/specification.mdx @@ -70,19 +70,17 @@ When `openrpc` is applied multiple times, fields are overridden by the most rece ## Service Discovery -`OpenRPCDiscoveryHandlerPlugin` answers the `rpc.discover` method with the document, as the [OpenRPC Service Discovery](https://spec.open-rpc.org/#service-discovery-method) method, and also serves it over `GET` at `/openrpc.json`, relative to the prefix: +The `discovery` option of [OpenRPC Handler](/docs/openrpc/handler) answers the `rpc.discover` method with the document, as the [OpenRPC Service Discovery](https://spec.open-rpc.org/#service-discovery-method) method, and also serves it over `GET` at `/openrpc.json`, relative to the prefix: ```ts -import { OpenRPCDiscoveryHandlerPlugin } from '@orpc/openrpc/plugins' - const handler = new OpenRPCHandler(router, { - plugins: [ - new OpenRPCDiscoveryHandlerPlugin({ - spec: () => generator.generate(router), // memoize it for production - specPath: '/openrpc.json', // or false to disable the GET endpoint - }), - ], + discovery: { + spec: () => generator.generate(router), // memoize it for production + specPath: '/openrpc.json', // or false to disable the GET endpoint + }, }) ``` +Throw an `ORPCError` from `spec` to refuse the document, such as to callers who are not signed in. + Tools such as the [OpenRPC Playground](https://playground.open-rpc.org) can then load the document from `http://localhost:3000/rpc/openrpc.json`. diff --git a/packages/openrpc/package.json b/packages/openrpc/package.json index ce1e02d40f..8b02ff1034 100644 --- a/packages/openrpc/package.json +++ b/packages/openrpc/package.json @@ -34,11 +34,6 @@ "import": "./dist/index.mjs", "default": "./dist/index.mjs" }, - "./plugins": { - "types": "./dist/plugins/index.d.mts", - "import": "./dist/plugins/index.mjs", - "default": "./dist/plugins/index.mjs" - }, "./standard": { "types": "./dist/adapters/standard/index.d.mts", "import": "./dist/adapters/standard/index.mjs", @@ -74,7 +69,6 @@ "exports": { "./package.json": "./package.json", ".": "./src/index.ts", - "./plugins": "./src/plugins/index.ts", "./standard": "./src/adapters/standard/index.ts", "./fetch": "./src/adapters/fetch/index.ts", "./node": "./src/adapters/node/index.ts", diff --git a/packages/openrpc/src/adapters/standard/index.test.ts b/packages/openrpc/src/adapters/standard/index.test.ts index 7940f1a50e..36dffcca02 100644 --- a/packages/openrpc/src/adapters/standard/index.test.ts +++ b/packages/openrpc/src/adapters/standard/index.test.ts @@ -1,8 +1,6 @@ -it('exports StandardOpenRPCHandler, OpenRPCHandlerCodec, OpenRPCEnvelopeHandlerPlugin, OpenRPCLinkCodec, getJsonRpcCall', async () => { +it('exports StandardOpenRPCHandler, OpenRPCLinkCodec, getJsonRpcCall', async () => { await expect(import('.')).resolves.toMatchObject({ StandardOpenRPCHandler: expect.any(Function), - OpenRPCHandlerCodec: expect.any(Function), - OpenRPCEnvelopeHandlerPlugin: expect.any(Function), OpenRPCLinkCodec: expect.any(Function), getJsonRpcCall: expect.any(Function), }) diff --git a/packages/openrpc/src/adapters/standard/index.ts b/packages/openrpc/src/adapters/standard/index.ts index a86d9ec67d..179f4fc3a5 100644 --- a/packages/openrpc/src/adapters/standard/index.ts +++ b/packages/openrpc/src/adapters/standard/index.ts @@ -1,6 +1,4 @@ -export * from './openrpc-envelope-handler-plugin' export * from './openrpc-handler' -export * from './openrpc-handler-codec' export * from './openrpc-link-codec' export { getJsonRpcCall } from './utils' export type { JsonRpcCall } from './utils' diff --git a/packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.ts b/packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.ts deleted file mode 100644 index 043de575f5..0000000000 --- a/packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { AnyORPCError } from '@orpc/client' -import type { Context } from '@orpc/server' -import type { StandardHandlerHandleResult, StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor, StandardHandlerRoutingInterceptorOptions } from '@orpc/server/standard' -import type { Promisable, Value } from '@orpc/shared' -import type { StandardHeaders, StandardLazyRequest } from '@standard-server/core' -import type { JsonRpcId, JsonRpcResponse } from '../../json-rpc' -import type { OpenRPCHandlerCodecOptions } from './openrpc-handler-codec' -import type { JsonRpcCall } from './utils' -import { COMMON_ERROR_STATUS_MAP, ORPCError } from '@orpc/client' -import { OpenAPIJsonSerializer } from '@orpc/openapi' -import { DEFAULT_ERROR_STATUS } from '@orpc/server' -import { getOwn, isPlainObject, matchesHttpPath, mergeHttpPath, pathToHttpPath, toArray, value } from '@orpc/shared' -import { mergeStandardHeaders } from '@standard-server/core' -import { DEFAULT_JSON_RPC_MAX_BATCH_SIZE, JSON_RPC_ERROR_CODES, JSON_RPC_VERSION } from '../../constants' -import { encodeJsonRpcError, hasJsonRpcResultOrError, isJsonRpcId } from '../../json-rpc' -import { getJsonRpcCall, isJsonRpcMediaType, JSON_RPC_CALL_SYMBOL } from './utils' - -export interface OpenRPCEnvelopeHandlerPluginOptions - extends Pick, 'serializer' | 'errorCodeMap'> { - /** - * The max number of requests in a batch. Larger batches are rejected with `413`. - * - * @default DEFAULT_JSON_RPC_MAX_BATCH_SIZE (100) - * @see {@link https://orpc.dev/docs/openrpc/handler#batch-requests-and-notifications | OpenRPC Handler - Batch Requests and Notifications} - */ - maxBatchSize?: Value, [options: StandardHandlerRoutingInterceptorOptions]> | undefined -} - -const PROTOCOL_ERROR_MESSAGES = { - [JSON_RPC_ERROR_CODES.PARSE_ERROR]: 'Parse error', - [JSON_RPC_ERROR_CODES.INVALID_REQUEST]: 'Invalid Request', - [JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND]: 'Method not found', - [JSON_RPC_ERROR_CODES.INTERNAL_ERROR]: 'Internal error', -} - -function protocolError(id: JsonRpcId, code: keyof typeof PROTOCOL_ERROR_MESSAGES): JsonRpcResponse { - return { jsonrpc: JSON_RPC_VERSION, id, error: { code, message: PROTOCOL_ERROR_MESSAGES[code] } } -} - -function reply(body: JsonRpcResponse | JsonRpcResponse[] | undefined, headers: StandardHeaders = {}): StandardHandlerHandleResult { - return { matched: true, response: { status: body === undefined ? 204 : 200, headers, body } } -} - -/** - * Reads the JSON-RPC 2.0 requests (single or batch) of an HTTP POST request and runs each through - * the rest of the handler as its own sub-request, then answers with the JSON-RPC response(s). - * Other requests (not POST, or another path than the prefix) pass through untouched. - * - * `OpenRPCHandler` adds it automatically. Plugins and interceptors inside it, such as tracing, - * see one sub-request per call, see `getJsonRpcCall`. - */ -export class OpenRPCEnvelopeHandlerPlugin implements StandardHandlerPlugin { - name = '~openrpc' - - /** - * Wrap the tracing interceptor so each call gets its own span. - */ - after = ['~tracing'] - - /** - * These must apply to the real HTTP request and response, not to each call. - */ - before = ['~cors', '~request-limit', '~request-compression', '~response-compression', '~response-headers', '~tmp-file-upload'] - - private readonly maxBatchSize: Exclude['maxBatchSize'], undefined> - private readonly serializer: Exclude['serializer'], undefined> - private readonly errorCodeMap: OpenRPCEnvelopeHandlerPluginOptions['errorCodeMap'] - - constructor(options: OpenRPCEnvelopeHandlerPluginOptions = {}) { - this.maxBatchSize = options.maxBatchSize ?? DEFAULT_JSON_RPC_MAX_BATCH_SIZE - this.serializer = options.serializer ?? new OpenAPIJsonSerializer() - this.errorCodeMap = options.errorCodeMap - } - - init(options: StandardHandlerOptions): StandardHandlerOptions { - const routingInterceptor: StandardHandlerRoutingInterceptor = async (interceptorOptions) => { - const { request, prefix } = interceptorOptions - - if ( - request.method !== 'POST' - || (prefix !== undefined && !matchesHttpPath(request.url, prefix)) - || getJsonRpcCall(request) !== undefined - ) { - return interceptorOptions.next() - } - - if (!isJsonRpcMediaType(request.headers['content-type'])) { - return this.reject(new ORPCError('UNSUPPORTED_MEDIA_TYPE', { message: 'JSON-RPC requests must use Content-Type: application/json' })) - } - - let payload: unknown - - try { - payload = await request.resolveBody('json') - } - catch (error) { - // errors of outer plugins, such as the request limit plugin - if (error instanceof ORPCError) { - return this.reject(error) - } - } - - if (payload === undefined) { - return reply(protocolError(null, JSON_RPC_ERROR_CODES.PARSE_ERROR)) - } - - if (!Array.isArray(payload)) { - const { response, headers } = await this.call(interceptorOptions, payload, false) - return reply(response, headers) - } - - if (payload.length === 0) { - return reply(protocolError(null, JSON_RPC_ERROR_CODES.INVALID_REQUEST)) - } - - const maxBatchSize = await value(this.maxBatchSize, interceptorOptions) - - if (payload.length > maxBatchSize) { - return this.reject(new ORPCError('PAYLOAD_TOO_LARGE', { message: `Batch size exceeds the maximum of ${maxBatchSize}.` })) - } - - const results = await Promise.all(payload.map(item => this.call(interceptorOptions, item, true))) - const responses = results.flatMap(({ response }) => response ? [response] : []) - - // a batch of only notifications is answered with nothing, never an empty array - return reply(responses.length ? responses : undefined, results.reduce((merged, { headers }) => mergeStandardHeaders(merged, headers), {})) - } - - return { - ...options, - routingInterceptors: [routingInterceptor, ...toArray(options.routingInterceptors)], - } - } - - /** - * Runs one call as a sub-request whose body is its `params`. Notifications run to completion too, but are never answered. - */ - private async call( - options: Parameters>[0], - item: unknown, - batch: boolean, - ): Promise<{ response?: JsonRpcResponse, headers: StandardHeaders }> { - const { jsonrpc, method, params, id: rawId } = isPlainObject(item) - ? { jsonrpc: getOwn(item, 'jsonrpc'), method: getOwn(item, 'method'), params: getOwn(item, 'params'), id: getOwn(item, 'id') } - : {} as Record - - if ( - jsonrpc !== JSON_RPC_VERSION - || typeof method !== 'string' - || (rawId !== undefined && !isJsonRpcId(rawId)) - || (params !== undefined && params !== null && !Array.isArray(params) && !isPlainObject(params)) - ) { - // answered even without an `id`, with the `id` when it is valid - return { response: protocolError(isJsonRpcId(rawId) ? rawId : null, JSON_RPC_ERROR_CODES.INVALID_REQUEST), headers: {} } - } - - const id = rawId as JsonRpcId | undefined - const request: StandardLazyRequest & { [JSON_RPC_CALL_SYMBOL]: JsonRpcCall } = { - method: 'POST', - // only for logs and spans, the codec routes by the call - url: mergeHttpPath(options.prefix ?? '/', pathToHttpPath(method.split('.'))), - headers: { ...options.request.headers, 'content-length': undefined }, - signal: options.request.signal, - resolveBody: async () => params ?? undefined, - [JSON_RPC_CALL_SYMBOL]: { method, id }, - } - - let result: StandardHandlerHandleResult - - try { - result = await options.next({ ...options, request }) - } - catch (error) { - if (!batch) { - throw error - } - - // like the batch plugin, report it as an unhandled rejection and answer the other calls - Promise.reject(error) - result = { matched: true, response: { status: 500, headers: {}, body: undefined } } - } - - const headers = Object.fromEntries(Object.entries(result.response?.headers ?? {}) - .filter(([name]) => !name.startsWith('content-') && name !== 'standard-server')) - - if (id === undefined) { - return { headers } - } - - if (!result.matched) { - return { response: protocolError(id, JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND), headers } - } - - const body = result.response.body - - if (hasJsonRpcResultOrError(body)) { - return { - response: 'result' in body ? { jsonrpc: JSON_RPC_VERSION, id, result: body.result } : { jsonrpc: JSON_RPC_VERSION, id, error: body.error }, - headers, - } - } - - return { response: protocolError(id, JSON_RPC_ERROR_CODES.INTERNAL_ERROR), headers } - } - - /** - * Rejects the whole HTTP request (wrong content type, body or batch too large) with the status of the error. - */ - private reject(error: AnyORPCError): StandardHandlerHandleResult { - return { - matched: true, - response: { - status: getOwn(COMMON_ERROR_STATUS_MAP, error.code) ?? DEFAULT_ERROR_STATUS, - headers: {}, - body: { jsonrpc: JSON_RPC_VERSION, id: null, error: encodeJsonRpcError(error, this.serializer, this.errorCodeMap) }, - }, - } - } -} diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts index 5639390dbe..22b5b59043 100644 --- a/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts +++ b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts @@ -41,8 +41,7 @@ export interface OpenRPCHandlerCodecOptions<_T extends Context> { } /** - * The codec of `OpenRPCHandler`. It only resolves the per-call sub-requests created by `OpenRPCEnvelopeHandlerPlugin`, - * so use the two together. + * The codec `StandardOpenRPCHandler` runs each call with. It only resolves the per-call sub-requests the handler creates. */ export class OpenRPCHandlerCodec implements StandardHandlerCodec { private readonly matcher: RPCMatcher @@ -100,7 +99,7 @@ export class OpenRPCHandlerCodec implements StandardHandlerCo encodeError(error: AnyORPCError): StandardResponse { return { - // the status the per-call plugins (such as rate limiting) see, the HTTP status of JSON-RPC errors is always 200 + // the status per-call plugins (such as tracing) see, the HTTP status of JSON-RPC errors is always 200 status: getOwn(COMMON_ERROR_STATUS_MAP, error.code) ?? DEFAULT_ERROR_STATUS, headers: {}, body: { error: encodeJsonRpcError(error, this.serializer, this.errorCodeMap) }, diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler-discovery.test.ts b/packages/openrpc/src/adapters/standard/openrpc-handler-discovery.test.ts new file mode 100644 index 0000000000..2a891db5ad --- /dev/null +++ b/packages/openrpc/src/adapters/standard/openrpc-handler-discovery.test.ts @@ -0,0 +1,161 @@ +import type { OpenRPCDocument } from '../../types' +import type { OpenRPCHandlerOptions } from '../fetch' +import { ORPCError } from '@orpc/client' +import { os } from '@orpc/server' +import { OpenRPCHandler } from '../fetch' + +describe('standardOpenRPCHandler discovery', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function createSpec(title = 'Planets'): OpenRPCDocument { + return { openrpc: '1.3.2', info: { title, version: '1.0.0' }, methods: [] } + } + + const discover = vi.fn() + const router = { rpc: { discover: os.handler(discover) }, planet: { find: os.handler(() => 'Earth') } } + + function createHandler(options: OpenRPCHandlerOptions = {}) { + return new OpenRPCHandler(router, options) + } + + async function post(handler: OpenRPCHandler, body: unknown, prefix: `/${string}` = '/rpc') { + const { response } = await handler.handle(new Request(`http://localhost${prefix}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }), { prefix, context: { user: 'alice' } }) + + return { status: response!.status, json: response!.status === 204 ? undefined : await response!.json() } + } + + it('answers rpc.discover in single, batch and notification requests, never calling a user rpc.discover procedure', async () => { + const spec = createSpec() + const handler = createHandler({ discovery: { spec: () => spec } }) + + await expect(post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).resolves.toEqual({ + status: 200, + json: { jsonrpc: '2.0', id: 1, result: spec }, + }) + await expect(post(handler, [{ jsonrpc: '2.0', method: 'rpc.discover', id: 1 }, { jsonrpc: '2.0', method: 'planet.find', id: 2 }])).resolves.toEqual({ + status: 200, + json: [{ jsonrpc: '2.0', id: 1, result: spec }, { jsonrpc: '2.0', id: 2, result: 'Earth' }], + }) + await expect(post(handler, { jsonrpc: '2.0', method: 'rpc.discover' })).resolves.toEqual({ status: 204, json: undefined }) + expect(discover).not.toHaveBeenCalled() + }) + + it('passes the routed context, prefix and request to spec', async () => { + const spec = vi.fn(() => createSpec()) + const handler = createHandler({ discovery: { spec } }) + + await post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 }) + + expect(spec).toHaveBeenCalledExactlyOnceWith({ context: { user: 'alice' }, prefix: '/rpc', request: expect.objectContaining({ url: '/rpc' }) }) + }) + + it('answers rpc.discover with -32601 without the discovery option', async () => { + await expect(post(createHandler(), { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).resolves.toEqual({ + status: 200, + json: { jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found' } }, + }) + expect(discover).not.toHaveBeenCalled() + }) + + it('answers the errors thrown by spec, so spec can guard the document', async () => { + const handler = createHandler({ + discovery: { + spec: ({ context }) => { + if (context.user !== 'admin') { + throw new ORPCError('FORBIDDEN') + } + + return createSpec() + }, + }, + }) + + expect((await post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).json).toEqual({ + jsonrpc: '2.0', + id: 1, + error: { code: -32003, message: 'Forbidden', data: { code: 'FORBIDDEN', defined: false } }, + }) + }) + + it('answers the GET of a document spec refuses with the status of the error', async () => { + const handler = createHandler({ + discovery: { + spec: () => { + throw new ORPCError('FORBIDDEN') + }, + }, + }) + + const { response } = await handler.handle(new Request('http://localhost/rpc/openrpc.json'), { prefix: '/rpc' }) + + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toMatchObject({ id: null, error: { code: -32003, data: { code: 'FORBIDDEN' } } }) + }) + + it('serves the document over GET at /openrpc.json under the prefix', async () => { + const spec = createSpec() + const handler = createHandler({ discovery: { spec } }) + + const { response } = await handler.handle(new Request('http://localhost/rpc/openrpc.json?pretty'), { prefix: '/rpc' }) + + expect(response?.status).toBe(200) + expect(response?.headers.get('content-type')).toContain('application/json') + await expect(response?.json()).resolves.toEqual(spec) + + // without a prefix + await expect((await createHandler({ discovery: { spec } }).handle(new Request('http://localhost/openrpc.json'))).response?.json()).resolves.toEqual(spec) + }) + + it('serves the document from a custom spec path relative to the prefix', async () => { + const spec = vi.fn(async () => createSpec('Custom')) + const handler = createHandler({ discovery: { spec, specPath: '/spec.json' } }) + + const { response } = await handler.handle(new Request('http://localhost/rpc/spec.json'), { prefix: '/rpc', context: { user: 'alice' } }) + + await expect(response?.json()).resolves.toEqual(createSpec('Custom')) + expect(spec).toHaveBeenCalledExactlyOnceWith({ context: { user: 'alice' }, prefix: '/rpc', request: expect.objectContaining({ url: '/rpc/spec.json' }) }) + + for (const url of ['http://localhost/spec.json', 'http://localhost/rpc/openrpc.json']) { + expect((await handler.handle(new Request(url), { prefix: '/rpc' })).matched).toBe(false) + } + }) + + it('disables the GET endpoint with specPath false but still answers rpc.discover', async () => { + const handler = createHandler({ discovery: { spec: createSpec(), specPath: false } }) + + expect((await handler.handle(new Request('http://localhost/rpc/openrpc.json'), { prefix: '/rpc' })).matched).toBe(false) + expect((await post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).json).toMatchObject({ result: createSpec() }) + }) + + it.each([ + ['POST', 'http://localhost/rpc/openrpc.json'], + ['GET', 'http://localhost/rpc/other.json'], + ['GET', 'http://localhost/rpc'], + ])('does not serve %s %s', async (method, url) => { + const spec = vi.fn(() => createSpec()) + const handler = createHandler({ discovery: { spec } }) + + expect((await handler.handle(new Request(url, { method }), { prefix: '/rpc' })).matched).toBe(false) + expect(spec).not.toHaveBeenCalled() + }) + + it('runs inside the routing interceptors, which can guard the whole request', async () => { + const spec = createSpec() + const handler = createHandler({ + routingInterceptors: [async ({ next, request }) => request.headers.authorization ? next() : { matched: false }], + discovery: { spec }, + }) + + expect((await handler.handle(new Request('http://localhost/openrpc.json'))).matched).toBe(false) + + const { response } = await handler.handle(new Request('http://localhost/openrpc.json', { headers: { authorization: 'token' } })) + + await expect(response?.json()).resolves.toEqual(spec) + }) +}) diff --git a/packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.test.ts b/packages/openrpc/src/adapters/standard/openrpc-handler.test.ts similarity index 83% rename from packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.test.ts rename to packages/openrpc/src/adapters/standard/openrpc-handler.test.ts index d3f8019c95..abb5181369 100644 --- a/packages/openrpc/src/adapters/standard/openrpc-envelope-handler-plugin.test.ts +++ b/packages/openrpc/src/adapters/standard/openrpc-handler.test.ts @@ -61,7 +61,7 @@ function batchOf(size: number) { return Array.from({ length: size }, (_, id) => ({ jsonrpc: '2.0', method: 'notify_hello', params: [id], id })) } -describe('openRPCEnvelopeHandlerPlugin', () => { +describe('standardOpenRPCHandler', () => { // https://www.jsonrpc.org/specification#examples // positional params are wrapped in one array, since a procedure takes a single input it.each([ @@ -278,51 +278,75 @@ describe('openRPCEnvelopeHandlerPlugin', () => { await expect(response!.json()).resolves.toEqual({ jsonrpc: '2.0', id: 1, result: ['hello', 5] }) }) - it('passes through sub-requests that are already marked as a call', async () => { + it('reads the calls from the body, even of a request a routing interceptor marks as a call', async () => { const handler = createHandler({ - plugins: [{ - name: 'mark', - after: ['~openrpc'], - init: options => ({ - ...options, - routingInterceptors: [({ next, ...rest }) => next({ - ...rest, - request: { ...rest.request, resolveBody: async () => undefined, [JSON_RPC_CALL_SYMBOL]: { method: 'get_data', id: 1 } } as typeof rest.request, - }), ...options.routingInterceptors ?? []], - }), - }], + routingInterceptors: [({ next, ...rest }) => next({ + ...rest, + request: { ...rest.request, [JSON_RPC_CALL_SYMBOL]: { method: 'get_data', id: 1 } } as typeof rest.request, + })], }) - // the codec answer, not wrapped in a JSON-RPC response - await expect(post(handler, { jsonrpc: '2.0', method: 'planet.list', id: 2 })).resolves.toEqual({ status: 200, json: { result: ['hello', 5] } }) + await expect(post(handler, { jsonrpc: '2.0', method: 'planet.list', id: 2 })).resolves.toEqual({ status: 200, json: { jsonrpc: '2.0', id: 2, result: {} } }) }) }) describe('plugins and interceptors', () => { - it('runs plugins inside the envelope once per call, and outside it once per request', async () => { + it('runs routing interceptors once per request, and the other interceptors once per call', async () => { const calls: unknown[] = [] - const plugin = (name: string, order: Pick, 'before' | 'after'>): StandardHandlerPlugin => ({ - name, - ...order, + const plugin: StandardHandlerPlugin = { + name: 'record', init: options => ({ ...options, routingInterceptors: [({ request, next }) => { - calls.push([name, request.url, getJsonRpcCall(request)]) + calls.push(['plugin routing', request.url, getJsonRpcCall(request)]) return next() }, ...options.routingInterceptors ?? []], + interceptors: [({ request, next }) => { + calls.push(['plugin interceptor', request.url, getJsonRpcCall(request)]) + return next() + }, ...options.interceptors ?? []], }), + } + + const handler = createHandler({ + plugins: [plugin], + routingInterceptors: [({ request, next }) => { + calls.push(['routing', request.url, getJsonRpcCall(request)]) + return next() + }], + clientInterceptors: [({ path, next }) => { + calls.push(['client', path.join('.')]) + return next() + }], }) - const handler = createHandler({ plugins: [plugin('outer', { after: ['~openrpc'] }), plugin('inner', { before: ['~openrpc'] })] }) await post(handler, [{ jsonrpc: '2.0', method: 'get_data', id: 1 }, { jsonrpc: '2.0', method: 'notify_hello', params: [1] }]) expect(calls).toEqual([ - ['outer', '/rpc', undefined], - ['inner', '/rpc/get_data', { method: 'get_data', id: 1 }], - ['inner', '/rpc/notify_hello', { method: 'notify_hello', id: undefined }], + ['plugin routing', '/rpc', undefined], + ['routing', '/rpc', undefined], + ['plugin interceptor', '/rpc/get_data', { method: 'get_data', id: 1 }], + ['plugin interceptor', '/rpc/notify_hello', { method: 'notify_hello', id: undefined }], + ['client', 'get_data'], + ['client', 'notify_hello'], ]) }) + it('lets routing interceptors answer, or change the context of, the whole request', async () => { + const handler = new OpenRPCHandler({ + whoami: os.$context<{ user?: string }>().handler(({ context }) => context.user), + }, { + routingInterceptors: [async ({ next, ...options }) => options.request.headers.authorization + ? next({ ...options, context: { ...options.context, user: 'u1' } }) + : { matched: true, response: { status: 401, headers: {}, body: undefined } }], + }) + + await expect(post(handler, [{ jsonrpc: '2.0', method: 'whoami', id: 1 }])).resolves.toEqual({ status: 401, json: undefined }) + await expect(post(handler, [{ jsonrpc: '2.0', method: 'whoami', id: 1 }, { jsonrpc: '2.0', method: 'whoami', id: 2 }], { headers: { authorization: 'Bearer token' } })) + .resolves + .toEqual({ status: 200, json: [{ jsonrpc: '2.0', id: 1, result: 'u1' }, { jsonrpc: '2.0', id: 2, result: 'u1' }] }) + }) + it('passes the HTTP request headers to each call, without its content-length', async () => { const seen: Record[] = [] const handler = createHandler({ @@ -411,19 +435,37 @@ describe('openRPCEnvelopeHandlerPlugin', () => { expect(names.filter(name => name.startsWith('POST'))).toEqual(['POST /rpc/get_data', 'POST /rpc/notify_hello']) }) - it('rethrows errors of a single call, but answers the other calls of a batch with -32603', async ({ onTestFinished }) => { + it('rethrows the errors the rethrow plugin catches, once every call of the request has run', async () => { + const handled = vi.fn() + const handler = new OpenRPCHandler({ + ...router, + get_data: os.handler(handled), + }, { plugins: [new RethrowHandlerPlugin({ filter: error => !(error instanceof ORPCError) })] }) + + await expect(post(handler, { jsonrpc: '2.0', method: 'planet.fail', id: 1 })).rejects.toThrow('secret') + await expect(post(handler, [{ jsonrpc: '2.0', method: 'planet.fail', id: 1 }, { jsonrpc: '2.0', method: 'get_data', id: 2 }])).rejects.toThrow('secret') + expect(handled).toHaveBeenCalledOnce() + }) + + it('rethrows unexpected errors of a single call, but answers the other calls of a batch with -32603', async ({ onTestFinished }) => { const rejectSpy = vi.spyOn(Promise, 'reject').mockImplementation(() => new Promise(() => {}) as Promise) onTestFinished(() => { rejectSpy.mockRestore() }) - const handler = createHandler({ plugins: [new RethrowHandlerPlugin({ filter: error => !(error instanceof ORPCError) })] }) + // a lazy router that fails to load fails the call outside of the interceptors + const handler = new OpenRPCHandler({ + ...router, + broken: os.lazy(async () => { + throw new Error('secret') + }), + }) - await expect(post(handler, { jsonrpc: '2.0', method: 'planet.fail', id: 1 })).rejects.toThrow('secret') + await expect(post(handler, { jsonrpc: '2.0', method: 'broken.ping', id: 1 })).rejects.toThrow('secret') expect(rejectSpy).not.toHaveBeenCalled() - expect((await post(handler, [{ jsonrpc: '2.0', method: 'planet.fail', id: 1 }, { jsonrpc: '2.0', method: 'get_data', id: 2 }])).json).toEqual([ + expect((await post(handler, [{ jsonrpc: '2.0', method: 'broken.ping', id: 1 }, { jsonrpc: '2.0', method: 'get_data', id: 2 }])).json).toEqual([ { jsonrpc: '2.0', id: 1, error: { code: -32603, message: 'Internal error' } }, { jsonrpc: '2.0', id: 2, result: ['hello', 5] }, ]) diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler.ts b/packages/openrpc/src/adapters/standard/openrpc-handler.ts index 73192e52f8..674fa733bb 100644 --- a/packages/openrpc/src/adapters/standard/openrpc-handler.ts +++ b/packages/openrpc/src/adapters/standard/openrpc-handler.ts @@ -1,23 +1,320 @@ +import type { AnyORPCError } from '@orpc/client' import type { Context, Router } from '@orpc/server' -import type { StandardHandlerOptions } from '@orpc/server/standard' -import type { OpenRPCEnvelopeHandlerPluginOptions } from './openrpc-envelope-handler-plugin' +import type { + StandardHandlerHandleOptions, + StandardHandlerHandleResult, + StandardHandlerOptions, + StandardHandlerRoutingInterceptor, + StandardHandlerRoutingInterceptorOptions, +} from '@orpc/server/standard' +import type { Promisable, Public, Value } from '@orpc/shared' +import type { StandardHeaders, StandardLazyRequest, StandardUrl } from '@standard-server/core' +import type { JsonRpcId, JsonRpcResponse } from '../../json-rpc' +import type { OpenRPCDocument } from '../../types' import type { OpenRPCHandlerCodecOptions } from './openrpc-handler-codec' +import type { JsonRpcCall } from './utils' +import { COMMON_ERROR_STATUS_MAP, ORPCError, toORPCError } from '@orpc/client' +import { OpenAPIJsonSerializer } from '@orpc/openapi' +import { DEFAULT_ERROR_STATUS } from '@orpc/server' import { StandardHandler } from '@orpc/server/standard' -import { toArray } from '@orpc/shared' -import { OpenRPCEnvelopeHandlerPlugin } from './openrpc-envelope-handler-plugin' +import { getOwn, intercept, isPlainObject, matchesHttpPath, matchesHttpPathPrefix, mergeHttpPath, pathToHttpPath, sortPlugins, toArray, value } from '@orpc/shared' +import { mergeStandardHeaders } from '@standard-server/core' +import { DEFAULT_JSON_RPC_MAX_BATCH_SIZE, JSON_RPC_ERROR_CODES, JSON_RPC_VERSION } from '../../constants' +import { encodeJsonRpcError, hasJsonRpcResultOrError, isJsonRpcId } from '../../json-rpc' import { OpenRPCHandlerCodec } from './openrpc-handler-codec' +import { isJsonRpcMediaType, JSON_RPC_CALL_SYMBOL } from './utils' -export interface StandardOpenRPCHandlerOptions - extends StandardHandlerOptions, OpenRPCHandlerCodecOptions, OpenRPCEnvelopeHandlerPluginOptions {} +export interface OpenRPCHandlerDiscoveryOptions { + /** + * The document to serve, for example a memoized `() => generator.generate(router)`. + * Throw an `ORPCError` to refuse it. + */ + spec: Value, [options: StandardHandlerRoutingInterceptorOptions]> + + /** + * The path (relative to the prefix) at which to also serve the document over `GET`, or `false` to disable it. + * + * @default '/openrpc.json' + */ + specPath?: StandardUrl | false | undefined +} + +export interface StandardOpenRPCHandlerOptions extends StandardHandlerOptions, OpenRPCHandlerCodecOptions { + /** + * The max number of requests in a batch. Larger batches are rejected with `413`. + * + * @default DEFAULT_JSON_RPC_MAX_BATCH_SIZE (100) + * @see {@link https://orpc.dev/docs/openrpc/handler#batch-requests-and-notifications | OpenRPC Handler - Batch Requests and Notifications} + */ + maxBatchSize?: Value, [options: StandardHandlerRoutingInterceptorOptions]> | undefined + + /** + * Answers the OpenRPC Service Discovery method `rpc.discover` with the document, and serves it over `GET`. + * + * @see {@link https://orpc.dev/docs/openrpc/specification#service-discovery | OpenRPC Specification - Service Discovery} + */ + discovery?: OpenRPCHandlerDiscoveryOptions | undefined +} + +export interface StandardOpenRPCHandlerMessageOptions extends StandardHandlerHandleOptions { + /** + * Aborts the calls of the message, such as when its socket closes. + */ + signal?: AbortSignal | undefined +} + +const PROTOCOL_ERROR_MESSAGES = { + [JSON_RPC_ERROR_CODES.PARSE_ERROR]: 'Parse error', + [JSON_RPC_ERROR_CODES.INVALID_REQUEST]: 'Invalid Request', + [JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND]: 'Method not found', + [JSON_RPC_ERROR_CODES.INTERNAL_ERROR]: 'Internal error', +} + +function protocolError(id: JsonRpcId, code: keyof typeof PROTOCOL_ERROR_MESSAGES): JsonRpcResponse { + return { jsonrpc: JSON_RPC_VERSION, id, error: { code, message: PROTOCOL_ERROR_MESSAGES[code] } } +} + +function reply(body: JsonRpcResponse | JsonRpcResponse[] | undefined, headers: StandardHeaders = {}): StandardHandlerHandleResult { + return { matched: true, response: { status: body === undefined ? 204 : 200, headers, body } } +} /** - * The `StandardHandler` every `OpenRPCHandler` adapter is built on: the OpenRPC codec, paired with the envelope plugin it needs. + * The handler every `OpenRPCHandler` adapter is built on. It reads the JSON-RPC 2.0 requests (single or batch) + * of an HTTP POST request, or of a WebSocket message, runs each call, and answers with the JSON-RPC response(s). + * + * Plugins hook in at two levels: `routingInterceptors` run once per HTTP request (or message), such as CORS + * or the request limit, while each call runs through `StandardHandler` as its own sub-request, with its own + * tracing span, `interceptors`, and `clientInterceptors`. Interceptors read the call with `getJsonRpcCall`. + * + * @see {@link https://orpc.dev/docs/openrpc/handler#interceptors | OpenRPC Handler - Interceptors} */ export class StandardOpenRPCHandler extends StandardHandler { + private readonly requestInterceptors: StandardHandlerRoutingInterceptor[] | undefined + private readonly serializer: Public + private readonly errorCodeMap: StandardOpenRPCHandlerOptions['errorCodeMap'] + private readonly maxBatchSize: Exclude['maxBatchSize'], undefined> + private readonly discovery: StandardOpenRPCHandlerOptions['discovery'] + constructor(router: Router, options: NoInfer> = {}) { + let initialized: StandardHandlerOptions = options + + for (const plugin of sortPlugins(toArray(options.plugins))) { + initialized = plugin.init?.(initialized) ?? initialized + } + + // the plugins are already initialized, the routing interceptors run in `.handle` super(new OpenRPCHandlerCodec(router, options), { - ...options, - plugins: [...toArray(options.plugins), new OpenRPCEnvelopeHandlerPlugin(options)], + interceptors: initialized.interceptors, + clientInterceptors: initialized.clientInterceptors, }) + + this.requestInterceptors = initialized.routingInterceptors + this.serializer = options.serializer ?? new OpenAPIJsonSerializer() + this.errorCodeMap = options.errorCodeMap + this.maxBatchSize = options.maxBatchSize ?? DEFAULT_JSON_RPC_MAX_BATCH_SIZE + this.discovery = options.discovery + } + + /** + * Answers a `POST` to the prefix itself (to any path without a prefix), and the `GET` of the discovery document. + * Other requests are not matched. + */ + override async handle(request: StandardLazyRequest, options: StandardHandlerHandleOptions): Promise { + const { prefix } = options + + if (prefix && !matchesHttpPathPrefix(request.url, prefix)) { + return { matched: false } + } + + const context = await value(options.context, request) as T + + return intercept(this.requestInterceptors, { context, prefix, request }, async (routed) => { + const { request, prefix } = routed + + if (request.method === 'GET') { + return this.serveSpec(routed) + } + + if (request.method !== 'POST' || (prefix !== undefined && !matchesHttpPath(request.url, prefix))) { + return { matched: false } + } + + if (!isJsonRpcMediaType(request.headers['content-type'])) { + return this.reject(new ORPCError('UNSUPPORTED_MEDIA_TYPE', { message: 'JSON-RPC requests must use Content-Type: application/json' })) + } + + return this.dispatch(routed) + }) + } + + /** + * Answers a message that is already parsed (`undefined` when it was not valid JSON), such as one of a WebSocket. + * Routing interceptors see a request whose body is the message. + */ + async handleMessage(message: unknown, options: StandardOpenRPCHandlerMessageOptions): Promise { + const { prefix, signal } = options + const request: StandardLazyRequest = { method: 'POST', url: prefix ?? '/', headers: {}, signal, resolveBody: async () => message } + const context = await value(options.context, request) as T + + return intercept(this.requestInterceptors, { context, prefix, request }, routed => this.dispatch(routed)) + } + + private async dispatch(options: StandardHandlerRoutingInterceptorOptions): Promise { + let payload: unknown + + try { + payload = await options.request.resolveBody('json') + } + catch (error) { + // errors of plugins, such as the request limit plugin + if (error instanceof ORPCError) { + return this.reject(error) + } + } + + if (payload === undefined) { + return reply(protocolError(null, JSON_RPC_ERROR_CODES.PARSE_ERROR)) + } + + if (!Array.isArray(payload)) { + const { response, headers } = await this.call(options, payload, false) + return reply(response, headers) + } + + if (payload.length === 0) { + return reply(protocolError(null, JSON_RPC_ERROR_CODES.INVALID_REQUEST)) + } + + const maxBatchSize = await value(this.maxBatchSize, options) + + if (payload.length > maxBatchSize) { + return this.reject(new ORPCError('PAYLOAD_TOO_LARGE', { message: `Batch size exceeds the maximum of ${maxBatchSize}.` })) + } + + const results = await Promise.all(payload.map(item => this.call(options, item, true))) + const responses = results.flatMap(({ response }) => response ? [response] : []) + + // a batch of only notifications is answered with nothing, never an empty array + return reply(responses.length ? responses : undefined, results.reduce((merged, { headers }) => mergeStandardHeaders(merged, headers), {})) + } + + /** + * Runs one call through `StandardHandler`, as a sub-request whose body is its `params`. + * Notifications run to completion too, but are never answered. + */ + private async call( + options: StandardHandlerRoutingInterceptorOptions, + item: unknown, + batch: boolean, + ): Promise<{ response?: JsonRpcResponse, headers: StandardHeaders }> { + const { jsonrpc, method, params, id: rawId } = isPlainObject(item) + ? { jsonrpc: getOwn(item, 'jsonrpc'), method: getOwn(item, 'method'), params: getOwn(item, 'params'), id: getOwn(item, 'id') } + : {} as Record + + if ( + jsonrpc !== JSON_RPC_VERSION + || typeof method !== 'string' + || (rawId !== undefined && !isJsonRpcId(rawId)) + || (params !== undefined && params !== null && !Array.isArray(params) && !isPlainObject(params)) + ) { + // answered even without an `id`, with the `id` when it is valid + return { response: protocolError(isJsonRpcId(rawId) ? rawId : null, JSON_RPC_ERROR_CODES.INVALID_REQUEST), headers: {} } + } + + const id = rawId as JsonRpcId | undefined + const request: StandardLazyRequest & { [JSON_RPC_CALL_SYMBOL]: JsonRpcCall } = { + method: 'POST', + // only for logs and spans, the codec routes by the call + url: mergeHttpPath(options.prefix ?? '/', pathToHttpPath(method.split('.'))), + headers: { ...options.request.headers, 'content-length': undefined }, + signal: options.request.signal, + resolveBody: async () => params ?? undefined, + [JSON_RPC_CALL_SYMBOL]: { method, id }, + } + + let result: StandardHandlerHandleResult + + try { + result = method === 'rpc.discover' && this.discovery + ? await this.discover(options) + : await super.handle(request, { prefix: options.prefix, context: options.context }) + } + catch (error) { + if (!batch) { + throw error + } + + // like the batch plugin, report it as an unhandled rejection and answer the other calls + Promise.reject(error) + result = { matched: true, response: { status: 500, headers: {}, body: undefined } } + } + + const headers = Object.fromEntries(Object.entries(result.response?.headers ?? {}) + .filter(([name]) => !name.startsWith('content-') && name !== 'standard-server')) + + if (id === undefined) { + return { headers } + } + + if (!result.matched) { + return { response: protocolError(id, JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND), headers } + } + + const body = result.response.body + + if (hasJsonRpcResultOrError(body)) { + return { + response: 'result' in body ? { jsonrpc: JSON_RPC_VERSION, id, result: body.result } : { jsonrpc: JSON_RPC_VERSION, id, error: body.error }, + headers, + } + } + + return { response: protocolError(id, JSON_RPC_ERROR_CODES.INTERNAL_ERROR), headers } + } + + /** + * Answers `rpc.discover` with the document, or with the error `spec` throws. + */ + private async discover(options: StandardHandlerRoutingInterceptorOptions): Promise { + try { + return { matched: true, response: { status: 200, headers: {}, body: { result: await value(this.discovery!.spec, options) } } } + } + catch (error) { + return { matched: true, response: { status: 200, headers: {}, body: { error: encodeJsonRpcError(toORPCError(error), this.serializer, this.errorCodeMap) } } } + } + } + + private async serveSpec(options: StandardHandlerRoutingInterceptorOptions): Promise { + const { request, prefix } = options + const specPath = this.discovery?.specPath ?? '/openrpc.json' + + if (!this.discovery || specPath === false || !matchesHttpPath(request.url, prefix ? mergeHttpPath(prefix, specPath) : specPath)) { + return { matched: false } + } + + try { + return { matched: true, response: { status: 200, headers: {}, body: await value(this.discovery.spec, options) } } + } + catch (error) { + if (error instanceof ORPCError) { + return this.reject(error) + } + + throw error + } + } + + /** + * Rejects the whole HTTP request (wrong content type, body or batch too large) with the status of the error. + */ + private reject(error: AnyORPCError): StandardHandlerHandleResult { + return { + matched: true, + response: { + status: getOwn(COMMON_ERROR_STATUS_MAP, error.code) ?? DEFAULT_ERROR_STATUS, + headers: {}, + body: { jsonrpc: JSON_RPC_VERSION, id: null, error: encodeJsonRpcError(error, this.serializer, this.errorCodeMap) }, + }, + } } } diff --git a/packages/openrpc/src/adapters/standard/utils.ts b/packages/openrpc/src/adapters/standard/utils.ts index 87ed5b01a2..08baa2a621 100644 --- a/packages/openrpc/src/adapters/standard/utils.ts +++ b/packages/openrpc/src/adapters/standard/utils.ts @@ -16,14 +16,14 @@ export interface JsonRpcCall { } /** - * Marks the sub-requests the envelope plugin creates, one per JSON-RPC call. The codec only resolves marked + * Marks the sub-requests `StandardOpenRPCHandler` creates, one per JSON-RPC call. The codec only resolves marked * requests, so a raw HTTP request (whose body or headers cannot carry a symbol) never routes to a procedure. */ export const JSON_RPC_CALL_SYMBOL: unique symbol = Symbol.for('ORPC_JSON_RPC_CALL') /** * Returns the JSON-RPC call a sub-request represents, or `undefined` for any other request, - * such as the raw HTTP request seen by plugins outside the envelope. + * such as the HTTP request routing interceptors see. * * @see {@link https://orpc.dev/docs/openrpc/handler#interceptors | OpenRPC Handler - Interceptors} */ diff --git a/packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts b/packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts index a0b4827bbc..a6405c139f 100644 --- a/packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts +++ b/packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts @@ -1,6 +1,7 @@ import type { StandardHandlerPlugin } from '@orpc/server/standard' import type { AddressInfo } from 'node:net' import { once } from 'node:events' +import { ORPCError } from '@orpc/client' import { os } from '@orpc/server' import { AbortError, promiseWithResolvers } from '@orpc/shared' import WebSocket, { WebSocketServer } from 'ws' @@ -98,7 +99,7 @@ describe('openRPCHandler', () => { expect(getSentMessages(ws)).toEqual([{ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }]) }) - it('passes options to the codec and the envelope plugin', async () => { + it('passes the handler options through', async () => { const handler = createHandler({ filter: (_, path) => path[0] !== 'echo', maxBatchSize: 1 }) const ws = createWs() @@ -111,16 +112,20 @@ describe('openRPCHandler', () => { ]) }) - it('runs plugins and interceptors once per call', async () => { + it('runs routing interceptors once per message, and the other interceptors once per call', async () => { + const routingInterceptor = vi.fn(({ next }) => next()) const interceptor = vi.fn(({ next }) => next()) const pluginInterceptor = vi.fn(({ next }) => next()) const plugin: StandardHandlerPlugin = { name: 'test', init: options => ({ ...options, interceptors: [...options.interceptors ?? [], pluginInterceptor] }), } - const handler = createHandler({ interceptors: [interceptor], plugins: [plugin] }) + const handler = createHandler({ routingInterceptors: [routingInterceptor], interceptors: [interceptor], plugins: [plugin] }) - await handler.message(createWs(), JSON.stringify([call('ping', 1), call('echo')])) + await handler.message(createWs(), JSON.stringify([call('ping', 1), call('echo')]), { prefix: '/api' }) + + expect(routingInterceptor).toHaveBeenCalledOnce() + expect(routingInterceptor).toHaveBeenCalledWith(expect.objectContaining({ prefix: '/api', request: expect.objectContaining({ url: '/api', headers: {} }) })) for (const spy of [interceptor, pluginInterceptor]) { expect(spy.mock.calls.map(([{ request }]) => getJsonRpcCall(request))).toEqual([ @@ -130,6 +135,24 @@ describe('openRPCHandler', () => { } }) + it('answers the errors of plugins that read the message, such as a size limit', async () => { + const handler = createHandler({ + routingInterceptors: [({ next, ...options }) => next({ + ...options, + request: { + ...options.request, + resolveBody: async () => { + throw new ORPCError('PAYLOAD_TOO_LARGE') + }, + }, + })], + }) + const ws = createWs() + + await expect(handler.message(ws, request)).resolves.toEqual({ matched: true }) + expect(getSentMessages(ws)).toMatchObject([{ jsonrpc: '2.0', id: null, error: { code: -32013, data: { code: 'PAYLOAD_TOO_LARGE' } } }]) + }) + it('aborts the in-flight calls of a closed socket only', async () => { const signals: AbortSignal[] = [] const release = promiseWithResolvers() diff --git a/packages/openrpc/src/adapters/websocket/openrpc-handler.ts b/packages/openrpc/src/adapters/websocket/openrpc-handler.ts index 2e3008f7d3..69f61f3146 100644 --- a/packages/openrpc/src/adapters/websocket/openrpc-handler.ts +++ b/packages/openrpc/src/adapters/websocket/openrpc-handler.ts @@ -50,17 +50,10 @@ export class OpenRPCHandler { const signal = controller.signal const options = resolveFriendlyStandardHandlerHandleOptions(resolveMaybeOptionalOptions(rest)) // parsed before the first `await`, since some servers, like uWebSockets, detach the message buffer once the listener returns. - // invalid JSON resolves to `undefined`, which the envelope answers with "Parse error" - const body = parseJsonMessage(toStringOrBytes(data)) + // invalid JSON resolves to `undefined`, which the handler answers with "Parse error" + const message = parseJsonMessage(toStringOrBytes(data)) - const { matched, response } = await this.handler.handle({ - method: 'POST', - // `StandardHandler` and the envelope only accept requests sent to the prefix itself - url: options.prefix ?? '/', - headers: { 'content-type': 'application/json' }, - signal, - resolveBody: async () => body, - }, options) + const { matched, response } = await this.handler.handleMessage(message, { ...options, signal }) if (response?.body !== undefined && !signal.aborted) { await ws.send(stringifyJSON(response.body)!) diff --git a/packages/openrpc/src/plugins/index.test.ts b/packages/openrpc/src/plugins/index.test.ts deleted file mode 100644 index aa2b198c4a..0000000000 --- a/packages/openrpc/src/plugins/index.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -it('exports OpenRPCDiscoveryHandlerPlugin', async () => { - await expect(import('.')).resolves.toMatchObject({ - OpenRPCDiscoveryHandlerPlugin: expect.any(Function), - }) -}) diff --git a/packages/openrpc/src/plugins/index.ts b/packages/openrpc/src/plugins/index.ts deleted file mode 100644 index b4124734ae..0000000000 --- a/packages/openrpc/src/plugins/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './openrpc-discovery' diff --git a/packages/openrpc/src/plugins/openrpc-discovery.test.ts b/packages/openrpc/src/plugins/openrpc-discovery.test.ts deleted file mode 100644 index 37efbb3211..0000000000 --- a/packages/openrpc/src/plugins/openrpc-discovery.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import type { StandardHandlerRoutingInterceptor } from '@orpc/server/standard' -import type { JsonRpcCall } from '../adapters/standard/utils' -import type { OpenRPCDocument } from '../types' -import { os } from '@orpc/server' -import { RPCHandler } from '@orpc/server/fetch' -import { OpenRPCHandler } from '../adapters/fetch' -import { getJsonRpcCall, JSON_RPC_CALL_SYMBOL } from '../adapters/standard/utils' -import { OpenRPCDiscoveryHandlerPlugin } from './openrpc-discovery' - -describe('openRPCDiscoveryHandlerPlugin', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - function createSpec(title = 'Planets'): OpenRPCDocument { - return { openrpc: '1.3.2', info: { title, version: '1.0.0' }, methods: [] } - } - - function getInterceptor(plugin: OpenRPCDiscoveryHandlerPlugin, options: Record = {}) { - const initialized = plugin.init(options as any) - const interceptor = initialized.routingInterceptors?.at(-1) - - expect(interceptor).toBeDefined() - - return { initialized, interceptor: interceptor! } - } - - async function invoke( - interceptor: StandardHandlerRoutingInterceptor, - { url, method = 'GET', prefix, call, nextResult = { matched: false as const } }: { - url: `/${string}` - method?: string - prefix?: `/${string}` - call?: JsonRpcCall - nextResult?: any - }, - ) { - const next = vi.fn().mockResolvedValue(nextResult) - const request = { method, url, headers: {}, signal: undefined, resolveBody: vi.fn(), ...call && { [JSON_RPC_CALL_SYMBOL]: call } } - const result = await interceptor({ next, context: {}, prefix, request } as any) - - return { next, request, result } - } - - it('preserves existing routing interceptors and returns the matched result from next untouched', async () => { - const spec = vi.fn(() => createSpec()) - const existing = vi.fn(({ next }) => next()) - const { initialized, interceptor } = getInterceptor(new OpenRPCDiscoveryHandlerPlugin({ spec }), { routingInterceptors: [existing] }) - const nextResult = { matched: true, response: { status: 200, headers: {}, body: 'handled' } } - - expect(initialized.routingInterceptors).toEqual([existing, interceptor]) - - const { next, result } = await invoke(interceptor, { url: '/openrpc.json', nextResult }) - - expect(next).toHaveBeenCalledOnce() - expect(result).toBe(nextResult) - expect(spec).not.toHaveBeenCalled() - }) - - it('answers rpc.discover calls without calling next, passing the routing options to spec', async () => { - const spec = vi.fn(() => createSpec()) - const { interceptor } = getInterceptor(new OpenRPCDiscoveryHandlerPlugin({ spec })) - - const { next, request, result } = await invoke(interceptor, { - method: 'POST', - url: '/rpc/rpc/discover', - prefix: '/rpc', - call: { method: 'rpc.discover', id: 1 }, - }) - - expect(result).toEqual({ matched: true, response: { status: 200, headers: {}, body: { result: createSpec() } } }) - expect(next).not.toHaveBeenCalled() - expect(spec).toHaveBeenCalledExactlyOnceWith({ context: {}, prefix: '/rpc', request }) - }) - - it('passes other JSON-RPC calls through', async () => { - const spec = vi.fn(() => createSpec()) - const { interceptor } = getInterceptor(new OpenRPCDiscoveryHandlerPlugin({ spec })) - const nextResult = { matched: true, response: { status: 200, headers: {}, body: { result: 'Earth' } } } - - const { result } = await invoke(interceptor, { method: 'POST', url: '/planet/find', call: { method: 'planet.find', id: 1 }, nextResult }) - - expect(result).toBe(nextResult) - expect(spec).not.toHaveBeenCalled() - }) - - it('serves a static document over GET at /openrpc.json once nothing else matched', async () => { - const { interceptor } = getInterceptor(new OpenRPCDiscoveryHandlerPlugin({ spec: createSpec() })) - - const { next, result } = await invoke(interceptor, { url: '/openrpc.json' }) - - expect(next).toHaveBeenCalledOnce() - expect(result).toEqual({ matched: true, response: { status: 200, headers: {}, body: createSpec() } }) - }) - - it('serves the document from a custom spec path relative to the prefix', async () => { - const spec = vi.fn(async () => createSpec('Custom')) - const { interceptor } = getInterceptor(new OpenRPCDiscoveryHandlerPlugin({ spec, specPath: '/spec.json' })) - - const { request, result } = await invoke(interceptor, { url: '/rpc/spec.json?pretty', prefix: '/rpc' }) - - expect(result.response?.body).toEqual(createSpec('Custom')) - expect(spec).toHaveBeenCalledExactlyOnceWith({ context: {}, prefix: '/rpc', request }) - - for (const url of ['/spec.json', '/rpc/openrpc.json'] as const) { - expect((await invoke(interceptor, { url, prefix: '/rpc' })).result).toEqual({ matched: false }) - } - }) - - it.each([ - ['POST', '/openrpc.json'], - ['GET', '/other.json'], - ])('does not serve %s %s', async (method, url) => { - const spec = vi.fn(() => createSpec()) - const { interceptor } = getInterceptor(new OpenRPCDiscoveryHandlerPlugin({ spec })) - const nextResult = { matched: false } - - expect((await invoke(interceptor, { method, url: url as `/${string}`, nextResult })).result).toBe(nextResult) - expect(spec).not.toHaveBeenCalled() - }) - - it('disables the GET endpoint with specPath false but still answers rpc.discover', async () => { - const { interceptor } = getInterceptor(new OpenRPCDiscoveryHandlerPlugin({ spec: createSpec(), specPath: false })) - - expect((await invoke(interceptor, { url: '/openrpc.json' })).result).toEqual({ matched: false }) - expect((await invoke(interceptor, { method: 'POST', url: '/', call: { method: 'rpc.discover', id: 1 } })).result.matched).toBe(true) - }) - - describe('with handlers', () => { - const spec = createSpec() - const discover = vi.fn() - - function post(handler: OpenRPCHandler | RPCHandler, body: unknown) { - return handler.handle(new Request('http://localhost/rpc', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }), { prefix: '/rpc' }) - } - - it('answers rpc.discover in single, batch and notification requests, never calling a user rpc.discover procedure', async () => { - const handler = new OpenRPCHandler({ rpc: { discover: os.handler(discover) }, planet: { find: os.handler(() => 'Earth') } }, { - plugins: [new OpenRPCDiscoveryHandlerPlugin({ spec: () => spec })], - }) - - await expect((await post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).response?.json()) - .resolves - .toEqual({ jsonrpc: '2.0', id: 1, result: spec }) - await expect((await post(handler, [{ jsonrpc: '2.0', method: 'rpc.discover', id: 1 }, { jsonrpc: '2.0', method: 'planet.find', id: 2 }])).response?.json()) - .resolves - .toEqual([{ jsonrpc: '2.0', id: 1, result: spec }, { jsonrpc: '2.0', id: 2, result: 'Earth' }]) - expect((await post(handler, { jsonrpc: '2.0', method: 'rpc.discover' })).response?.status).toBe(204) - expect(discover).not.toHaveBeenCalled() - }) - - it('serves the document over GET under the prefix', async () => { - const handler = new OpenRPCHandler({}, { plugins: [new OpenRPCDiscoveryHandlerPlugin({ spec })] }) - - const { response } = await handler.handle(new Request('http://localhost/rpc/openrpc.json'), { prefix: '/rpc' }) - - expect(response?.status).toBe(200) - expect(response?.headers.get('content-type')).toContain('application/json') - await expect(response?.json()).resolves.toEqual(spec) - }) - - it('runs after user routing interceptors, so they can guard discovery', async () => { - const guard = vi.fn>(async ({ next, request }) => - request.headers.authorization ? next() : { matched: false }) - const handler = new OpenRPCHandler({}, { - routingInterceptors: [guard], - plugins: [new OpenRPCDiscoveryHandlerPlugin({ spec })], - }) - - await expect((await post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).response?.json()) - .resolves - .toEqual({ jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found' } }) - expect(getJsonRpcCall(guard.mock.calls[0]![0].request)).toEqual({ method: 'rpc.discover', id: 1 }) - expect((await handler.handle(new Request('http://localhost/openrpc.json'))).matched).toBe(false) - - const { response } = await handler.handle(new Request('http://localhost/openrpc.json', { headers: { authorization: 'token' } })) - - await expect(response?.json()).resolves.toEqual(spec) - }) - - it('serves only the GET document when mounted on RPCHandler', async () => { - const handler = new RPCHandler({}, { plugins: [new OpenRPCDiscoveryHandlerPlugin({ spec, specPath: '/spec.json' })] }) - - const { response } = await handler.handle(new Request('http://localhost/rpc/spec.json'), { prefix: '/rpc' }) - - await expect(response?.json()).resolves.toEqual(spec) - expect((await post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).matched).toBe(false) - }) - }) -}) diff --git a/packages/openrpc/src/plugins/openrpc-discovery.ts b/packages/openrpc/src/plugins/openrpc-discovery.ts deleted file mode 100644 index 41f268d4a8..0000000000 --- a/packages/openrpc/src/plugins/openrpc-discovery.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { Context } from '@orpc/server' -import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptorOptions } from '@orpc/server/standard' -import type { Promisable, Value } from '@orpc/shared' -import type { StandardUrl } from '@standard-server/core' -import type { OpenRPCDocument } from '../types' -import { matchesHttpPath, mergeHttpPath, toArray, value } from '@orpc/shared' -import { getJsonRpcCall } from '../adapters/standard/utils' - -export interface OpenRPCDiscoveryHandlerPluginOptions { - /** - * The document to serve, for example a memoized `() => generator.generate(router)`. - */ - spec: Value, [options: StandardHandlerRoutingInterceptorOptions]> - - /** - * The path (relative to the prefix) at which to also serve the document over `GET`, or `false` to disable it. - * - * @default '/openrpc.json' - */ - specPath?: StandardUrl | false | undefined -} - -/** - * Answers the OpenRPC Service Discovery method `rpc.discover` with the document, and serves it over `GET`. - * - * @see {@link https://orpc.dev/docs/openrpc/specification#service-discovery | OpenRPC Specification - Service Discovery} - */ -export class OpenRPCDiscoveryHandlerPlugin implements StandardHandlerPlugin { - name = '~openrpc-discovery' - - private readonly spec: OpenRPCDiscoveryHandlerPluginOptions['spec'] - private readonly specPath: Exclude['specPath'], undefined> - - constructor(options: OpenRPCDiscoveryHandlerPluginOptions) { - this.spec = options.spec - this.specPath = options.specPath ?? '/openrpc.json' - } - - init(options: StandardHandlerOptions): StandardHandlerOptions { - return { - ...options, - routingInterceptors: [ - ...toArray(options.routingInterceptors), - async ({ next, ...interceptorOptions }) => { - const { request, prefix } = interceptorOptions - - // only `OpenRPCHandler` creates JSON-RPC calls - if (getJsonRpcCall(request)?.method === 'rpc.discover') { - return { matched: true, response: { status: 200, headers: {}, body: { result: await value(this.spec, interceptorOptions) } } } - } - - const result = await next() - - if ( - result.matched - || request.method !== 'GET' - || this.specPath === false - || !matchesHttpPath(request.url, prefix ? mergeHttpPath(prefix, this.specPath) : this.specPath) - ) { - return result - } - - return { matched: true, response: { status: 200, headers: {}, body: await value(this.spec, interceptorOptions) } } - }, - ], - } - } -} diff --git a/tests/openrpc/plugins.test.ts b/tests/openrpc/plugins.test.ts new file mode 100644 index 0000000000..81d42e8f83 --- /dev/null +++ b/tests/openrpc/plugins.test.ts @@ -0,0 +1,144 @@ +import type { RateLimitHandlerPluginContext } from '@orpc/ratelimit' +import type { RequestHeadersHandlerPluginContext, ResponseHeadersHandlerPluginContext } from '@orpc/server/plugins' +import { EvlogHandlerPlugin } from '@orpc/evlog' +import { OpenRPCHandler } from '@orpc/openrpc/fetch' +import { PinoHandlerPlugin } from '@orpc/pino' +import { RATELIMIT_HANDLER_PLUGIN_CONTEXT_SYMBOL, RateLimitHandlerPlugin } from '@orpc/ratelimit' +import { ORPCError, os } from '@orpc/server' +import { + BatchHandlerPlugin, + CORSHandlerPlugin, + RequestCompressionHandlerPlugin, + RequestHeadersHandlerPlugin, + RequestLimitHandlerPlugin, + ResponseCompressionHandlerPlugin, + ResponseHeadersHandlerPlugin, + RethrowHandlerPlugin, + SignalSyncHandlerPlugin, + TimeoutHandlerPlugin, +} from '@orpc/server/plugins' +import pino from 'pino' +import { z } from 'zod' + +/** + * Which level each handler plugin hooks into on `OpenRPCHandler`: routing interceptors run once per HTTP request, + * interceptors and client interceptors once per JSON-RPC call. + */ + +interface TestContext extends RequestHeadersHandlerPluginContext, ResponseHeadersHandlerPluginContext, RateLimitHandlerPluginContext {} + +const base = os.$context() + +const router = { + login: base.input(z.object({ user: z.string() })).handler(({ input, context }) => { + context.resHeaders?.append('set-cookie', `session=${input.user}; HttpOnly`) + return input.user + }), + limited: base.input(z.object({ remaining: z.number() })).handler(({ input, context, path, procedure }) => { + const success = input.remaining > 0 + context[RATELIMIT_HANDLER_PLUGIN_CONTEXT_SYMBOL]?.checks.push({ path, procedure, result: { success, limit: 10, remaining: input.remaining, reset: Date.now() + 60_000 } }) + + if (!success) { + throw new ORPCError('TOO_MANY_REQUESTS') + } + + return input.remaining + }), +} + +function createHandler() { + const logs: Record[] = [] + const events: Record[] = [] + const logger = pino({ level: 'info' }, { write: line => logs.push(JSON.parse(line)) }) + + const handler = new OpenRPCHandler(router, { + plugins: [ + new BatchHandlerPlugin(), + new CORSHandlerPlugin(), + new EvlogHandlerPlugin({ drain: ({ event }) => void events.push(event) }), + new PinoHandlerPlugin({ logger, logLifecycle: true }), + new RateLimitHandlerPlugin(), + new RequestCompressionHandlerPlugin(), + new RequestHeadersHandlerPlugin(), + new RequestLimitHandlerPlugin({ maxBodySize: 10 * 1024 }), + new ResponseCompressionHandlerPlugin({ threshold: 0 }), + new ResponseHeadersHandlerPlugin(), + new RethrowHandlerPlugin({ filter: () => false }), + new SignalSyncHandlerPlugin(), + new TimeoutHandlerPlugin({ timeout: 5000 }), + ], + }) + + return { handler, logs, events } +} + +async function post(handler: OpenRPCHandler, body: unknown, headers: Record = {}) { + const { response } = await handler.handle(new Request('http://localhost/rpc', { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }), { prefix: '/rpc', context: {} }) + + return response! +} + +describe('handler plugins on OpenRPCHandler', () => { + it('apply CORS, compression and response headers to the HTTP response of a batch', async () => { + const { handler } = createHandler() + + const response = await post(handler, [ + { jsonrpc: '2.0', method: 'login', params: { user: 'alice' }, id: 1 }, + { jsonrpc: '2.0', method: 'login', params: { user: 'bob' }, id: 2 }, + ], { 'origin': 'https://example.com', 'accept-encoding': 'gzip' }) + + expect(response.headers.get('access-control-allow-origin')).toBe('*') + expect(response.headers.get('content-encoding')).toBe('gzip') + expect(response.headers.getSetCookie().sort()).toEqual(['session=alice; HttpOnly', 'session=bob; HttpOnly']) + await expect(new Response(response.body!.pipeThrough(new DecompressionStream('gzip'))).json()).resolves.toEqual([ + { jsonrpc: '2.0', id: 1, result: 'alice' }, + { jsonrpc: '2.0', id: 2, result: 'bob' }, + ]) + }) + + it('sets the rate limit headers of the most constrained call, without retry-after since JSON-RPC errors use 200', async () => { + const { handler } = createHandler() + + const response = await post(handler, [ + { jsonrpc: '2.0', method: 'limited', params: { remaining: 5 }, id: 1 }, + { jsonrpc: '2.0', method: 'limited', params: { remaining: 0 }, id: 2 }, + ]) + + expect(response.status).toBe(200) + expect(response.headers.get('ratelimit-remaining')).toBe('0') + expect(response.headers.get('retry-after')).toBeNull() + await expect(response.json()).resolves.toMatchObject([{ result: 5 }, { error: { code: -32029 } }]) + }) + + it('logs one request (and one wide event) per HTTP request, so the calls of a batch share one rpc binding', async () => { + const { handler, logs, events } = createHandler() + + await post(handler, [ + { jsonrpc: '2.0', method: 'login', params: { user: 'alice' }, id: 1 }, + { jsonrpc: '2.0', method: 'limited', params: { remaining: 1 }, id: 2 }, + ]) + + const handled = logs.filter(log => log.msg === 'request handled') + + expect(handled).toHaveLength(1) + expect(handled[0]).toMatchObject({ req: { url: '/rpc', method: 'POST' }, res: { status: 200 } }) + // the per-call interceptors of pino and evlog set `rpc` on the shared request logger, so the last call wins + expect(['login', 'limited']).toContain(handled[0]!.rpc.method) + + await vi.waitFor(() => expect(events).toHaveLength(1)) + expect(events[0]).toMatchObject({ method: 'POST', path: '/rpc', status: 200 }) + expect(['login', 'limited']).toContain(events[0]!.rpc.method) + }) + + it('applies the request limit to the whole HTTP request', async () => { + const { handler } = createHandler() + + const response = await post(handler, Array.from({ length: 200 }, (_, id) => ({ jsonrpc: '2.0', method: 'login', params: { user: 'x' }, id }))) + + expect(response.status).toBe(413) + }) +}) diff --git a/tests/plugins/all-plugins.test.ts b/tests/plugins/all-plugins.test.ts index b08f467053..0785c126f3 100644 --- a/tests/plugins/all-plugins.test.ts +++ b/tests/plugins/all-plugins.test.ts @@ -1,6 +1,5 @@ import type { RetryLinkPluginContext } from '@orpc/client/plugins' import type { OpenAPIDocument } from '@orpc/openapi' -import type { OpenRPCDocument } from '@orpc/openrpc' import type { RouterClient } from '@orpc/server' import type { RequestHeadersHandlerPluginContext, ResponseHeadersHandlerPluginContext } from '@orpc/server/plugins' import { createORPCClient } from '@orpc/client' @@ -21,7 +20,6 @@ import { HibernationHandlerPlugin } from '@orpc/hibernation' import { SmartCoercionHandlerPlugin, SmartCoercionLinkPlugin } from '@orpc/json-schema' import { BatchResponseCompressionHandlerPlugin, StaticFileHandlerPlugin, TmpFileUploadHandlerPlugin } from '@orpc/node' import { OpenAPIReferenceHandlerPlugin } from '@orpc/openapi/plugins' -import { OpenRPCDiscoveryHandlerPlugin } from '@orpc/openrpc/plugins' import { PinoHandlerPlugin } from '@orpc/pino' import { RateLimitHandlerPlugin } from '@orpc/ratelimit' import { implement } from '@orpc/server' @@ -69,12 +67,6 @@ const spec: OpenAPIDocument<'3.2.0'> = { info: { title: 'All Plugins', version: '1.0.0' }, } -const openrpcSpec: OpenRPCDocument = { - openrpc: '1.3.2', - info: { title: 'All Plugins', version: '1.0.0' }, - methods: [], -} - const implementer = implement(contract).$context() const router = implementer.router({ @@ -96,7 +88,6 @@ function createHandlerPlugins() { new HibernationHandlerPlugin(), new MethodOverrideHandlerPlugin(), new OpenAPIReferenceHandlerPlugin({ spec, docsPath: '/docs', specPath: '/spec.json' }), - new OpenRPCDiscoveryHandlerPlugin({ spec: openrpcSpec, specPath: '/openrpc.json' }), new PinoHandlerPlugin({ logger: pino({ level: 'silent' }) }), new RateLimitHandlerPlugin(), new RequestCompressionHandlerPlugin(), @@ -228,16 +219,4 @@ describe('all plugins on one handler and one link', () => { expect(response?.status).toEqual(200) await expect(response?.json()).resolves.toEqual(spec) }) - - it('serves the openrpc document behind the other routing plugins', async () => { - const { handler } = createClientServer() - - const { response } = await handler.handle(new Request('http://localhost/rpc/openrpc.json'), { - context: {}, - prefix: '/rpc', - }) - - expect(response?.status).toEqual(200) - await expect(response?.json()).resolves.toEqual(openrpcSpec) - }) }) From 9f53db89e41fad1adb7a43ba8dbe6adb03f29458 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 03:15:53 +0000 Subject: [PATCH 5/7] fix(openrpc): answer each call of a refused WebSocket message A WebSocket client matches responses by id, so when a routing interceptor answers, declines, or a plugin rejects a whole message, each of its calls now gets the error under its own id instead of a single null-id error or no reply. - rpc.discover rethrows unexpected spec errors, like any other call - Request-level plugins sort around core's tracing plugin, as in StandardHandler, so CORS answers preflights before logging plugins see them - Document that rpc.discover only runs inside routing interceptors Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ETTyGHAmagRW7GK4kevsX5 --- apps/content/docs/openrpc/handler.mdx | 2 +- .../adapters/fetch/openrpc-handler.test.ts | 4 +- .../standard/openrpc-handler-codec.test.ts | 2 +- .../standard/openrpc-handler-codec.ts | 2 +- .../openrpc-handler-discovery.test.ts | 25 +++++++ .../src/adapters/standard/openrpc-handler.ts | 74 ++++++++++++++++--- .../src/adapters/standard/utils.test.ts | 2 +- .../websocket/openrpc-handler.test.ts | 41 +++++++++- tests/openrpc/plugins.test.ts | 18 +++++ 9 files changed, 152 insertions(+), 18 deletions(-) diff --git a/apps/content/docs/openrpc/handler.mdx b/apps/content/docs/openrpc/handler.mdx index ef622a7edf..bd2d6e14b7 100644 --- a/apps/content/docs/openrpc/handler.mdx +++ b/apps/content/docs/openrpc/handler.mdx @@ -146,4 +146,4 @@ const handler = new OpenRPCHandler(router, { }) ``` -Routing interceptors run once per HTTP request (or WebSocket message), around all the calls of a batch, and can answer the whole request, such as to require authentication. Plugins built on them apply to the HTTP request itself, such as [CORS](/docs/plugins/cors), [Request Limit](/docs/plugins/request-limit), and [Response Headers](/docs/plugins/response-headers), and logging plugins such as [Pino](/docs/integrations/pino) log once per HTTP request. The response headers of the calls in a batch are merged. The [Batch Plugin](/docs/plugins/batch) is not needed, since JSON-RPC batches requests natively. +Routing interceptors run once per HTTP request (or WebSocket message), around all the calls of a batch, and can answer the whole request, such as to require authentication. Over WebSocket, such an answer is sent as an error for each call of the message. `rpc.discover`, answered by the [discovery option](/docs/openrpc/specification#service-discovery), only runs inside routing interceptors. Plugins built on them apply to the HTTP request itself, such as [CORS](/docs/plugins/cors), [Request Limit](/docs/plugins/request-limit), and [Response Headers](/docs/plugins/response-headers), and logging plugins such as [Pino](/docs/integrations/pino) log once per HTTP request. The response headers of the calls in a batch are merged. The [Batch Plugin](/docs/plugins/batch) is not needed, since JSON-RPC batches requests natively. diff --git a/packages/openrpc/src/adapters/fetch/openrpc-handler.test.ts b/packages/openrpc/src/adapters/fetch/openrpc-handler.test.ts index fab4969b8b..06835f5c6e 100644 --- a/packages/openrpc/src/adapters/fetch/openrpc-handler.test.ts +++ b/packages/openrpc/src/adapters/fetch/openrpc-handler.test.ts @@ -47,7 +47,7 @@ describe('openRPCHandler', () => { expect(misMatchPrefixResult.response).toBeUndefined() }) - it('passes options to the codec and the envelope plugin', async () => { + it('passes the handler options through', async () => { const handler = new OpenRPCHandler({ ping: os.handler(() => 'pong'), hidden: os.handler(() => 'hidden'), @@ -67,7 +67,7 @@ describe('openRPCHandler', () => { await expect(call({ jsonrpc: '2.0', method: 'hidden', id: 1 })).resolves.toMatchObject({ status: 200, json: { error: { code: -32601 } } }) await expect(call({ jsonrpc: '2.0', method: 'find', id: 1 })).resolves.toMatchObject({ status: 200, json: { error: { code: 404 } } }) - // the error code map replaces the common one, for the errors of the envelope too + // the error code map replaces the common one, also for the errors the handler raises itself (here maxBatchSize) await expect(call([{ jsonrpc: '2.0', method: 'ping', id: 1 }, { jsonrpc: '2.0', method: 'ping', id: 2 }])) .resolves .toMatchObject({ status: 413, json: { error: { code: -32000, data: { code: 'PAYLOAD_TOO_LARGE' } } } }) diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler-codec.test.ts b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.test.ts index b1716554b8..865468434b 100644 --- a/packages/openrpc/src/adapters/standard/openrpc-handler-codec.test.ts +++ b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.test.ts @@ -12,7 +12,7 @@ beforeEach(() => { describe('openRPCHandlerCodec', () => { /** - * A sub-request the envelope plugin creates for a JSON-RPC call, or a raw HTTP request when `method` is undefined. + * A sub-request `StandardOpenRPCHandler` creates for a JSON-RPC call, or a raw HTTP request when `method` is undefined. */ function createRequest(method: string | undefined, params?: unknown, overrides: Partial = {}): StandardLazyRequest { return { diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts index 22b5b59043..92ee58ebe2 100644 --- a/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts +++ b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts @@ -99,7 +99,7 @@ export class OpenRPCHandlerCodec implements StandardHandlerCo encodeError(error: AnyORPCError): StandardResponse { return { - // the status per-call plugins (such as tracing) see, the HTTP status of JSON-RPC errors is always 200 + // `StandardOpenRPCHandler` keeps only the body and headers, the HTTP status of JSON-RPC errors is always 200 status: getOwn(COMMON_ERROR_STATUS_MAP, error.code) ?? DEFAULT_ERROR_STATUS, headers: {}, body: { error: encodeJsonRpcError(error, this.serializer, this.errorCodeMap) }, diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler-discovery.test.ts b/packages/openrpc/src/adapters/standard/openrpc-handler-discovery.test.ts index 2a891db5ad..f55a341b8c 100644 --- a/packages/openrpc/src/adapters/standard/openrpc-handler-discovery.test.ts +++ b/packages/openrpc/src/adapters/standard/openrpc-handler-discovery.test.ts @@ -83,6 +83,31 @@ describe('standardOpenRPCHandler discovery', () => { }) }) + it('rethrows unexpected spec errors for a single call, and answers -32603 in a batch', async ({ onTestFinished }) => { + const rejectSpy = vi.spyOn(Promise, 'reject').mockImplementation(() => new Promise(() => {}) as Promise) + + onTestFinished(() => { + rejectSpy.mockRestore() + }) + + const handler = createHandler({ + discovery: { + spec: () => { + throw new TypeError('generator bug') + }, + }, + }) + + await expect(post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).rejects.toThrow('generator bug') + expect(rejectSpy).not.toHaveBeenCalled() + + expect((await post(handler, [{ jsonrpc: '2.0', method: 'rpc.discover', id: 1 }, { jsonrpc: '2.0', method: 'planet.find', id: 2 }])).json).toEqual([ + { jsonrpc: '2.0', id: 1, error: { code: -32603, message: 'Internal error' } }, + { jsonrpc: '2.0', id: 2, result: 'Earth' }, + ]) + expect(rejectSpy).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ message: 'generator bug' })) + }) + it('answers the GET of a document spec refuses with the status of the error', async () => { const handler = createHandler({ discovery: { diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler.ts b/packages/openrpc/src/adapters/standard/openrpc-handler.ts index 674fa733bb..c120015575 100644 --- a/packages/openrpc/src/adapters/standard/openrpc-handler.ts +++ b/packages/openrpc/src/adapters/standard/openrpc-handler.ts @@ -4,16 +4,17 @@ import type { StandardHandlerHandleOptions, StandardHandlerHandleResult, StandardHandlerOptions, + StandardHandlerPlugin, StandardHandlerRoutingInterceptor, StandardHandlerRoutingInterceptorOptions, } from '@orpc/server/standard' import type { Promisable, Public, Value } from '@orpc/shared' import type { StandardHeaders, StandardLazyRequest, StandardUrl } from '@standard-server/core' -import type { JsonRpcId, JsonRpcResponse } from '../../json-rpc' +import type { JsonRpcErrorObject, JsonRpcId, JsonRpcResponse } from '../../json-rpc' import type { OpenRPCDocument } from '../../types' import type { OpenRPCHandlerCodecOptions } from './openrpc-handler-codec' import type { JsonRpcCall } from './utils' -import { COMMON_ERROR_STATUS_MAP, ORPCError, toORPCError } from '@orpc/client' +import { COMMON_ERROR_STATUS_MAP, ORPCError } from '@orpc/client' import { OpenAPIJsonSerializer } from '@orpc/openapi' import { DEFAULT_ERROR_STATUS } from '@orpc/server' import { StandardHandler } from '@orpc/server/standard' @@ -85,6 +86,7 @@ function reply(body: JsonRpcResponse | JsonRpcResponse[] | undefined, headers: S * Plugins hook in at two levels: `routingInterceptors` run once per HTTP request (or message), such as CORS * or the request limit, while each call runs through `StandardHandler` as its own sub-request, with its own * tracing span, `interceptors`, and `clientInterceptors`. Interceptors read the call with `getJsonRpcCall`. + * `rpc.discover`, answered by the `discovery` option, only runs inside the routing interceptors. * * @see {@link https://orpc.dev/docs/openrpc/handler#interceptors | OpenRPC Handler - Interceptors} */ @@ -98,7 +100,8 @@ export class StandardOpenRPCHandler extends StandardHandler, options: NoInfer> = {}) { let initialized: StandardHandlerOptions = options - for (const plugin of sortPlugins(toArray(options.plugins))) { + // core's per-call tracing plugin is not initialized here, but takes part in the sort, so plugins ordered around it keep their order + for (const plugin of sortPlugins>([...toArray(options.plugins), { name: '~tracing' }])) { initialized = plugin.init?.(initialized) ?? initialized } @@ -156,10 +159,59 @@ export class StandardOpenRPCHandler extends StandardHandler message } const context = await value(options.context, request) as T - return intercept(this.requestInterceptors, { context, prefix, request }, routed => this.dispatch(routed)) + // a WebSocket client matches responses by `id`, so a refusal of the whole message answers each of its calls + const refuse = (error: JsonRpcErrorObject) => this.answerEachCall(message, error) + + let dispatched = false + + const result = await intercept(this.requestInterceptors, { context, prefix, request }, (routed) => { + dispatched = true + return this.dispatch(routed, error => refuse(encodeJsonRpcError(error, this.serializer, this.errorCodeMap))) + }) + + if (dispatched) { + return result + } + + // a routing interceptor answered the message itself, such as an authentication guard + if (!result.matched) { + return refuse({ code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND, message: PROTOCOL_ERROR_MESSAGES[JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND] }) + } + + const { status, body } = result.response + + if (hasJsonRpcResultOrError(body) && 'error' in body) { + return refuse(body.error) + } + + const code = Object.keys(COMMON_ERROR_STATUS_MAP).find(code => getOwn(COMMON_ERROR_STATUS_MAP, code) === status) ?? 'INTERNAL_SERVER_ERROR' + + return refuse(encodeJsonRpcError(new ORPCError(code), this.serializer, this.errorCodeMap)) } - private async dispatch(options: StandardHandlerRoutingInterceptorOptions): Promise { + /** + * Answers each call of a message with the same error. Notifications are never answered, + * and a message without readable calls is answered with a `null` id. + */ + private answerEachCall(message: unknown, error: JsonRpcErrorObject): StandardHandlerHandleResult { + const answer = (item: unknown): JsonRpcResponse | undefined => { + const id = isPlainObject(item) ? getOwn(item, 'id') : null + return id === undefined ? undefined : { jsonrpc: JSON_RPC_VERSION, id: isJsonRpcId(id) ? id : null, error } + } + + if (!Array.isArray(message) || message.length === 0) { + return reply(answer(Array.isArray(message) ? null : message)) + } + + const responses = message.flatMap(item => answer(item) ?? []) + + return reply(responses.length ? responses : undefined) + } + + private async dispatch( + options: StandardHandlerRoutingInterceptorOptions, + reject = (error: AnyORPCError) => this.reject(error), + ): Promise { let payload: unknown try { @@ -168,7 +220,7 @@ export class StandardOpenRPCHandler extends StandardHandler extends StandardHandler maxBatchSize) { - return this.reject(new ORPCError('PAYLOAD_TOO_LARGE', { message: `Batch size exceeds the maximum of ${maxBatchSize}.` })) + return reject(new ORPCError('PAYLOAD_TOO_LARGE', { message: `Batch size exceeds the maximum of ${maxBatchSize}.` })) } const results = await Promise.all(payload.map(item => this.call(options, item, true))) @@ -273,14 +325,18 @@ export class StandardOpenRPCHandler extends StandardHandler): Promise { try { return { matched: true, response: { status: 200, headers: {}, body: { result: await value(this.discovery!.spec, options) } } } } catch (error) { - return { matched: true, response: { status: 200, headers: {}, body: { error: encodeJsonRpcError(toORPCError(error), this.serializer, this.errorCodeMap) } } } + if (!(error instanceof ORPCError)) { + throw error + } + + return { matched: true, response: { status: 200, headers: {}, body: { error: encodeJsonRpcError(error, this.serializer, this.errorCodeMap) } } } } } diff --git a/packages/openrpc/src/adapters/standard/utils.test.ts b/packages/openrpc/src/adapters/standard/utils.test.ts index 90113844d6..18dcb08c00 100644 --- a/packages/openrpc/src/adapters/standard/utils.test.ts +++ b/packages/openrpc/src/adapters/standard/utils.test.ts @@ -5,7 +5,7 @@ describe('getJsonRpcCall', () => { const request: StandardLazyRequest = { method: 'POST', url: '/rpc', headers: {}, resolveBody: vi.fn() } const call = { method: 'planet.find', id: 1 } - it('returns the call of a sub-request marked by the envelope', () => { + it('returns the call of a sub-request marked by StandardOpenRPCHandler', () => { expect(getJsonRpcCall({ ...request, [JSON_RPC_CALL_SYMBOL]: call } as StandardLazyRequest)).toBe(call) }) diff --git a/packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts b/packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts index a6405c139f..f0b2606ed1 100644 --- a/packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts +++ b/packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts @@ -1,12 +1,13 @@ import type { StandardHandlerPlugin } from '@orpc/server/standard' import type { AddressInfo } from 'node:net' import { once } from 'node:events' -import { ORPCError } from '@orpc/client' +import { createORPCClient, ORPCError } from '@orpc/client' import { os } from '@orpc/server' import { AbortError, promiseWithResolvers } from '@orpc/shared' import WebSocket, { WebSocketServer } from 'ws' import { getJsonRpcCall } from '../standard' import { OpenRPCHandler } from './openrpc-handler' +import { OpenRPCLink } from './openrpc-link' describe('openRPCHandler', () => { beforeEach(() => { @@ -108,7 +109,8 @@ describe('openRPCHandler', () => { expect(getSentMessages(ws)).toMatchObject([ { id: 1, error: { code: -32601 } }, - { id: null, error: { code: -32013, data: { code: 'PAYLOAD_TOO_LARGE' } } }, + // a WebSocket client matches responses by id, so each call of a refused message is answered + [{ id: 1, error: { code: -32013, data: { code: 'PAYLOAD_TOO_LARGE' } } }, { id: 2, error: { code: -32013 } }], ]) }) @@ -150,7 +152,40 @@ describe('openRPCHandler', () => { const ws = createWs() await expect(handler.message(ws, request)).resolves.toEqual({ matched: true }) - expect(getSentMessages(ws)).toMatchObject([{ jsonrpc: '2.0', id: null, error: { code: -32013, data: { code: 'PAYLOAD_TOO_LARGE' } } }]) + expect(getSentMessages(ws)).toMatchObject([{ jsonrpc: '2.0', id: 1, error: { code: -32013, data: { code: 'PAYLOAD_TOO_LARGE' } } }]) + }) + + it.each([ + // the error of the response status, or "Method not found" + ['answered by a routing interceptor', { matched: true, response: { status: 401, headers: {}, body: undefined } }, -32001], + ['declined by a routing interceptor', { matched: false }, -32601], + ] as const)('answers each call of a message %s', async (_, result, code) => { + const handler = createHandler({ routingInterceptors: [async () => result] }) + const ws = createWs() + + await handler.message(ws, JSON.stringify([call('ping', 1), call('ping'), call('ping', 'b')])) + await handler.message(ws, JSON.stringify(call('ping'))) + await handler.message(ws, '[]') + + expect(getSentMessages(ws)).toMatchObject([ + [{ id: 1, error: { code } }, { id: 'b', error: { code } }], + { id: null, error: { code } }, + ]) + }) + + it('answers the calls of a refused message for the OpenRPC link', async () => { + const handler = createHandler({ + routingInterceptors: [async ({ next, ...options }) => options.context.userId + ? next() + : { matched: true, response: { status: 401, headers: {}, body: undefined } }], + }) + const wss = new WebSocketServer({ port: 0 }) + wss.on('connection', ws => handler.upgrade(ws, { context: {} })) + onTestFinished(() => wss.close()) + + const client = createORPCClient(new OpenRPCLink({ connect: () => new WebSocket(`ws://localhost:${(wss.address() as AddressInfo).port}`) })) + + await expect(client.ping()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }) }) it('aborts the in-flight calls of a closed socket only', async () => { diff --git a/tests/openrpc/plugins.test.ts b/tests/openrpc/plugins.test.ts index 81d42e8f83..53ad77a6f7 100644 --- a/tests/openrpc/plugins.test.ts +++ b/tests/openrpc/plugins.test.ts @@ -134,6 +134,24 @@ describe('handler plugins on OpenRPCHandler', () => { expect(['login', 'limited']).toContain(events[0]!.rpc.method) }) + it('keeps the order core gives plugins around tracing, so CORS answers preflights before logging plugins see them', async () => { + const logs: Record[] = [] + const handler = new OpenRPCHandler(router, { + plugins: [ + new CORSHandlerPlugin(), + new PinoHandlerPlugin({ logger: pino({ level: 'info' }, { write: line => logs.push(JSON.parse(line)) }), logLifecycle: true }), + ], + }) + + const { response } = await handler.handle(new Request('http://localhost/rpc', { + method: 'OPTIONS', + headers: { 'origin': 'https://example.com', 'access-control-request-method': 'POST' }, + }), { prefix: '/rpc', context: {} }) + + expect(response?.status).toBe(204) + expect(logs).toEqual([]) + }) + it('applies the request limit to the whole HTTP request', async () => { const { handler } = createHandler() From dc2a367bf42cc598a80da27ab2f0bc406c7f7421 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 04:08:33 +0000 Subject: [PATCH 6/7] refactor(openrpc): simplify StandardOpenRPCHandler - Refuse each call of a WebSocket message by the final answer (not matched, or a status of 400 or more), so a routing interceptor that refuses after next() is answered per call too, without a dispatched flag or a reject callback - Encode errors with the codec's encodeError instead of a second serializer and error code map - Resolve the discovery spec once for rpc.discover and the GET endpoint - Build a call's sub-request only when it runs through StandardHandler - Move the discovery tests into the handler tests, and drop duplicated tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ETTyGHAmagRW7GK4kevsX5 --- .../standard/openrpc-handler-codec.ts | 5 +- .../openrpc-handler-discovery.test.ts | 186 ------------------ .../adapters/standard/openrpc-handler.test.ts | 163 +++++++++++++-- .../src/adapters/standard/openrpc-handler.ts | 130 ++++++------ .../standard/openrpc-link-codec.test.ts | 11 +- .../websocket/openrpc-handler.test.ts | 14 +- tests/openrpc/plugins.test.ts | 8 - 7 files changed, 226 insertions(+), 291 deletions(-) delete mode 100644 packages/openrpc/src/adapters/standard/openrpc-handler-discovery.test.ts diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts index 92ee58ebe2..a3cebfe118 100644 --- a/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts +++ b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts @@ -4,6 +4,7 @@ import type { AnyProcedure, AnyRouter, Context } from '@orpc/server' import type { StandardHandlerCodec, StandardHandlerCodecResolvedProcedure } from '@orpc/server/standard' import type { Public, Value } from '@orpc/shared' import type { StandardLazyRequest, StandardResponse } from '@standard-server/core' +import type { JsonRpcErrorObject } from '../../json-rpc' import { COMMON_ERROR_STATUS_MAP, ORPCError } from '@orpc/client' import { OpenAPIJsonSerializer } from '@orpc/openapi' import { DEFAULT_ERROR_STATUS } from '@orpc/server' @@ -97,9 +98,9 @@ export class OpenRPCHandlerCodec implements StandardHandlerCo return { status: 200, headers: {}, body: { result: json } } } - encodeError(error: AnyORPCError): StandardResponse { + encodeError(error: AnyORPCError): StandardResponse & { body: { error: JsonRpcErrorObject } } { return { - // `StandardOpenRPCHandler` keeps only the body and headers, the HTTP status of JSON-RPC errors is always 200 + // the HTTP status of JSON-RPC errors is always 200, except for whole requests `StandardOpenRPCHandler` rejects status: getOwn(COMMON_ERROR_STATUS_MAP, error.code) ?? DEFAULT_ERROR_STATUS, headers: {}, body: { error: encodeJsonRpcError(error, this.serializer, this.errorCodeMap) }, diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler-discovery.test.ts b/packages/openrpc/src/adapters/standard/openrpc-handler-discovery.test.ts deleted file mode 100644 index f55a341b8c..0000000000 --- a/packages/openrpc/src/adapters/standard/openrpc-handler-discovery.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { OpenRPCDocument } from '../../types' -import type { OpenRPCHandlerOptions } from '../fetch' -import { ORPCError } from '@orpc/client' -import { os } from '@orpc/server' -import { OpenRPCHandler } from '../fetch' - -describe('standardOpenRPCHandler discovery', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - function createSpec(title = 'Planets'): OpenRPCDocument { - return { openrpc: '1.3.2', info: { title, version: '1.0.0' }, methods: [] } - } - - const discover = vi.fn() - const router = { rpc: { discover: os.handler(discover) }, planet: { find: os.handler(() => 'Earth') } } - - function createHandler(options: OpenRPCHandlerOptions = {}) { - return new OpenRPCHandler(router, options) - } - - async function post(handler: OpenRPCHandler, body: unknown, prefix: `/${string}` = '/rpc') { - const { response } = await handler.handle(new Request(`http://localhost${prefix}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }), { prefix, context: { user: 'alice' } }) - - return { status: response!.status, json: response!.status === 204 ? undefined : await response!.json() } - } - - it('answers rpc.discover in single, batch and notification requests, never calling a user rpc.discover procedure', async () => { - const spec = createSpec() - const handler = createHandler({ discovery: { spec: () => spec } }) - - await expect(post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).resolves.toEqual({ - status: 200, - json: { jsonrpc: '2.0', id: 1, result: spec }, - }) - await expect(post(handler, [{ jsonrpc: '2.0', method: 'rpc.discover', id: 1 }, { jsonrpc: '2.0', method: 'planet.find', id: 2 }])).resolves.toEqual({ - status: 200, - json: [{ jsonrpc: '2.0', id: 1, result: spec }, { jsonrpc: '2.0', id: 2, result: 'Earth' }], - }) - await expect(post(handler, { jsonrpc: '2.0', method: 'rpc.discover' })).resolves.toEqual({ status: 204, json: undefined }) - expect(discover).not.toHaveBeenCalled() - }) - - it('passes the routed context, prefix and request to spec', async () => { - const spec = vi.fn(() => createSpec()) - const handler = createHandler({ discovery: { spec } }) - - await post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 }) - - expect(spec).toHaveBeenCalledExactlyOnceWith({ context: { user: 'alice' }, prefix: '/rpc', request: expect.objectContaining({ url: '/rpc' }) }) - }) - - it('answers rpc.discover with -32601 without the discovery option', async () => { - await expect(post(createHandler(), { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).resolves.toEqual({ - status: 200, - json: { jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found' } }, - }) - expect(discover).not.toHaveBeenCalled() - }) - - it('answers the errors thrown by spec, so spec can guard the document', async () => { - const handler = createHandler({ - discovery: { - spec: ({ context }) => { - if (context.user !== 'admin') { - throw new ORPCError('FORBIDDEN') - } - - return createSpec() - }, - }, - }) - - expect((await post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).json).toEqual({ - jsonrpc: '2.0', - id: 1, - error: { code: -32003, message: 'Forbidden', data: { code: 'FORBIDDEN', defined: false } }, - }) - }) - - it('rethrows unexpected spec errors for a single call, and answers -32603 in a batch', async ({ onTestFinished }) => { - const rejectSpy = vi.spyOn(Promise, 'reject').mockImplementation(() => new Promise(() => {}) as Promise) - - onTestFinished(() => { - rejectSpy.mockRestore() - }) - - const handler = createHandler({ - discovery: { - spec: () => { - throw new TypeError('generator bug') - }, - }, - }) - - await expect(post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).rejects.toThrow('generator bug') - expect(rejectSpy).not.toHaveBeenCalled() - - expect((await post(handler, [{ jsonrpc: '2.0', method: 'rpc.discover', id: 1 }, { jsonrpc: '2.0', method: 'planet.find', id: 2 }])).json).toEqual([ - { jsonrpc: '2.0', id: 1, error: { code: -32603, message: 'Internal error' } }, - { jsonrpc: '2.0', id: 2, result: 'Earth' }, - ]) - expect(rejectSpy).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ message: 'generator bug' })) - }) - - it('answers the GET of a document spec refuses with the status of the error', async () => { - const handler = createHandler({ - discovery: { - spec: () => { - throw new ORPCError('FORBIDDEN') - }, - }, - }) - - const { response } = await handler.handle(new Request('http://localhost/rpc/openrpc.json'), { prefix: '/rpc' }) - - expect(response?.status).toBe(403) - await expect(response?.json()).resolves.toMatchObject({ id: null, error: { code: -32003, data: { code: 'FORBIDDEN' } } }) - }) - - it('serves the document over GET at /openrpc.json under the prefix', async () => { - const spec = createSpec() - const handler = createHandler({ discovery: { spec } }) - - const { response } = await handler.handle(new Request('http://localhost/rpc/openrpc.json?pretty'), { prefix: '/rpc' }) - - expect(response?.status).toBe(200) - expect(response?.headers.get('content-type')).toContain('application/json') - await expect(response?.json()).resolves.toEqual(spec) - - // without a prefix - await expect((await createHandler({ discovery: { spec } }).handle(new Request('http://localhost/openrpc.json'))).response?.json()).resolves.toEqual(spec) - }) - - it('serves the document from a custom spec path relative to the prefix', async () => { - const spec = vi.fn(async () => createSpec('Custom')) - const handler = createHandler({ discovery: { spec, specPath: '/spec.json' } }) - - const { response } = await handler.handle(new Request('http://localhost/rpc/spec.json'), { prefix: '/rpc', context: { user: 'alice' } }) - - await expect(response?.json()).resolves.toEqual(createSpec('Custom')) - expect(spec).toHaveBeenCalledExactlyOnceWith({ context: { user: 'alice' }, prefix: '/rpc', request: expect.objectContaining({ url: '/rpc/spec.json' }) }) - - for (const url of ['http://localhost/spec.json', 'http://localhost/rpc/openrpc.json']) { - expect((await handler.handle(new Request(url), { prefix: '/rpc' })).matched).toBe(false) - } - }) - - it('disables the GET endpoint with specPath false but still answers rpc.discover', async () => { - const handler = createHandler({ discovery: { spec: createSpec(), specPath: false } }) - - expect((await handler.handle(new Request('http://localhost/rpc/openrpc.json'), { prefix: '/rpc' })).matched).toBe(false) - expect((await post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).json).toMatchObject({ result: createSpec() }) - }) - - it.each([ - ['POST', 'http://localhost/rpc/openrpc.json'], - ['GET', 'http://localhost/rpc/other.json'], - ['GET', 'http://localhost/rpc'], - ])('does not serve %s %s', async (method, url) => { - const spec = vi.fn(() => createSpec()) - const handler = createHandler({ discovery: { spec } }) - - expect((await handler.handle(new Request(url, { method }), { prefix: '/rpc' })).matched).toBe(false) - expect(spec).not.toHaveBeenCalled() - }) - - it('runs inside the routing interceptors, which can guard the whole request', async () => { - const spec = createSpec() - const handler = createHandler({ - routingInterceptors: [async ({ next, request }) => request.headers.authorization ? next() : { matched: false }], - discovery: { spec }, - }) - - expect((await handler.handle(new Request('http://localhost/openrpc.json'))).matched).toBe(false) - - const { response } = await handler.handle(new Request('http://localhost/openrpc.json', { headers: { authorization: 'token' } })) - - await expect(response?.json()).resolves.toEqual(spec) - }) -}) diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler.test.ts b/packages/openrpc/src/adapters/standard/openrpc-handler.test.ts index abb5181369..dc0ab2ade2 100644 --- a/packages/openrpc/src/adapters/standard/openrpc-handler.test.ts +++ b/packages/openrpc/src/adapters/standard/openrpc-handler.test.ts @@ -1,5 +1,6 @@ import type { ResponseHeadersHandlerPluginContext } from '@orpc/server/plugins' import type { StandardHandlerPlugin } from '@orpc/server/standard' +import type { OpenRPCDocument } from '../../types' import type { OpenRPCHandlerOptions } from '../fetch' import { ORPCError } from '@orpc/client' import { os } from '@orpc/server' @@ -7,13 +8,14 @@ import { CORSHandlerPlugin, RequestLimitHandlerPlugin, ResponseHeadersHandlerPlu import { getTracer, promiseWithResolvers, setTracer } from '@orpc/shared' import { z } from 'zod' import { OpenRPCHandler } from '../fetch' -import { getJsonRpcCall, JSON_RPC_CALL_SYMBOL } from './utils' +import { getJsonRpcCall } from './utils' beforeEach(() => { vi.clearAllMocks() }) const notify = vi.fn() +const discover = vi.fn() const router = { subtract: os @@ -24,6 +26,7 @@ const router = { notify_sum: os.input(z.array(z.number())).handler(({ input }) => notify('notify_sum', input)), notify_hello: os.input(z.number()).handler(({ input }) => notify('notify_hello', input)), get_data: os.handler(() => ['hello', 5]), + rpc: { discover: os.handler(discover) }, planet: { list: os.input(z.object({ limit: z.number().optional() })).handler(({ input }) => input), find: os @@ -277,17 +280,6 @@ describe('standardOpenRPCHandler', () => { expect(matched).toBe(true) await expect(response!.json()).resolves.toEqual({ jsonrpc: '2.0', id: 1, result: ['hello', 5] }) }) - - it('reads the calls from the body, even of a request a routing interceptor marks as a call', async () => { - const handler = createHandler({ - routingInterceptors: [({ next, ...rest }) => next({ - ...rest, - request: { ...rest.request, [JSON_RPC_CALL_SYMBOL]: { method: 'get_data', id: 1 } } as typeof rest.request, - })], - }) - - await expect(post(handler, { jsonrpc: '2.0', method: 'planet.list', id: 2 })).resolves.toEqual({ status: 200, json: { jsonrpc: '2.0', id: 2, result: {} } }) - }) }) describe('plugins and interceptors', () => { @@ -473,4 +465,151 @@ describe('standardOpenRPCHandler', () => { expect(rejectSpy).toHaveBeenCalledWith(expect.objectContaining({ message: 'secret' })) }) }) + + describe('discovery', () => { + function createSpec(title = 'Planets'): OpenRPCDocument { + return { openrpc: '1.3.2', info: { title, version: '1.0.0' }, methods: [] } + } + + it('answers rpc.discover in single, batch and notification requests, never calling a user rpc.discover procedure', async () => { + const spec = createSpec() + const handler = createHandler({ discovery: { spec: () => spec } }) + + await expect(post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).resolves.toEqual({ + status: 200, + json: { jsonrpc: '2.0', id: 1, result: spec }, + }) + await expect(post(handler, [{ jsonrpc: '2.0', method: 'rpc.discover', id: 1 }, { jsonrpc: '2.0', method: 'get_data', id: 2 }])).resolves.toEqual({ + status: 200, + json: [{ jsonrpc: '2.0', id: 1, result: spec }, { jsonrpc: '2.0', id: 2, result: ['hello', 5] }], + }) + await expect(post(handler, { jsonrpc: '2.0', method: 'rpc.discover' })).resolves.toEqual({ status: 204, json: undefined }) + expect(discover).not.toHaveBeenCalled() + }) + + it('passes the routed context, prefix and request to spec', async () => { + const spec = vi.fn(() => createSpec()) + const handler = createHandler({ discovery: { spec } }) + + await post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 }, { context: { user: 'alice' } }) + + expect(spec).toHaveBeenCalledExactlyOnceWith({ context: { user: 'alice' }, prefix: '/rpc', request: expect.objectContaining({ url: '/rpc' }) }) + }) + + it('answers rpc.discover with -32601 without the discovery option', async () => { + await expect(post(createHandler(), { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).resolves.toEqual({ + status: 200, + json: { jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found' } }, + }) + expect(discover).not.toHaveBeenCalled() + }) + + it('answers the errors thrown by spec, so spec can guard the document', async () => { + const handler = createHandler({ + discovery: { + spec: ({ context }) => { + if (context.user !== 'admin') { + throw new ORPCError('FORBIDDEN') + } + + return createSpec() + }, + }, + }) + + expect((await post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).json).toEqual({ + jsonrpc: '2.0', + id: 1, + error: { code: -32003, message: 'Forbidden', data: { code: 'FORBIDDEN', defined: false } }, + }) + }) + + it('rethrows unexpected spec errors, like those of any other call', async () => { + const handler = createHandler({ + discovery: { + spec: () => { + throw new TypeError('generator bug') + }, + }, + }) + + await expect(post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).rejects.toThrow('generator bug') + }) + + it('answers the GET of a document spec refuses with the status of the error', async () => { + const handler = createHandler({ + discovery: { + spec: () => { + throw new ORPCError('FORBIDDEN') + }, + }, + }) + + const { response } = await handler.handle(new Request('http://localhost/rpc/openrpc.json'), { prefix: '/rpc' }) + + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toMatchObject({ id: null, error: { code: -32003, data: { code: 'FORBIDDEN' } } }) + }) + + it('serves the document over GET at /openrpc.json under the prefix', async () => { + const spec = createSpec() + const handler = createHandler({ discovery: { spec } }) + + const { response } = await handler.handle(new Request('http://localhost/rpc/openrpc.json?pretty'), { prefix: '/rpc' }) + + expect(response?.status).toBe(200) + expect(response?.headers.get('content-type')).toContain('application/json') + await expect(response?.json()).resolves.toEqual(spec) + + // without a prefix + await expect((await createHandler({ discovery: { spec } }).handle(new Request('http://localhost/openrpc.json'))).response?.json()).resolves.toEqual(spec) + }) + + it('serves the document from a custom spec path relative to the prefix', async () => { + const spec = vi.fn(async () => createSpec('Custom')) + const handler = createHandler({ discovery: { spec, specPath: '/spec.json' } }) + + const { response } = await handler.handle(new Request('http://localhost/rpc/spec.json'), { prefix: '/rpc', context: { user: 'alice' } }) + + await expect(response?.json()).resolves.toEqual(createSpec('Custom')) + expect(spec).toHaveBeenCalledExactlyOnceWith({ context: { user: 'alice' }, prefix: '/rpc', request: expect.objectContaining({ url: '/rpc/spec.json' }) }) + + for (const url of ['http://localhost/spec.json', 'http://localhost/rpc/openrpc.json']) { + expect((await handler.handle(new Request(url), { prefix: '/rpc' })).matched).toBe(false) + } + }) + + it('disables the GET endpoint with specPath false but still answers rpc.discover', async () => { + const handler = createHandler({ discovery: { spec: createSpec(), specPath: false } }) + + expect((await handler.handle(new Request('http://localhost/rpc/openrpc.json'), { prefix: '/rpc' })).matched).toBe(false) + expect((await post(handler, { jsonrpc: '2.0', method: 'rpc.discover', id: 1 })).json).toMatchObject({ result: createSpec() }) + }) + + it.each([ + ['POST', 'http://localhost/rpc/openrpc.json'], + ['GET', 'http://localhost/rpc/other.json'], + ['GET', 'http://localhost/rpc'], + ])('does not serve %s %s', async (method, url) => { + const spec = vi.fn(() => createSpec()) + const handler = createHandler({ discovery: { spec } }) + + expect((await handler.handle(new Request(url, { method }), { prefix: '/rpc' })).matched).toBe(false) + expect(spec).not.toHaveBeenCalled() + }) + + it('runs inside the routing interceptors, which can guard the whole request', async () => { + const spec = createSpec() + const handler = createHandler({ + routingInterceptors: [async ({ next, request }) => request.headers.authorization ? next() : { matched: false }], + discovery: { spec }, + }) + + expect((await handler.handle(new Request('http://localhost/openrpc.json'))).matched).toBe(false) + + const { response } = await handler.handle(new Request('http://localhost/openrpc.json', { headers: { authorization: 'token' } })) + + await expect(response?.json()).resolves.toEqual(spec) + }) + }) }) diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler.ts b/packages/openrpc/src/adapters/standard/openrpc-handler.ts index c120015575..bbfb20a412 100644 --- a/packages/openrpc/src/adapters/standard/openrpc-handler.ts +++ b/packages/openrpc/src/adapters/standard/openrpc-handler.ts @@ -8,20 +8,18 @@ import type { StandardHandlerRoutingInterceptor, StandardHandlerRoutingInterceptorOptions, } from '@orpc/server/standard' -import type { Promisable, Public, Value } from '@orpc/shared' +import type { Promisable, Value } from '@orpc/shared' import type { StandardHeaders, StandardLazyRequest, StandardUrl } from '@standard-server/core' import type { JsonRpcErrorObject, JsonRpcId, JsonRpcResponse } from '../../json-rpc' import type { OpenRPCDocument } from '../../types' import type { OpenRPCHandlerCodecOptions } from './openrpc-handler-codec' import type { JsonRpcCall } from './utils' import { COMMON_ERROR_STATUS_MAP, ORPCError } from '@orpc/client' -import { OpenAPIJsonSerializer } from '@orpc/openapi' -import { DEFAULT_ERROR_STATUS } from '@orpc/server' import { StandardHandler } from '@orpc/server/standard' import { getOwn, intercept, isPlainObject, matchesHttpPath, matchesHttpPathPrefix, mergeHttpPath, pathToHttpPath, sortPlugins, toArray, value } from '@orpc/shared' import { mergeStandardHeaders } from '@standard-server/core' import { DEFAULT_JSON_RPC_MAX_BATCH_SIZE, JSON_RPC_ERROR_CODES, JSON_RPC_VERSION } from '../../constants' -import { encodeJsonRpcError, hasJsonRpcResultOrError, isJsonRpcId } from '../../json-rpc' +import { hasJsonRpcResultOrError, isJsonRpcId } from '../../json-rpc' import { OpenRPCHandlerCodec } from './openrpc-handler-codec' import { isJsonRpcMediaType, JSON_RPC_CALL_SYMBOL } from './utils' @@ -71,7 +69,7 @@ const PROTOCOL_ERROR_MESSAGES = { [JSON_RPC_ERROR_CODES.INTERNAL_ERROR]: 'Internal error', } -function protocolError(id: JsonRpcId, code: keyof typeof PROTOCOL_ERROR_MESSAGES): JsonRpcResponse { +function protocolError(id: JsonRpcId, code: keyof typeof PROTOCOL_ERROR_MESSAGES): Extract { return { jsonrpc: JSON_RPC_VERSION, id, error: { code, message: PROTOCOL_ERROR_MESSAGES[code] } } } @@ -92,8 +90,7 @@ function reply(body: JsonRpcResponse | JsonRpcResponse[] | undefined, headers: S */ export class StandardOpenRPCHandler extends StandardHandler { private readonly requestInterceptors: StandardHandlerRoutingInterceptor[] | undefined - private readonly serializer: Public - private readonly errorCodeMap: StandardOpenRPCHandlerOptions['errorCodeMap'] + private readonly openrpcCodec: OpenRPCHandlerCodec private readonly maxBatchSize: Exclude['maxBatchSize'], undefined> private readonly discovery: StandardOpenRPCHandlerOptions['discovery'] @@ -105,15 +102,16 @@ export class StandardOpenRPCHandler extends StandardHandler extends StandardHandler message } const context = await value(options.context, request) as T + const result = await intercept(this.requestInterceptors, { context, prefix, request }, routed => this.dispatch(routed)) - // a WebSocket client matches responses by `id`, so a refusal of the whole message answers each of its calls - const refuse = (error: JsonRpcErrorObject) => this.answerEachCall(message, error) - - let dispatched = false - - const result = await intercept(this.requestInterceptors, { context, prefix, request }, (routed) => { - dispatched = true - return this.dispatch(routed, error => refuse(encodeJsonRpcError(error, this.serializer, this.errorCodeMap))) - }) - - if (dispatched) { - return result - } - - // a routing interceptor answered the message itself, such as an authentication guard + // a WebSocket client matches responses by `id`, so a refusal of the whole message, by the handler (such as a batch + // too large) or by a routing interceptor (such as an authentication guard), answers each of its calls instead if (!result.matched) { - return refuse({ code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND, message: PROTOCOL_ERROR_MESSAGES[JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND] }) + return this.answerEachCall(message, protocolError(null, JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND).error) } const { status, body } = result.response + if (status < 400) { + return result + } + if (hasJsonRpcResultOrError(body) && 'error' in body) { - return refuse(body.error) + return this.answerEachCall(message, body.error) } const code = Object.keys(COMMON_ERROR_STATUS_MAP).find(code => getOwn(COMMON_ERROR_STATUS_MAP, code) === status) ?? 'INTERNAL_SERVER_ERROR' - return refuse(encodeJsonRpcError(new ORPCError(code), this.serializer, this.errorCodeMap)) + return this.answerEachCall(message, this.openrpcCodec.encodeError(new ORPCError(code)).body.error) } /** @@ -208,10 +198,7 @@ export class StandardOpenRPCHandler extends StandardHandler, - reject = (error: AnyORPCError) => this.reject(error), - ): Promise { + private async dispatch(options: StandardHandlerRoutingInterceptorOptions): Promise { let payload: unknown try { @@ -220,7 +207,7 @@ export class StandardOpenRPCHandler extends StandardHandler extends StandardHandler maxBatchSize) { - return reject(new ORPCError('PAYLOAD_TOO_LARGE', { message: `Batch size exceeds the maximum of ${maxBatchSize}.` })) + return this.reject(new ORPCError('PAYLOAD_TOO_LARGE', { message: `Batch size exceeds the maximum of ${maxBatchSize}.` })) } const results = await Promise.all(payload.map(item => this.call(options, item, true))) @@ -274,22 +261,26 @@ export class StandardOpenRPCHandler extends StandardHandler params ?? undefined, - [JSON_RPC_CALL_SYMBOL]: { method, id }, - } let result: StandardHandlerHandleResult try { - result = method === 'rpc.discover' && this.discovery - ? await this.discover(options) - : await super.handle(request, { prefix: options.prefix, context: options.context }) + if (method === 'rpc.discover' && this.discovery) { + result = await this.discover(options) + } + else { + const request: StandardLazyRequest & { [JSON_RPC_CALL_SYMBOL]: JsonRpcCall } = { + method: 'POST', + // only for logs and spans, the codec routes by the call + url: mergeHttpPath(options.prefix ?? '/', pathToHttpPath(method.split('.'))), + headers: { ...options.request.headers, 'content-length': undefined }, + signal: options.request.signal, + resolveBody: async () => params ?? undefined, + [JSON_RPC_CALL_SYMBOL]: { method, id }, + } + + result = await super.handle(request, { prefix: options.prefix, context: options.context }) + } } catch (error) { if (!batch) { @@ -324,36 +315,40 @@ export class StandardOpenRPCHandler extends StandardHandler): Promise { - try { - return { matched: true, response: { status: 200, headers: {}, body: { result: await value(this.discovery!.spec, options) } } } - } - catch (error) { - if (!(error instanceof ORPCError)) { - throw error - } + const spec = await this.resolveSpec(options) - return { matched: true, response: { status: 200, headers: {}, body: { error: encodeJsonRpcError(error, this.serializer, this.errorCodeMap) } } } - } + return { matched: true, response: spec instanceof ORPCError ? this.openrpcCodec.encodeError(spec) : { status: 200, headers: {}, body: { result: spec } } } } private async serveSpec(options: StandardHandlerRoutingInterceptorOptions): Promise { const { request, prefix } = options - const specPath = this.discovery?.specPath ?? '/openrpc.json' - if (!this.discovery || specPath === false || !matchesHttpPath(request.url, prefix ? mergeHttpPath(prefix, specPath) : specPath)) { + if (!this.discovery) { + return { matched: false } + } + + const specPath = this.discovery.specPath ?? '/openrpc.json' + + if (specPath === false || !matchesHttpPath(request.url, prefix ? mergeHttpPath(prefix, specPath) : specPath)) { return { matched: false } } + const spec = await this.resolveSpec(options) + + return spec instanceof ORPCError ? this.reject(spec) : { matched: true, response: { status: 200, headers: {}, body: spec } } + } + + /** + * The document, or the `ORPCError` `spec` throws to refuse it. Other errors are unexpected, see `.call`. + */ + private async resolveSpec(options: StandardHandlerRoutingInterceptorOptions): Promise { try { - return { matched: true, response: { status: 200, headers: {}, body: await value(this.discovery.spec, options) } } + return await value(this.discovery!.spec, options) } catch (error) { if (error instanceof ORPCError) { - return this.reject(error) + return error } throw error @@ -364,13 +359,8 @@ export class StandardOpenRPCHandler extends StandardHandler { expect(request.signal).toBe(signal) }) - it('generates sequential base-36 string ids per codec', async () => { + it('generates sequential string ids per codec', async () => { const codec = new OpenRPCLinkCodec() const other = new OpenRPCLinkCodec() const encodeId = async (codec: OpenRPCLinkCodec) => ((await codec.encodeInput('a', ['ping'], { context: {} })).body as { id: string }).id - const ids: string[] = [] - - for (let i = 0; i < 36; i++) { - ids.push(await encodeId(codec)) - } - - expect([ids[0], ids[1], ids[9], ids[35]]).toEqual(['1', '2', 'a', '10']) - await expect(encodeId(other)).resolves.toBe('1') + expect([await encodeId(codec), await encodeId(codec), await encodeId(other)]).toEqual(['1', '2', '1']) }) describe('params', () => { diff --git a/packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts b/packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts index f0b2606ed1..6e019c7c8f 100644 --- a/packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts +++ b/packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts @@ -155,12 +155,18 @@ describe('openRPCHandler', () => { expect(getSentMessages(ws)).toMatchObject([{ jsonrpc: '2.0', id: 1, error: { code: -32013, data: { code: 'PAYLOAD_TOO_LARGE' } } }]) }) + const unauthorized = { matched: true, response: { status: 401, headers: {}, body: undefined } } as const + it.each([ // the error of the response status, or "Method not found" - ['answered by a routing interceptor', { matched: true, response: { status: 401, headers: {}, body: undefined } }, -32001], - ['declined by a routing interceptor', { matched: false }, -32601], - ] as const)('answers each call of a message %s', async (_, result, code) => { - const handler = createHandler({ routingInterceptors: [async () => result] }) + ['answered by a routing interceptor', async () => unauthorized, -32001], + ['answered by a routing interceptor after its calls ran', async ({ next }: any) => { + await next() + return unauthorized + }, -32001], + ['declined by a routing interceptor', async () => ({ matched: false } as const), -32601], + ] as const)('answers each call of a message %s', async (_, interceptor, code) => { + const handler = createHandler({ routingInterceptors: [interceptor] }) const ws = createWs() await handler.message(ws, JSON.stringify([call('ping', 1), call('ping'), call('ping', 'b')])) diff --git a/tests/openrpc/plugins.test.ts b/tests/openrpc/plugins.test.ts index 53ad77a6f7..0dc9443678 100644 --- a/tests/openrpc/plugins.test.ts +++ b/tests/openrpc/plugins.test.ts @@ -151,12 +151,4 @@ describe('handler plugins on OpenRPCHandler', () => { expect(response?.status).toBe(204) expect(logs).toEqual([]) }) - - it('applies the request limit to the whole HTTP request', async () => { - const { handler } = createHandler() - - const response = await post(handler, Array.from({ length: 200 }, (_, id) => ({ jsonrpc: '2.0', method: 'login', params: { user: 'x' }, id }))) - - expect(response.status).toBe(413) - }) }) From 7a9231514b8c2ace5f8103736e6aa23a773cba9d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:46:18 +0000 Subject: [PATCH 7/7] build(openrpc): migrate to obuild transform mode, like the other packages - Add build.config.ts and run obuild on prepack - Annotate COMMON_JSON_RPC_ERROR_CODE_MAP for isolatedDeclarations - Mark side-effect-free module-level calls as pure Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ETTyGHAmagRW7GK4kevsX5 --- packages/openrpc/build.config.ts | 16 ++++++++++++++++ packages/openrpc/package.json | 2 +- .../adapters/standard/openrpc-handler-codec.ts | 2 +- packages/openrpc/src/adapters/standard/utils.ts | 4 ++-- packages/openrpc/src/adapters/websocket/utils.ts | 2 +- packages/openrpc/src/constants.ts | 4 ++-- packages/openrpc/src/json-rpc.ts | 2 +- packages/openrpc/src/openrpc-generator.ts | 2 +- 8 files changed, 25 insertions(+), 9 deletions(-) create mode 100644 packages/openrpc/build.config.ts diff --git a/packages/openrpc/build.config.ts b/packages/openrpc/build.config.ts new file mode 100644 index 0000000000..673bfcaf32 --- /dev/null +++ b/packages/openrpc/build.config.ts @@ -0,0 +1,16 @@ +import { defineBuildConfig } from 'obuild/config' +import { isTestFile, verifyDist } from '../../scripts/build-utils' + +export default defineBuildConfig({ + entries: [ + { + type: 'transform', + input: './src', + outDir: './dist', + filter: file => !isTestFile(file), + }, + ], + hooks: { + end: verifyDist, + }, +}) diff --git a/packages/openrpc/package.json b/packages/openrpc/package.json index 8b02ff1034..d64551ac37 100644 --- a/packages/openrpc/package.json +++ b/packages/openrpc/package.json @@ -80,7 +80,7 @@ "dist" ], "scripts": { - "prepack": "unbuild", + "prepack": "obuild", "type:check": "tsc -b" }, "dependencies": { diff --git a/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts index a3cebfe118..33e90580ca 100644 --- a/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts +++ b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts @@ -141,7 +141,7 @@ async function decodeParams(params: unknown, procedure: AnyProcedure): Promise>() +const emptyInputsAcceptance = /* @__PURE__ */ new WeakMap>() function acceptsEmptyInputs(procedure: AnyProcedure): Promise<{ undefined: boolean, object: boolean }> { let accepts = emptyInputsAcceptance.get(procedure) diff --git a/packages/openrpc/src/adapters/standard/utils.ts b/packages/openrpc/src/adapters/standard/utils.ts index 08baa2a621..6b67fe314b 100644 --- a/packages/openrpc/src/adapters/standard/utils.ts +++ b/packages/openrpc/src/adapters/standard/utils.ts @@ -19,7 +19,7 @@ export interface JsonRpcCall { * Marks the sub-requests `StandardOpenRPCHandler` creates, one per JSON-RPC call. The codec only resolves marked * requests, so a raw HTTP request (whose body or headers cannot carry a symbol) never routes to a procedure. */ -export const JSON_RPC_CALL_SYMBOL: unique symbol = Symbol.for('ORPC_JSON_RPC_CALL') +export const JSON_RPC_CALL_SYMBOL: unique symbol = /* @__PURE__ */ Symbol.for('ORPC_JSON_RPC_CALL') /** * Returns the JSON-RPC call a sub-request represents, or `undefined` for any other request, @@ -34,7 +34,7 @@ export function getJsonRpcCall(request: StandardLazyRequest): JsonRpcCall | unde /** * None of these is CORS-safelisted, so a cross-site request always needs a preflight (CSRF protection). */ -const JSON_RPC_MEDIA_TYPES = new Set(['application/json', 'application/json-rpc', 'application/jsonrequest']) +const JSON_RPC_MEDIA_TYPES = /* @__PURE__ */ new Set(['application/json', 'application/json-rpc', 'application/jsonrequest']) export function isJsonRpcMediaType(contentType: string | readonly string[] | undefined): boolean { return JSON_RPC_MEDIA_TYPES.has(flattenStandardHeader(contentType)?.split(';')[0]?.trim().toLowerCase() ?? '') diff --git a/packages/openrpc/src/adapters/websocket/utils.ts b/packages/openrpc/src/adapters/websocket/utils.ts index 9805c71a55..de1137ab3a 100644 --- a/packages/openrpc/src/adapters/websocket/utils.ts +++ b/packages/openrpc/src/adapters/websocket/utils.ts @@ -1,6 +1,6 @@ import { tryOrUndefined } from '@orpc/shared' -const textDecoder = new TextDecoder() +const textDecoder = /* @__PURE__ */ new TextDecoder() /** * Parses a WebSocket message as JSON, or returns `undefined` when it is not valid JSON. diff --git a/packages/openrpc/src/constants.ts b/packages/openrpc/src/constants.ts index b1ae38f76b..3eca4f96a6 100644 --- a/packages/openrpc/src/constants.ts +++ b/packages/openrpc/src/constants.ts @@ -19,7 +19,7 @@ export const JSON_RPC_ERROR_CODES = { * * @see {@link https://orpc.dev/docs/openrpc/handler#error-codes | OpenRPC Handler - Error Codes} */ -export const COMMON_JSON_RPC_ERROR_CODE_MAP = { +export const COMMON_JSON_RPC_ERROR_CODE_MAP: Record = { BAD_REQUEST: JSON_RPC_ERROR_CODES.INVALID_PARAMS, UNAUTHORIZED: -32001, PAYMENT_REQUIRED: -32002, @@ -43,7 +43,7 @@ export const COMMON_JSON_RPC_ERROR_CODE_MAP = { BAD_GATEWAY: JSON_RPC_ERROR_CODES.INTERNAL_ERROR, SERVICE_UNAVAILABLE: JSON_RPC_ERROR_CODES.INTERNAL_ERROR, GATEWAY_TIMEOUT: JSON_RPC_ERROR_CODES.INTERNAL_ERROR, -} satisfies Record +} /** * The JSON-RPC error code of ORPCError codes missing from the error code map (an implementation-defined server error). diff --git a/packages/openrpc/src/json-rpc.ts b/packages/openrpc/src/json-rpc.ts index 66e165cebb..432bd80934 100644 --- a/packages/openrpc/src/json-rpc.ts +++ b/packages/openrpc/src/json-rpc.ts @@ -69,7 +69,7 @@ export function encodeJsonRpcError( } } -const PROTOCOL_ERROR_ORPC_CODES = new Map([ +const PROTOCOL_ERROR_ORPC_CODES = /* @__PURE__ */ new Map([ [JSON_RPC_ERROR_CODES.PARSE_ERROR, 'BAD_REQUEST'], [JSON_RPC_ERROR_CODES.INVALID_REQUEST, 'BAD_REQUEST'], [JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND, 'NOT_FOUND'], diff --git a/packages/openrpc/src/openrpc-generator.ts b/packages/openrpc/src/openrpc-generator.ts index 540734f1fe..a03bb8b3a0 100644 --- a/packages/openrpc/src/openrpc-generator.ts +++ b/packages/openrpc/src/openrpc-generator.ts @@ -58,7 +58,7 @@ export interface OpenRPCGeneratorGenerateOptions { /** * Root keywords of an object input that by-name params can express, so splitting it into one param per property loses nothing. */ -const BY_NAME_ROOT_KEYWORDS = new Set(['type', 'properties', 'required', 'additionalProperties', 'title', 'description', '$defs', '$comment', 'examples', 'default']) +const BY_NAME_ROOT_KEYWORDS = /* @__PURE__ */ new Set(['type', 'properties', 'required', 'additionalProperties', 'title', 'description', '$defs', '$comment', 'examples', 'default']) /** * Generates an OpenRPC 1.3 document from a contract or router: each procedure becomes a method