diff --git a/README.md b/README.md index e33df73285..53fda9f38a 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..bd2d6e14b7 --- /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 + +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' + +const handler = new OpenRPCHandler(router, { + interceptors: [ + async ({ next, request }) => { + const call = getJsonRpcCall(request) // { method: 'planet.find', id: 1 } + return next() + }, + ], +}) +``` + +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/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..e13cf2892f --- /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..f6810371dd --- /dev/null +++ b/apps/content/docs/openrpc/specification.mdx @@ -0,0 +1,86 @@ +--- +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 + +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 +const handler = new OpenRPCHandler(router, { + 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/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 a8b0b66bc8..186a88ef1f 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 9dbc1f0c94..f77e17a307 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 1c62cd7acf..94131cf271 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 ef503878cb..bbb7b1ab10 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 e33df73285..53fda9f38a 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 7ea11b29cc..28f96dea2b 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 fc200d2268..b14fa91e85 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 1b6aaadc55..d290d019e6 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 55e174eada..578304694d 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 8fda40f941..03e4a851eb 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 3069d8fb8e..6f572cc9aa 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 3d8bb83fd1..88b05369b7 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 bf20c1ee86..10d2d54228 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 8dbc51baa1..c416023cae 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 c0022d6f4f..a1762337e1 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 7010a76a07..4b158ffed5 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 60a768a01a..c6e33f5fb6 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 dcfa69a2fd..a2e5f876bb 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 πŸͺ„

+ +
+ + codecov + + + weekly downloads + + + CodSpeed + + + MIT License + + + Discord + + + Ask DeepWiki + +
+ +**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/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 new file mode 100644 index 0000000000..d64551ac37 --- /dev/null +++ b/packages/openrpc/package.json @@ -0,0 +1,101 @@ +{ + "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" + }, + "./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", + "./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": "obuild", + "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" + }, + "devDependencies": { + "@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..f8f8d338e2 --- /dev/null +++ b/packages/openrpc/src/adapters/aws-lambda/openrpc-handler.ts @@ -0,0 +1,21 @@ +import type { Context, Router } from '@orpc/server' +import type { AwsLambdaHandlerOptions } from '@orpc/server/aws-lambda' +import type { StandardOpenRPCHandlerOptions } from '../standard' +import { AwsLambdaHandler } from '@orpc/server/aws-lambda' +import { StandardOpenRPCHandler } from '../standard' + +export interface OpenRPCHandlerOptions extends AwsLambdaHandlerOptions, StandardOpenRPCHandlerOptions {} + +/** + * 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> = {}) { + super(new StandardOpenRPCHandler(router, options), 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..04a470c440 --- /dev/null +++ b/packages/openrpc/src/adapters/fastify/openrpc-handler.ts @@ -0,0 +1,18 @@ +import type { Context, Router } from '@orpc/server' +import type { FastifyHandlerOptions } from '@orpc/server/fastify' +import type { StandardOpenRPCHandlerOptions } from '../standard' +import { FastifyHandler } from '@orpc/server/fastify' +import { StandardOpenRPCHandler } from '../standard' + +export interface OpenRPCHandlerOptions extends FastifyHandlerOptions, StandardOpenRPCHandlerOptions {} + +/** + * 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> = {}) { + super(new StandardOpenRPCHandler(router, options), 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..06835f5c6e --- /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 the handler options through', 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, 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' } } } }) + }) + + 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..bd7153ce49 --- /dev/null +++ b/packages/openrpc/src/adapters/fetch/openrpc-handler.ts @@ -0,0 +1,18 @@ +import type { Context, Router } from '@orpc/server' +import type { FetchHandlerOptions } from '@orpc/server/fetch' +import type { StandardOpenRPCHandlerOptions } from '../standard' +import { FetchHandler } from '@orpc/server/fetch' +import { StandardOpenRPCHandler } from '../standard' + +export interface OpenRPCHandlerOptions extends FetchHandlerOptions, StandardOpenRPCHandlerOptions {} + +/** + * 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> = {}) { + super(new StandardOpenRPCHandler(router, options), 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..3fd25022dc --- /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..b708ffd2fa --- /dev/null +++ b/packages/openrpc/src/adapters/node/openrpc-handler.ts @@ -0,0 +1,18 @@ +import type { Context, Router } from '@orpc/server' +import type { NodeHttpHandlerOptions } from '@orpc/server/node' +import type { StandardOpenRPCHandlerOptions } from '../standard' +import { NodeHttpHandler } from '@orpc/server/node' +import { StandardOpenRPCHandler } from '../standard' + +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. + * + * @see {@link https://orpc.dev/docs/openrpc/handler | OpenRPC Handler} + */ +export class OpenRPCHandler extends NodeHttpHandler { + constructor(router: Router, options: NoInfer> = {}) { + 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 new file mode 100644 index 0000000000..36dffcca02 --- /dev/null +++ b/packages/openrpc/src/adapters/standard/index.test.ts @@ -0,0 +1,7 @@ +it('exports StandardOpenRPCHandler, OpenRPCLinkCodec, getJsonRpcCall', async () => { + await expect(import('.')).resolves.toMatchObject({ + StandardOpenRPCHandler: 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..179f4fc3a5 --- /dev/null +++ b/packages/openrpc/src/adapters/standard/index.ts @@ -0,0 +1,4 @@ +export * from './openrpc-handler' +export * from './openrpc-link-codec' +export { getJsonRpcCall } from './utils' +export type { JsonRpcCall } from './utils' 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..865468434b --- /dev/null +++ b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.test.ts @@ -0,0 +1,284 @@ +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 `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 { + 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({}) + }) + + 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) + }) + }) + }) + }) + + 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..33e90580ca --- /dev/null +++ b/packages/openrpc/src/adapters/standard/openrpc-handler-codec.ts @@ -0,0 +1,171 @@ +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 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' +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 `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 + 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.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 & { body: { error: JsonRpcErrorObject } } { + return { + // 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) }, + } + } +} + +/** + * `[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 accepts = await acceptsEmptyInputs(procedure) + + if (params === undefined || Array.isArray(params)) { + return accepts.undefined || !accepts.object ? undefined : {} + } + + return accepts.object || !accepts.undefined ? {} : undefined +} + +/** + * Whether the input schemas accept `undefined` and `{}`, worked out once per procedure since it only depends on its schemas. + */ +const emptyInputsAcceptance = /* @__PURE__ */ new WeakMap>() + +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 { + 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-handler.test.ts b/packages/openrpc/src/adapters/standard/openrpc-handler.test.ts new file mode 100644 index 0000000000..dc0ab2ade2 --- /dev/null +++ b/packages/openrpc/src/adapters/standard/openrpc-handler.test.ts @@ -0,0 +1,615 @@ +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' +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 } from './utils' + +beforeEach(() => { + vi.clearAllMocks() +}) + +const notify = vi.fn() +const discover = 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]), + rpc: { discover: os.handler(discover) }, + 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('standardOpenRPCHandler', () => { + // 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] }) + }) + }) + + describe('plugins and interceptors', () => { + it('runs routing interceptors once per request, and the other interceptors once per call', async () => { + const calls: unknown[] = [] + const plugin: StandardHandlerPlugin = { + name: 'record', + init: options => ({ + ...options, + routingInterceptors: [({ request, next }) => { + 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() + }], + }) + + await post(handler, [{ jsonrpc: '2.0', method: 'get_data', id: 1 }, { jsonrpc: '2.0', method: 'notify_hello', params: [1] }]) + + expect(calls).toEqual([ + ['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({ + 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 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() + }) + + // 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: 'broken.ping', id: 1 })).rejects.toThrow('secret') + expect(rejectSpy).not.toHaveBeenCalled() + + 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] }, + ]) + expect(rejectSpy).toHaveBeenCalledOnce() + 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 new file mode 100644 index 0000000000..bbfb20a412 --- /dev/null +++ b/packages/openrpc/src/adapters/standard/openrpc-handler.ts @@ -0,0 +1,366 @@ +import type { AnyORPCError } from '@orpc/client' +import type { Context, Router } from '@orpc/server' +import type { + StandardHandlerHandleOptions, + StandardHandlerHandleResult, + StandardHandlerOptions, + StandardHandlerPlugin, + StandardHandlerRoutingInterceptor, + StandardHandlerRoutingInterceptorOptions, +} from '@orpc/server/standard' +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 { 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 { hasJsonRpcResultOrError, isJsonRpcId } from '../../json-rpc' +import { OpenRPCHandlerCodec } from './openrpc-handler-codec' +import { isJsonRpcMediaType, JSON_RPC_CALL_SYMBOL } from './utils' + +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): Extract { + 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 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`. + * `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} + */ +export class StandardOpenRPCHandler extends StandardHandler { + private readonly requestInterceptors: StandardHandlerRoutingInterceptor[] | undefined + private readonly openrpcCodec: OpenRPCHandlerCodec + private readonly maxBatchSize: Exclude['maxBatchSize'], undefined> + private readonly discovery: StandardOpenRPCHandlerOptions['discovery'] + + constructor(router: Router, options: NoInfer> = {}) { + let initialized: StandardHandlerOptions = options + + // 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 + } + + const codec = new OpenRPCHandlerCodec(router, options) + + // the plugins are already initialized, the routing interceptors run in `.handle` + super(codec, { + interceptors: initialized.interceptors, + clientInterceptors: initialized.clientInterceptors, + }) + + this.requestInterceptors = initialized.routingInterceptors + this.openrpcCodec = codec + 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 + 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, 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 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 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 this.answerEachCall(message, this.openrpcCodec.encodeError(new ORPCError(code)).body.error) + } + + /** + * 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): 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 + + let result: StandardHandlerHandleResult + + try { + 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) { + 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 } + } + + private async discover(options: StandardHandlerRoutingInterceptorOptions): Promise { + const spec = await this.resolveSpec(options) + + 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 + + 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 await value(this.discovery!.spec, options) + } + catch (error) { + if (error instanceof ORPCError) { + return 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 { + const { status, body } = this.openrpcCodec.encodeError(error) + + return { matched: true, response: { status, headers: {}, body: { jsonrpc: JSON_RPC_VERSION, id: null, ...body } } } + } +} 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..27edd5a507 --- /dev/null +++ b/packages/openrpc/src/adapters/standard/openrpc-link-codec.test.ts @@ -0,0 +1,323 @@ +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('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 + + expect([await encodeId(codec), await encodeId(codec), await encodeId(other)]).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 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' } }], + ])('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: 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.') + }) + }) + + 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..e70e4f6ccb --- /dev/null +++ b/packages/openrpc/src/adapters/standard/openrpc-link-codec.ts @@ -0,0 +1,126 @@ +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, SequentialIdGenerator, 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, 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 ?? '/' + 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.idGenerator.generate() + 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..18dcb08c00 --- /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 StandardOpenRPCHandler', () => { + 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..6b67fe314b --- /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 `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 = /* @__PURE__ */ Symbol.for('ORPC_JSON_RPC_CALL') + +/** + * Returns the JSON-RPC call a sub-request represents, or `undefined` for any other request, + * such as the HTTP request routing interceptors see. + * + * @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 = /* @__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/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..6e019c7c8f --- /dev/null +++ b/packages/openrpc/src/adapters/websocket/openrpc-handler.test.ts @@ -0,0 +1,285 @@ +import type { StandardHandlerPlugin } from '@orpc/server/standard' +import type { AddressInfo } from 'node:net' +import { once } from 'node:events' +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(() => { + 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 the handler options through', 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 } }, + // 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 } }], + ]) + }) + + 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({ routingInterceptors: [routingInterceptor], interceptors: [interceptor], plugins: [plugin] }) + + 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([ + { method: 'ping', id: 1 }, + { method: 'echo', id: undefined }, + ]) + } + }) + + 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: 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', 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')])) + 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 () => { + 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..69f61f3146 --- /dev/null +++ b/packages/openrpc/src/adapters/websocket/openrpc-handler.ts @@ -0,0 +1,95 @@ +import type { Context, Router } from '@orpc/server' +import type { FriendlyStandardHandlerHandleOptions } from '@orpc/server/standard' +import type { WebSocketLike } from '@orpc/server/websocket' +import type { Arrayable, MaybeOptionalOptions } from '@orpc/shared' +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 StandardOpenRPCHandlerOptions {} + +/** + * 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: StandardOpenRPCHandler + + /** + * Aborts the in-flight calls of a socket once it closes. + */ + private readonly controllers = new WeakMap() + + constructor(router: Router, options: NoInfer> = {}) { + this.handler = new StandardOpenRPCHandler(router, options) + } + + /** + * 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 handler answers with "Parse error" + const message = parseJsonMessage(toStringOrBytes(data)) + + const { matched, response } = await this.handler.handleMessage(message, { ...options, signal }) + + 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..635bc2bb8c --- /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: string, 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..de1137ab3a --- /dev/null +++ b/packages/openrpc/src/adapters/websocket/utils.ts @@ -0,0 +1,10 @@ +import { tryOrUndefined } from '@orpc/shared' + +const textDecoder = /* @__PURE__ */ 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..3eca4f96a6 --- /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: Record = { + BAD_REQUEST: JSON_RPC_ERROR_CODES.INVALID_PARAMS, + 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: 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, +} + +/** + * 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..67346b49ff --- /dev/null +++ b/packages/openrpc/src/index.test.ts @@ -0,0 +1,13 @@ +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), + hasJsonRpcResultOrError: 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..35707cede3 --- /dev/null +++ b/packages/openrpc/src/json-rpc.test.ts @@ -0,0 +1,125 @@ +import { createORPCErrorFromJson, ORPCError } from '@orpc/client' +import { OpenAPIJsonSerializer } from '@orpc/openapi' +import { encodeJsonRpcError, fromJsonRpcErrorCode, hasJsonRpcResultOrError, 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('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], + [{ 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..432bd80934 --- /dev/null +++ b/packages/openrpc/src/json-rpc.ts @@ -0,0 +1,85 @@ +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 has exactly one own `result` or valid `error` member, like a JSON-RPC response. + */ +export function hasJsonRpcResultOrError(value: unknown): value is { result: unknown } | { error: JsonRpcErrorObject } { + if (!isPlainObject(value)) { + return false + } + + const hasResult = Object.hasOwn(value, 'result') + + 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`. + */ +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 = /* @__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'], +]) + +/** + * 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..a03bb8b3a0 --- /dev/null +++ b/packages/openrpc/src/openrpc-generator.ts @@ -0,0 +1,269 @@ +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 { OpenRPCMeta } from './meta' +import type { OpenRPCContentDescriptorObject, OpenRPCDocument, OpenRPCErrorObject, OpenRPCMethodObject } from './types' +import { ORPCError } from '@orpc/client' +import { + combineJsonSchemasWithComposition, + containsJsonFileSchema, + DelegatingJsonSchemaConverter, + extractJsonObjectSchemaEntries, + hoistRecursiveRefToDef, + isJsonObjectSchema, + 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 = /* @__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 + * 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[] = [] + const errorCodeMap = options.errorCodeMap ?? COMMON_JSON_RPC_ERROR_CODE_MAP + + await walkProcedureContractsAsync(router, (contract, path) => { + if (value(options.filter, contract, path) === false || isEventIteratorProcedure(contract)) { + return + } + + try { + doc.methods.push(this.buildMethod(registry, contract, path, 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, + ): 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 meta = getOpenRPCMeta(contract) + const method = typeof meta?.spec === 'object' ? meta.spec : this.generateMethod(registry, contract, meta, name, errorCodeMap) + + return typeof meta?.spec === 'function' ? meta.spec(method) : method + } + + private generateMethod( + registry: OpenAPIComponentRegistry, + contract: AnyProcedureContract, + meta: OpenRPCMeta | undefined, + 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 openapiMeta = getOpenAPIMeta(contract) + const resultSchema = registry.toOpenAPISchema(outputSchema, 'output') + 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: [resultSchema, { type: 'null' }] } : resultSchema, + }, + 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 ( + !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 ?? {}, '') + ) { + 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'), + } +} + +/** + * 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/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..a6da8ad97f --- /dev/null +++ b/packages/openrpc/src/types.ts @@ -0,0 +1,88 @@ +import type { JsonSchema } from '@orpc/json-schema' +import type { JsonRpcErrorObject } from './json-rpc' + +/** + * `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 type OpenRPCErrorObject = JsonRpcErrorObject + +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 ffec783b26..c59be92bf5 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 23731fef5f..e12a86142c 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 bbd5b37a35..d4b2aa29cb 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 24937ea826..2b163c1074 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 891d4c8503..48efa42d54 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 9f9dfbeadb..61cd0fb3f6 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 b1e01487c0..3784e12808 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 721f34d0ac..5803616163 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 06ef9f9a8c..bd3377f6c4 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 90fe1eba96..13ae280e41 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 212729384f..b42e725720 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 39befad771..4f23a790b0 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 c1640adefc..7e3a826f68 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 @@ -924,6 +927,43 @@ 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 + devDependencies: + '@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/openrpc/plugins.test.ts b/tests/openrpc/plugins.test.ts new file mode 100644 index 0000000000..0dc9443678 --- /dev/null +++ b/tests/openrpc/plugins.test.ts @@ -0,0 +1,154 @@ +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('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([]) + }) +})