diff --git a/.env.example b/.env.example index c0baf4787..2d4a7f949 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,9 @@ # copy this file and rename it to .env # then fill in the appropriate values -# Some tests in the project depend on Redis or Upstash Redis -# You can create a free redis instance at upstash and copy the connection details here +# Some project tests depend on Redis or Upstash Redis. +# You can create a free Redis instance on Upstash and add its connection details here. +# Point Redis and Upstash variables to the same server, as some cross-adapter tests rely on this. REDIS_URL= UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 2304f61bc..008c75c77 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,6 +1,6 @@ # These are supported funding model platforms -github: [dinwwwh] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +github: [unnoq] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2bf8b2873..235eb3382 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,16 +12,12 @@ updates: - eslint - '@antfu/eslint-config' - 'eslint-plugin-*' - hey-api: - patterns: - - '@hey-api/*' dev-dependencies-minor-patch: dependency-type: development exclude-patterns: - eslint - '@antfu/eslint-config' - 'eslint-plugin-*' - - '@hey-api/*' - compression # inline update-types: - minor diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7ece118fc..6d34a3f6e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -6,15 +6,16 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: - lint: + lint_and_typecheck: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v7 - - run: pnpm env use --global 22 + - uses: pnpm/action-setup@v6 - run: pnpm i @@ -22,50 +23,39 @@ jobs: - run: pnpm run type:check - - name: Ensure apps/content builds successfully - working-directory: apps/content - run: pnpm run build - - test: + test_node_matrix: runs-on: ubuntu-latest - services: - redis: - image: redis:7-alpine - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 + strategy: + matrix: + node-version: [24, 22, 20] + fail-fast: false steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 - - run: pnpm env use --global 22 + - run: pnpm runtime set node ${{ matrix.node-version }} - run: pnpm i - run: pnpm run test:coverage env: - REDIS_URL: redis://localhost:6379 + REDIS_URL: ${{ secrets.REDIS_URL }} UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} - - uses: codecov/codecov-action@v5 + - uses: codecov/codecov-action@v7 + if: matrix.node-version == 24 with: token: ${{ secrets.CODECOV_TOKEN }} - publish-commit: + publish_preview: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v7 - - run: pnpm env use --global 22 + - uses: pnpm/action-setup@v6 - run: pnpm i - - run: pnpm run packages:publish:commit + - run: pnpm --filter='./packages/*' run -r build && pnpm exec pkg-pr-new publish './packages/*' --pnpm --compact --template './playgrounds/*' diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml deleted file mode 100644 index 19401170c..000000000 --- a/.github/workflows/pullfrog.yml +++ /dev/null @@ -1,57 +0,0 @@ -# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED -name: Pullfrog -run-name: ${{ inputs.name || github.workflow }} -on: - workflow_dispatch: - inputs: - prompt: - type: string - description: Agent prompt - name: - type: string - description: Run name - -permissions: - contents: read - -jobs: - pullfrog: - runs-on: ubuntu-latest - permissions: - id-token: write - contents: read - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 1 - - name: Run agent - uses: pullfrog/pullfrog@v0 - with: - prompt: ${{ inputs.prompt }} - env: - # add at least one provider API key - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - GOOGLE_GENERATIVE_AI_API_KEY: - ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - XAI_API_KEY: ${{ secrets.XAI_API_KEY }} - DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} - MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} - OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - - # for Amazon Bedrock (https://docs.pullfrog.com/bedrock) - # AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }} - # AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - # AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - # AWS_REGION: us-east-1 - # BEDROCK_MODEL_ID: - - # for Google Vertex AI (https://docs.pullfrog.com/vertex) - # VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }} - # GOOGLE_CLOUD_PROJECT: my-project - # VERTEX_LOCATION: global - # VERTEX_MODEL_ID: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3d3f27a7c..c62e4f4c7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -39,13 +39,11 @@ jobs: exit 1 fi - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: pnpm/action-setup@v4 - - - run: pnpm env use --global 22 + - uses: pnpm/action-setup@v6 - run: pnpm i @@ -53,16 +51,16 @@ jobs: git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - - run: pnpm run packages:bump ${{ github.event.inputs.version }} --yes + - run: pnpm exec bumpp -r ${{ github.event.inputs.version }} --yes - run: pnpm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}" env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - run: pnpm run packages:publish --tag=${{ steps.version.outputs.preid || 'latest' }} + - run: pnpm --filter='./packages/*' run -r build && pnpm --filter='./packages/*' publish -r --access=public --tag=${{ steps.version.outputs.preid || 'latest' }} env: NPM_CONFIG_PROVENANCE: true - - run: pnpm run packages:changelog:github + - run: pnpm exec changelogithub --draft env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sponsors-sync.yaml b/.github/workflows/sponsors-sync.yaml index 9962a7782..68a2225cd 100644 --- a/.github/workflows/sponsors-sync.yaml +++ b/.github/workflows/sponsors-sync.yaml @@ -11,19 +11,15 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 - with: - ref: main - - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v7 - - run: pnpm env use --global 22 + - uses: pnpm/action-setup@v6 - run: pnpm i - - run: pnpm sponsors:sync + - run: pnpm run sponsors:sync - - uses: EndBug/add-and-commit@v9 + - uses: EndBug/add-and-commit@v10 with: author_name: GitHub Actions author_email: 41898282+github-actions[bot]@users.noreply.github.com diff --git a/README.md b/README.md index 293bc5c11..27fc183ad 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,4 @@ -
- oRPC logo -
- -

+

oRPC - Typesafe APIs Made Simple 🪄

@@ -22,185 +18,46 @@
-

Typesafe APIs Made Simple 🪄

+## Documentation -**oRPC is a powerful combination of RPC and OpenAPI**, makes it easy to build APIs that are end-to-end type-safe and adhere to OpenAPI standards +You can read the documentation [here](https://orpc.dev). ---- +## Packages -## Highlights +**Core** -- **🔗 End-to-End Type Safety**: Ensure type-safe inputs, outputs, and errors from client to server. -- **📘 First-Class OpenAPI**: Built-in support that fully adheres to the OpenAPI standard. -- **📝 Contract-First Development**: Optionally define your API contract before implementation. -- **🔍 First-Class OpenTelemetry**: Seamlessly integrate with OpenTelemetry for observability. -- **⚙️ Framework Integrations**: Seamlessly integrate with TanStack Query (React, Vue, Solid, Svelte, Angular), SWR, Pinia Colada, and more. -- **🚀 Server Actions**: Fully compatible with React Server Actions on Next.js, TanStack Start, and other platforms. -- **🔠 Standard Schema Support**: Works out of the box with Zod, Valibot, ArkType, and other schema validators. -- **🗃️ Native Types**: Supports native types like Date, File, Blob, BigInt, URL, and more. -- **⏱️ Lazy Router**: Enhance cold start times with our lazy routing feature. -- **📡 SSE & Streaming**: Enjoy full type-safe support for SSE and streaming. -- **🌍 Multi-Runtime Support**: Fast and lightweight on Cloudflare, Deno, Bun, Node.js, and beyond. -- **🔌 Extendability**: Easily extend functionality with plugins, middleware, and interceptors. +- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Define type-safe contracts for your API. +- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Implement your API or contract on the server. +- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API from the client with full type safety. +- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Bring OpenAPI compatibility to your APIs. -## Documentation +**Schema validation** -You can find the full documentation [here](https://orpc.dev). +- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): Integrate with [Zod](https://zod.dev/). +- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): Integrate with [Valibot](https://valibot.dev/). +- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): Integrate with [ArkType](https://arktype.io/). -## Packages +**Framework & ecosystem integrations** + +- [@orpc/next](https://www.npmjs.com/package/@orpc/react): Use oRPC with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). +- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). +- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). + +**Built-in features** + +- [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub. +- [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting. +- [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. + +**Observability** -- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Build your API contract. -- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build your API or implement API contract. -- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API on the client with type-safety. -- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Generate OpenAPI specs and handle OpenAPI requests. -- [@orpc/otel](https://www.npmjs.com/package/@orpc/otel): [OpenTelemetry](https://opentelemetry.io/) integration for observability. -- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Deeply integrate oRPC with [NestJS](https://nestjs.com/). -- [@orpc/react](https://www.npmjs.com/package/@orpc/react): Utilities for integrating oRPC with React and React Server Actions. -- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): [TanStack Query](https://tanstack.com/query/latest) integration. -- [@orpc/experimental-react-swr](https://www.npmjs.com/package/@orpc/experimental-react-swr): [SWR](https://swr.vercel.app/) integration. -- [@orpc/vue-colada](https://www.npmjs.com/package/@orpc/vue-colada): Integration with [Pinia Colada](https://pinia-colada.esm.dev/). -- [@orpc/hey-api](https://www.npmjs.com/package/@orpc/hey-api): [Hey API](https://heyapi.dev/) integration. -- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): More schemas that [Zod](https://zod.dev/) doesn't support yet. -- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): OpenAPI spec generation from [Valibot](https://valibot.dev/). -- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): OpenAPI spec generation from [ArkType](https://arktype.io/). - -## Overview - -This is a quick overview of how to use oRPC. For more details, please refer to the [documentation](https://orpc.dev). - -1. **Define your router:** - - ```ts - import type { IncomingHttpHeaders } from 'node:http' - import { ORPCError, os } from '@orpc/server' - import * as z from 'zod' - - const PlanetSchema = z.object({ - id: z.number().int().min(1), - name: z.string(), - description: z.string().optional(), - }) - - export const listPlanet = os - .input( - z.object({ - limit: z.number().int().min(1).max(100).optional(), - cursor: z.number().int().min(0).default(0), - }), - ) - .handler(async ({ input }) => { - // your list code here - return [{ id: 1, name: 'name' }] - }) - - export const findPlanet = os - .input(PlanetSchema.pick({ id: true })) - .handler(async ({ input }) => { - // your find code here - return { id: 1, name: 'name' } - }) - - export const createPlanet = os - .$context<{ headers: IncomingHttpHeaders }>() - .use(({ context, next }) => { - const user = parseJWT(context.headers.authorization?.split(' ')[1]) - - if (user) { - return next({ context: { user } }) - } - - throw new ORPCError('UNAUTHORIZED') - }) - .input(PlanetSchema.omit({ id: true })) - .handler(async ({ input, context }) => { - // your create code here - return { id: 1, name: 'name' } - }) - - export const router = { - planet: { - list: listPlanet, - find: findPlanet, - create: createPlanet - } - } - ``` - -2. **Create your server:** - - ```ts - import { createServer } from 'node:http' - import { RPCHandler } from '@orpc/server/node' - import { CORSPlugin } from '@orpc/server/plugins' - - const handler = new RPCHandler(router, { - plugins: [new CORSPlugin()] - }) - - const server = createServer(async (req, res) => { - const result = await handler.handle(req, res, { - context: { headers: req.headers } - }) - - if (!result.matched) { - res.statusCode = 404 - res.end('No procedure matched') - } - }) - - server.listen( - 3000, - '127.0.0.1', - () => console.log('Listening on 127.0.0.1:3000') - ) - ``` - -3. **Create your client:** - - ```ts - import type { RouterClient } from '@orpc/server' - import { createORPCClient } from '@orpc/client' - import { RPCLink } from '@orpc/client/fetch' - - const link = new RPCLink({ - url: 'http://127.0.0.1:3000', - headers: { Authorization: 'Bearer token' }, - }) - - export const orpc: RouterClient = createORPCClient(link) - ``` - -4. **Consume your API:** - - ```ts - import { orpc } from './client' - - const planets = await orpc.planet.list({ limit: 10 }) - ``` - -5. **Generate OpenAPI Spec:** - - ```ts - import { OpenAPIGenerator } from '@orpc/openapi' - import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4' - - const generator = new OpenAPIGenerator({ - schemaConverters: [new ZodToJsonSchemaConverter()] - }) - - const spec = await generator.generate(router, { - info: { - title: 'Planet API', - version: '1.0.0' - } - }) - - console.log(spec) - ``` +- [@orpc/opentelemetry](https://www.npmjs.com/package/@orpc/opentelemetry): Instrument your API with [OpenTelemetry](https://opentelemetry.io/). +- [@orpc/pino](https://www.npmjs.com/package/@orpc/pino): Log with [Pino](https://getpino.io/). +- [@orpc/evlog](https://www.npmjs.com/package/@orpc/evlog): Log with [Evlog](https://evlog.dev/). ## Sponsors -If you find oRPC valuable and would like to support its development, you can do so here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). +Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). Every bit helps! 🚀 ### 🏆 Platinum Sponsor diff --git a/apps/content/.vitepress/config.ts b/apps/content/.vitepress/config.ts index 423151563..07a382416 100644 --- a/apps/content/.vitepress/config.ts +++ b/apps/content/.vitepress/config.ts @@ -11,6 +11,7 @@ export default withMermaid(defineConfig({ description: 'Easy to build APIs that are end-to-end type-safe and adhere to OpenAPI standards', lastUpdated: true, cleanUrls: true, + ignoreDeadLinks: true, // TODO: turn off this flag markdown: { theme: { light: 'github-light', @@ -64,8 +65,7 @@ export default withMermaid(defineConfig({ copyright: 'Copyright © 2024-present MiddleAPI & oRPC contributors.', }, nav: [ - { text: 'Docs', link: '/docs/getting-started', activeMatch: '/docs/(?!openapi/)' }, - { text: 'OpenAPI', link: '/docs/openapi/getting-started', activeMatch: '/docs/openapi/' }, + { text: 'Docs', link: '/docs/getting-started', activeMatch: '/docs/' }, { text: 'Blog', link: '/blog/v1-announcement', activeMatch: '/blog/' }, { text: 'Learn & Contribute', link: '/learn-and-contribute/overview', activeMatch: '/learn-and-contribute/' }, { @@ -82,77 +82,87 @@ export default withMermaid(defineConfig({ sidebar: { '/docs/': [ { text: 'Getting Started', link: '/docs/getting-started' }, - { text: 'Comparison', link: '/docs/comparison' }, { text: 'Procedure', link: '/docs/procedure' }, { text: 'Router', link: '/docs/router' }, { text: 'Middleware', link: '/docs/middleware' }, { text: 'Context', link: '/docs/context' }, { text: 'Error Handling', link: '/docs/error-handling' }, - { text: 'File Upload/Download', link: '/docs/file-upload-download' }, + { text: 'Binary Data', link: '/docs/binary-data' }, { text: 'Event Iterator (SSE)', link: '/docs/event-iterator' }, - { text: 'Server Action', link: '/docs/server-action' }, { text: 'Metadata', link: '/docs/metadata' }, - { text: 'RPC Handler', link: '/docs/rpc-handler' }, - { text: 'OpenAPI', link: '/docs/openapi/getting-started' }, - { text: 'Ecosystem', link: '/docs/ecosystem' }, { text: 'Playgrounds', link: '/docs/playgrounds' }, { - text: 'Contract First', + text: 'RPC', collapsed: true, items: [ - { text: 'Define Contract', link: '/docs/contract-first/define-contract' }, - { text: 'Implement Contract', link: '/docs/contract-first/implement-contract' }, - { text: 'Router to Contract', link: '/docs/contract-first/router-to-contract' }, - { text: 'OpenAPI to Contract', link: '/docs/openapi/openapi-to-contract' }, + { text: 'RPC Protocol', link: '/docs/rpc/protocol' }, + { text: 'RPC Serializer', link: '/docs/rpc/serializer' }, + { text: 'RPC Handler', link: '/docs/rpc/handler' }, + { text: 'RPC Link', link: '/docs/rpc/link' }, + ], + }, + { + text: 'OpenAPI', + collapsed: true, + items: [ + { text: 'OpenAPI Routing', link: '/docs/openapi/routing' }, + { text: 'Input and Output Mapping', link: '/docs/openapi/input-and-output-mapping' }, + { text: 'Bracket Notation', link: '/docs/openapi/bracket-notation' }, + { text: 'OpenAPI Serializer', link: '/docs/openapi/serializer' }, + { text: 'OpenAPI Handler', link: '/docs/openapi/handler' }, + { text: 'OpenAPI Link', link: '/docs/openapi/link' }, + { text: 'OpenAPI Specification', link: '/docs/openapi/specification' }, + { text: 'OpenAPI Scalar (Swagger)', link: '/docs/openapi/scalar' }, + ], + }, + { + text: 'Contract', + collapsed: true, + items: [ + { text: 'Procedure Contract', link: '/docs/contract/procedure' }, + { text: 'Router Contract', link: '/docs/contract/router' }, + { text: 'Contract Implementation', link: '/docs/contract/implementation' }, + ], + }, + { + text: 'Client', + collapsed: true, + items: [ + { text: 'Server-Side', link: '/docs/client/server-side' }, + { text: 'Client-Side', link: '/docs/client/client-side' }, + { text: 'Error Handling', link: '/docs/client/error-handling' }, + { text: 'Event Iterator', link: '/docs/client/event-iterator' }, + { text: 'Dynamic Link', link: '/docs/client/dynamic-link' }, ], }, { text: 'Adapters', collapsed: true, items: [ - { text: 'HTTP', link: '/docs/adapters/http' }, + { text: 'Fetch API', link: '/docs/adapters/fetch-api' }, + { text: 'Node HTTP', link: '/docs/adapters/node-http' }, { text: 'Websocket', link: '/docs/adapters/websocket' }, { text: 'Message Port', link: '/docs/adapters/message-port' }, - { text: '---' }, - { text: 'Astro', link: '/docs/adapters/astro' }, - { text: 'Browser', link: '/docs/adapters/browser' }, - { text: 'Electron', link: '/docs/adapters/electron' }, - { text: 'Elysia', link: '/docs/adapters/elysia' }, - { text: 'Express', link: '/docs/adapters/express' }, - { text: 'Fastify', link: '/docs/adapters/fastify' }, - { text: 'H3', link: '/docs/adapters/h3' }, - { text: 'Hono', link: '/docs/adapters/hono' }, - { text: 'Next.js', link: '/docs/adapters/next' }, - { text: 'Nuxt', link: '/docs/adapters/nuxt' }, - { text: 'React Native', link: '/docs/adapters/react-native' }, - { text: 'Remix', link: '/docs/adapters/remix' }, - { text: 'Solid Start', link: '/docs/adapters/solid-start' }, - { text: 'Svelte Kit', link: '/docs/adapters/svelte-kit' }, - { text: 'Tanstack Start', link: '/docs/adapters/tanstack-start' }, - { text: 'Web Workers', link: '/docs/adapters/web-workers' }, - { text: 'Worker Threads', link: '/docs/adapters/worker-threads' }, ], }, { text: 'Plugins', collapsed: true, items: [ + { text: 'Batch', link: '/docs/plugins/batch' }, + { text: 'Body Compression', link: '/docs/plugins/body-compression' }, + { text: 'Body Limit', link: '/docs/plugins/body-limit' }, { text: 'CORS', link: '/docs/plugins/cors' }, + { text: 'CSRF Guard', link: '/docs/plugins/csrf-guard' }, + { text: 'Dedupe', link: '/docs/plugins/dedupe' }, + { text: 'OpenAPI Reference', link: '/docs/plugins/openapi-reference' }, { text: 'Request Headers', link: '/docs/plugins/request-headers' }, - { text: 'Response Headers', link: '/docs/plugins/response-headers' }, { text: 'Request Validation', link: '/docs/plugins/request-validation' }, + { text: 'Response Headers', link: '/docs/plugins/response-headers' }, { text: 'Response Validation', link: '/docs/plugins/response-validation' }, - { text: 'Hibernation', link: '/docs/plugins/hibernation' }, - { text: 'Dedupe Requests', link: '/docs/plugins/dedupe-requests' }, - { text: 'Batch Requests', link: '/docs/plugins/batch-requests' }, - { text: 'Client Retry', link: '/docs/plugins/client-retry' }, { text: 'Retry After', link: '/docs/plugins/retry-after' }, - { text: 'Rethrow Handler', link: '/docs/plugins/rethrow-handler' }, - { text: 'Compression', link: '/docs/plugins/compression' }, - { text: 'Body Limit', link: '/docs/plugins/body-limit' }, - { text: 'Simple CSRF Protection', link: '/docs/plugins/simple-csrf-protection' }, - { text: 'Strict GET method', link: '/docs/plugins/strict-get-method' }, - { text: 'Logging', link: '/docs/integrations/pino' }, + { text: 'Retry', link: '/docs/plugins/retry' }, + { text: 'Smart Coercion', link: '/docs/plugins/smart-coercion' }, ], }, { @@ -168,52 +178,26 @@ export default withMermaid(defineConfig({ { text: 'Signing', link: '/docs/helpers/signing' }, ], }, - { - text: 'Client', - collapsed: true, - items: [ - { text: 'Server-Side', link: '/docs/client/server-side' }, - { text: 'Client-Side', link: '/docs/client/client-side' }, - { text: 'Error Handling', link: '/docs/client/error-handling' }, - { text: 'Event Iterator', link: '/docs/client/event-iterator' }, - { text: 'RPC Link', link: '/docs/client/rpc-link' }, - { text: 'Dynamic Link', link: '/docs/client/dynamic-link' }, - ], - }, { text: 'Integrations', collapsed: true, items: [ - { text: 'AI SDK', link: '/docs/integrations/ai-sdk' }, - { text: 'Better Auth', link: '/docs/integrations/better-auth' }, - { text: 'Durable Iterator', link: '/docs/integrations/durable-iterator' }, - { text: 'Hey API', link: '/docs/openapi/integrations/hey-api' }, + { text: 'Effect', link: '/docs/integrations/effect' }, + { text: 'Evlog', link: '/docs/integrations/evlog' }, + { text: 'Next.js', link: '/docs/integrations/next' }, { text: 'OpenTelemetry', link: '/docs/integrations/opentelemetry' }, - { text: 'Pinia Colada', link: '/docs/integrations/pinia-colada' }, { text: 'Pino', link: '/docs/integrations/pino' }, - { text: 'React SWR', link: '/docs/integrations/react-swr' }, - { text: 'Sentry', link: '/docs/integrations/sentry' }, { text: 'Tanstack Query', link: '/docs/integrations/tanstack-query' }, - { - text: 'Tanstack Query (Old)', - collapsed: true, - items: [ - { text: 'Basic', link: '/docs/integrations/tanstack-query-old/basic' }, - { text: 'React', link: '/docs/integrations/tanstack-query-old/react' }, - { text: 'Vue', link: '/docs/integrations/tanstack-query-old/vue' }, - { text: 'Solid', link: '/docs/integrations/tanstack-query-old/solid' }, - { text: 'Svelte', link: '/docs/integrations/tanstack-query-old/svelte' }, - ], - }, - { text: 'NestJS', link: '/docs/openapi/integrations/implement-contract-in-nest' }, - { text: 'tRPC', link: '/docs/openapi/integrations/trpc' }, ], }, { - text: 'Examples', + text: 'Extensions', collapsed: true, items: [ - { text: 'OpenAI Streaming', link: '/docs/examples/openai-streaming' }, + { text: '.callable', link: '/docs/client/server-side#callable-extension' }, + { text: '.route', link: '/docs/openapi/routing#callable-extension' }, + { text: '.actionable', link: '/docs/integrations/next#actionable-extension' }, + { text: '.effect', link: '/docs/integrations/effect#effect-extension' }, ], }, { @@ -223,21 +207,18 @@ export default withMermaid(defineConfig({ { text: 'Dedupe Middleware', link: '/docs/best-practices/dedupe-middleware' }, { text: 'Monorepo Setup', link: '/docs/best-practices/monorepo-setup' }, { text: 'No Throw Literal', link: '/docs/best-practices/no-throw-literal' }, - { text: 'Optimize SSR', link: '/docs/best-practices/optimize-ssr' }, + { text: 'Optimizing SSR', link: '/docs/best-practices/optimizing-ssr' }, ], }, { text: 'Advanced', collapsed: true, items: [ - { text: 'Building Custom Plugins', link: '/docs/advanced/building-custom-plugins' }, { text: 'Exceeds the maximum length ...', link: '/docs/advanced/exceeds-the-maximum-length-problem' }, - { text: 'Extend Body Parser', link: '/docs/advanced/extend-body-parser' }, + { text: 'Expanding Type Support for OpenAPI Link', link: '/docs/advanced/expanding-type-support-for-openapi-link' }, { text: 'Publish Client to NPM', link: '/docs/advanced/publish-client-to-npm' }, - { text: 'RPC JSON Serializer', link: '/docs/advanced/rpc-json-serializer' }, - { text: 'RPC Protocol', link: '/docs/advanced/rpc-protocol' }, - { text: 'SuperJson', link: '/docs/advanced/superjson' }, - { text: 'Testing & Mocking', link: '/docs/advanced/testing-mocking' }, + { text: 'Scaling Large Projects', link: '/docs/advanced/scaling-large-projects' }, + { text: 'Testing and Mocking', link: '/docs/advanced/testing-and-mocking' }, { text: 'Validation Errors', link: '/docs/advanced/validation-errors' }, ], }, @@ -249,53 +230,6 @@ export default withMermaid(defineConfig({ ], }, ], - '/docs/openapi/': [ - { text: 'Getting Started', link: '/docs/openapi/getting-started' }, - { text: 'Routing', link: '/docs/openapi/routing' }, - { text: 'Input/Output Structure', link: '/docs/openapi/input-output-structure' }, - { text: 'Error Handling', link: '/docs/openapi/error-handling' }, - { text: 'Bracket Notation', link: '/docs/openapi/bracket-notation' }, - { text: 'OpenAPI Handler', link: '/docs/openapi/openapi-handler' }, - { text: 'OpenAPI Specification', link: '/docs/openapi/openapi-specification' }, - { text: 'Scalar (Swagger)', link: '/docs/openapi/scalar' }, - { text: 'OpenAPI to Contract', link: '/docs/openapi/openapi-to-contract' }, - { - text: 'Plugins', - collapsed: true, - items: [ - { text: 'OpenAPI Reference (Swagger)', link: '/docs/openapi/plugins/openapi-reference' }, - { text: 'Smart Coercion', link: '/docs/openapi/plugins/smart-coercion' }, - { text: 'Zod Smart Coercion (old)', link: '/docs/openapi/plugins/zod-smart-coercion' }, - ], - }, - { - text: 'Client', - collapsed: true, - items: [ - { text: 'OpenAPI Link', link: '/docs/openapi/client/openapi-link' }, - ], - }, - { - text: 'Integrations', - collapsed: true, - items: [ - { text: 'Hey API', link: '/docs/openapi/integrations/hey-api' }, - { text: 'Implement Contract in NestJS', link: '/docs/openapi/integrations/implement-contract-in-nest' }, - { text: 'tRPC', link: '/docs/openapi/integrations/trpc' }, - ], - }, - { - text: 'Advanced', - collapsed: true, - items: [ - { text: 'Customizing Error Response', link: '/docs/openapi/advanced/customizing-error-response' }, - { text: 'Disabling Output Validation', link: '/docs/openapi/advanced/disabling-output-validation' }, - { text: 'Expanding Type Support for OpenAPI Link', link: '/docs/openapi/advanced/expanding-type-support-for-openapi-link' }, - { text: 'OpenAPI JSON Serializer', link: '/docs/openapi/advanced/openapi-json-serializer' }, - { text: 'Redirect Response', link: '/docs/openapi/advanced/redirect-response' }, - ], - }, - ], '/blog/': [ { text: 'V1 Announcement', link: '/blog/v1-announcement' }, ], diff --git a/apps/content/blog/v1-announcement.md b/apps/content/blog/v1-announcement.md index 8e17caead..7bbebdd35 100644 --- a/apps/content/blog/v1-announcement.md +++ b/apps/content/blog/v1-announcement.md @@ -125,10 +125,10 @@ In this long journey, I specially thank all my sponsors, they help me to keep go - [Robbe95](https://github.com/Robbe95) - And my first sponsor (private) to start my story -If you're interested in sponsoring oRPC, you can do it [here](https://github.com/sponsors/dinwwwh). +If you're interesting in sponsoring oRPC, you can do it [here](https://github.com/sponsors/unnoq).

- - + + Sponsors

diff --git a/apps/content/docs/adapters/astro.md b/apps/content/docs/adapters/astro.md deleted file mode 100644 index 2a3c8aa2a..000000000 --- a/apps/content/docs/adapters/astro.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: Astro Adapter -description: Use oRPC inside an Astro project ---- - -# Astro Adapter - -[Astro](https://astro.build/) is a JavaScript web framework optimized for building fast, content-driven websites. For additional context, refer to the [HTTP Adapter](/docs/adapters/http) guide. - -## Basic - -::: code-group - -```ts [pages/rpc/[...rest].ts] -import { RPCHandler } from '@orpc/server/fetch' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -export const prerender = false - -export const ALL: APIRoute = async ({ request }) => { - const { response } = await handler.handle(request, { - prefix: '/rpc', - context: {}, - }) - - return response ?? new Response('Not found', { status: 404 }) -} -``` - -::: - -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: diff --git a/apps/content/docs/adapters/browser.md b/apps/content/docs/adapters/browser.md deleted file mode 100644 index 6fc574176..000000000 --- a/apps/content/docs/adapters/browser.md +++ /dev/null @@ -1,171 +0,0 @@ ---- -title: Browser Adapter -description: Type-safe communication between browser scripts using Message Port Adapter ---- - -# Browser Adapter - -Enable type-safe communication between browser scripts using the [Message Port Adapter](/docs/adapters/message-port). - -## Between Extension Scripts - -To set up communication between scripts in a browser extension (e.g. background, content, popup), configure one script to listen for connections and upgrade them, and another to initiate the connection. - -::: warning -The browser extension [Message Passing API](https://developer.chrome.com/docs/extensions/develop/concepts/messaging) does not support transferring binary data, which means oRPC features like `File` and `Blob` cannot be used natively. However, you can temporarily work around this limitation by extending the [RPC JSON Serializer](/docs/advanced/rpc-json-serializer#extending-native-data-types) to encode binary data as Base64. -::: - -::: code-group - -```ts [server] -import { RPCHandler } from '@orpc/server/message-port' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -browser.runtime.onConnect.addListener((port) => { - handler.upgrade(port, { - context: {}, // provide initial context if needed - }) -}) -``` - -```ts [client] -import { RPCLink } from '@orpc/client/message-port' - -const port = browser.runtime.connect() - -const link = new RPCLink({ - port, -}) -``` - -::: - -:::info -This only shows how to configure the link. For full client examples, see [Client-Side Clients](/docs/client/client-side). -::: - -## Window to Window - -To enable communication between two window contexts (e.g. parent and popup), one must listen and upgrade the port, and the other must initiate the connection. - -::: code-group - -```ts [opener] -import { RPCHandler } from '@orpc/server/message-port' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -window.addEventListener('message', (event) => { - if (event.data instanceof MessagePort) { - handler.upgrade(event.data, { - context: {}, // Optional context - }) - - event.data.start() - } -}) - -window.open('/example/popup', 'popup', 'width=680,height=520') -``` - -```ts [popup] -import { RPCLink } from '@orpc/client/message-port' - -const { port1: serverPort, port2: clientPort } = new MessageChannel() - -window.opener.postMessage(serverPort, '*', [serverPort]) - -const link = new RPCLink({ - port: clientPort, -}) - -clientPort.start() -``` - -::: - -## Advanced Relay Pattern - -In some advanced cases, direct communication between scripts isn't possible. For example, a content script running in the ["MAIN" world](https://developer.chrome.com/docs/extensions/reference/manifest/content-scripts#world-timings) cannot directly communicate with the background script using `browser.runtime` or `chrome.runtime` APIs. - -To work around this, you can use a **relay pattern** typically an additional content script running in the default **"ISOLATED" (default) world** to relay messages between the two contexts. This **relay pattern** acts as an intermediary, enabling communication where direct access is restricted. - -::: code-group - -```ts [relay] -window.addEventListener('message', (event) => { - if (event.data instanceof MessagePort) { - const port = browser.runtime.connect() - - // Relay `message` and `close/disconnect` events between the MessagePort and runtime.Port - - event.data.addEventListener('message', (event) => { - port.postMessage(event.data) - }) - - event.data.addEventListener('close', () => { - port.disconnect() - }) - - port.onMessage.addListener((message) => { - event.data.postMessage(message) - }) - - port.onDisconnect.addListener(() => { - event.data.close() - }) - - event.data.start() - } -}) -``` - -```ts [server] -import { RPCHandler } from '@orpc/server/message-port' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -browser.runtime.onConnect.addListener((port) => { - handler.upgrade(port, { - context: {}, // provide initial context if needed - }) -}) -``` - -```ts [client] -import { RPCLink } from '@orpc/client/message-port' - -const { port1: serverPort, port2: clientPort } = new MessageChannel() - -window.postMessage(serverPort, '*', [serverPort]) - -const link = new RPCLink({ - port: clientPort, -}) - -clientPort.start() -``` - -::: diff --git a/apps/content/docs/adapters/electron.md b/apps/content/docs/adapters/electron.md deleted file mode 100644 index fb76c1b2a..000000000 --- a/apps/content/docs/adapters/electron.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: Electron Adapter -description: Use oRPC inside an Electron project ---- - -# Electron Adapter - -Establish type-safe communication between processes in [Electron](https://www.electronjs.org/) using the [Message Port Adapter](/docs/adapters/message-port). Before you start, we recommend reading the [MessagePorts in Electron](https://www.electronjs.org/docs/latest/tutorial/message-ports) guide. - -## Main Process - -Listen for a port sent from the renderer, then upgrade it: - -```ts -import { RPCHandler } from '@orpc/server/message-port' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -app.whenReady().then(() => { - ipcMain.on('start-orpc-server', async (event) => { - const [serverPort] = event.ports - handler.upgrade(serverPort) - serverPort.start() - }) -}) -``` - -:::info -Channel `start-orpc-server` is arbitrary. you can use any name that fits your needs. -::: - -## Preload Process - -Receive the port from the renderer and forward it to the main process: - -```ts -window.addEventListener('message', (event) => { - if (event.data === 'start-orpc-client') { - const [serverPort] = event.ports - - ipcRenderer.postMessage('start-orpc-server', null, [serverPort]) - } -}) -``` - -## Renderer Process - -Create a `MessageChannel`, send one port to the preload script, and use the other to initialize the client link: - -```ts -const { port1: clientPort, port2: serverPort } = new MessageChannel() - -window.postMessage('start-orpc-client', '*', [serverPort]) - -const link = new RPCLink({ - port: clientPort, -}) - -clientPort.start() -``` - -:::info -This only shows how to configure the link. For full client examples, see [Client-Side Clients](/docs/client/client-side). -::: diff --git a/apps/content/docs/adapters/elysia.md b/apps/content/docs/adapters/elysia.md deleted file mode 100644 index d21bab808..000000000 --- a/apps/content/docs/adapters/elysia.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: Elysia Adapter -description: Use oRPC inside an Elysia project ---- - -# Elysia Adapter - -[Elysia](https://elysiajs.com/) is a high-performance web framework for [Bun](https://bun.sh/) that adheres to the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). For additional context, refer to the [HTTP Adapter](/docs/adapters/http) guide. - -## Basic - -```ts -import { Elysia } from 'elysia' -import { RPCHandler } from '@orpc/server/fetch' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -const app = new Elysia() - .all('/rpc*', async ({ request }: { request: Request }) => { - const { response } = await handler.handle(request, { - prefix: '/rpc', - }) - - return response ?? new Response('Not Found', { status: 404 }) - }, { - parse: 'none' // Disable Elysia body parser to prevent "body already used" error - }) - .listen(3000) - -console.log( - `🦊 Elysia is running at http://localhost:3000` -) -``` - -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: diff --git a/apps/content/docs/adapters/express.md b/apps/content/docs/adapters/express.md deleted file mode 100644 index a15d1d9f1..000000000 --- a/apps/content/docs/adapters/express.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -title: Express.js Adapter -description: Use oRPC inside an Express.js project ---- - -# Express.js Adapter - -[Express.js](https://expressjs.com/) is a popular Node.js framework for building web applications. For additional context, refer to the [HTTP Adapter](/docs/adapters/http) guide. - -::: warning -Express's [body-parser](https://expressjs.com/en/resources/middleware/body-parser.html) handles common request body types, and oRPC will use the parsed body if available. However, it doesn't support features like [Bracket Notation](/docs/openapi/bracket-notation), and in case you upload a file with `application/json`, it may be parsed as plain JSON instead of a `File`. To avoid these issues, register any body-parsing middleware **after** your oRPC middleware or only on routes that don't use oRPC. -::: - -## Basic - -```ts -import express from 'express' -import cors from 'cors' -import { RPCHandler } from '@orpc/server/node' -import { onError } from '@orpc/server' - -const app = express() - -app.use(cors()) - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -app.use('/rpc{/*path}', async (req, res, next) => { - const { matched } = await handler.handle(req, res, { - prefix: '/rpc', - context: {}, - }) - - if (matched) { - return - } - - next() -}) - -app.listen(3000, () => console.log('Server listening on port 3000')) -``` - -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: diff --git a/apps/content/docs/adapters/fastify.md b/apps/content/docs/adapters/fastify.md deleted file mode 100644 index a5746ffb8..000000000 --- a/apps/content/docs/adapters/fastify.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: Fastify Adapter -description: Use oRPC inside an Fastify project ---- - -# Fastify Adapter - -[Fastify](https://fastify.dev/) is a web framework highly focused on providing the best developer experience with the least overhead and a powerful plugin architecture. For additional context, refer to the [HTTP Adapter](/docs/adapters/http) guide. - -::: warning -Fastify parses common request content types by default. oRPC will use the parsed body when available. -::: - -## Basic - -```ts -import Fastify from 'fastify' -import { RPCHandler } from '@orpc/server/fastify' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }) - ] -}) - -const fastify = Fastify() - -fastify.addContentTypeParser('*', (request, payload, done) => { - // Fully utilize oRPC feature by allowing any content type - // And let oRPC parse the body manually by passing `undefined` - done(null, undefined) -}) - -fastify.all('/rpc/*', async (req, reply) => { - const { matched } = await handler.handle(req, reply, { - prefix: '/rpc', - context: {} // Provide initial context if needed - }) - - if (!matched) { - reply.status(404).send('Not found') - } -}) - -fastify.listen({ port: 3000 }).then(() => console.log('Server running on http://localhost:3000')) -``` - -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: diff --git a/apps/content/docs/adapters/fetch-api.md b/apps/content/docs/adapters/fetch-api.md new file mode 100644 index 000000000..60a09c58e --- /dev/null +++ b/apps/content/docs/adapters/fetch-api.md @@ -0,0 +1,213 @@ +## Fetch API Adapter + +oRPC supports the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) for both servers and clients. + +## Server Usage + +::: code-group + +```ts [RPC] +import { RPCHandler } from '@orpc/server/fetch' +import { CORSPlugin } from '@orpc/server/plugins' +import { onError } from '@orpc/server' + +const handler = new RPCHandler(router, { + plugins: [ + new CORSHandlerPlugin() + ], + interceptors: [ + onError((error) => { + console.error(error) + }), + ], +}) + +export async function fetch(request: Request): Promise { + 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 }) +} +``` + +```ts [OpenAPI] +import { OpenAPIHandler } from '@orpc/openapi/fetch' +import { CORSPlugin } from '@orpc/server/plugins' +import { onError } from '@orpc/server' + +const handler = new OpenAPIHandler(router, { + plugins: [ + new CORSHandlerPlugin() + ], + interceptors: [ + onError((error) => { + console.error(error) + }), + ], +}) + +export async function fetch(request: Request): Promise { + const { matched, response } = await handler.handle(request, { + prefix: '/api', + context: {} // Provide initial context if needed + }) + + if (matched) { + return response + } + + return new Response('Not found', { status: 404 }) +} +``` + +::: + +::: info +The actual usage of `fetch` depends on the runtime environment or library you use: + +::: code-group + +```ts [Bun] +Bun.serve({ + fetch, +}) +``` + +```ts [Cloudflare Workers] +export default { + fetch, +} +``` + +```ts [Deno] +Deno.serve(fetch) +``` + +```ts [Hono Lambda] +import { handle } from 'hono/aws-lambda' + +export const handler = handle({ fetch }) +``` + +::: + + + +## Client Usage + +::: code-group + +```ts [RPC] +import { RPCLink } from '@orpc/client/fetch' +import { onError } from '@orpc/client' + +const link = new RPCLink({ + origin: 'https://api.example.com', // accepts async function, defaults to current origin + url: '/rpc', // accepts async function + headers: { authorization: 'bearer token' }, // accept async function + interceptors: [ + onError((error) => { + console.error(error) + }), + ], + fetch: (request, init) => { // <- override fetch if needed + return globalThis.fetch(request, { + ...init, + credentials: 'include', // Include cookies on cross-origin requests + }) + }, +}) +``` + +```ts [OpenAPI] +import { OpenAPILink } from '@orpc/openapi/fetch' +import { onError } from '@orpc/client' + +const link = new OpenAPILink(contract, { + origin: 'https://api.example.com', // accepts async function, defaults to current origin + url: '/rpc', // accepts async function + headers: { authorization: 'bearer token' }, // accept async function + interceptors: [ + onError((error) => { + console.error(error) + }), + ], + fetch: (request, init) => { // <- override fetch if needed + return globalThis.fetch(request, { + ...init, + credentials: 'include', // Include cookies on cross-origin requests + }) + }, +}) +``` + +::: + +::: info +The examples above only show how to configure the link. For examples of creating a typesafe client, see [RPC Link](/docs/rpc/link#typesafe-clients) and [OpenAPI Link](/docs/openapi/link#typesafe-clients). +::: + +## Event Stream Options + +You can configure how [event iterators](/docs/event-iterator) are streamed to the client using the `toFetchResponse.eventStream` options when creating the handler. + +```ts +const handler = new OpenAPIHandler(router, { + toFetchResponse: { + eventStream: { + initialComment: { + /** + * If true, an initial comment is sent immediately upon stream start to flush headers. + * This allows the receiving side to establish the connection without waiting for the first event. + * + * @default true + */ + enabled: true, + /** + * The content of the initial comment sent upon stream start. Must not include newline characters. + * + * @default '' + */ + comment: '', + }, + keepAlive: { + /** + * If true, a ping comment is sent periodically to keep the connection alive. + * + * @default true + */ + enabled: true, + /** + * Interval (in milliseconds) between ping comments sent after the last event. + * + * @default 5000 + */ + interval: 5000, + /** + * The content of the ping comment. Must not include newline characters. + * + * @default '' + */ + comment: '', + }, + /** + * If true, a `close` event is sent even when the iterator completes with `undefined`. + * When the iterator returns a value, a `close` event is always emitted regardless of this setting. + * + * @default true + */ + emptyCloseEventEnabled: true, + }, + }, +}) +``` + +::: info +You can also configure how [event iterators](/docs/event-iterator) are streamed from client to server using `toFetchBody.eventStream` options when creating the link. However, this is rarely used because streaming requests are not widely supported in browsers and may require manually overriding the `fetch` function with `duplex`. +::: diff --git a/apps/content/docs/adapters/h3.md b/apps/content/docs/adapters/h3.md deleted file mode 100644 index 970449755..000000000 --- a/apps/content/docs/adapters/h3.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: H3 Adapter -description: Use oRPC inside an H3 project ---- - -# H3 Adapter - -[H3](https://h3.dev/) is a universal, tiny, and fast web framework built on top of web standards. For additional context, refer to the [HTTP Adapter](/docs/adapters/http) guide. - -## Basic - -```ts -import { H3, serve } from 'h3' -import { RPCHandler } from '@orpc/server/fetch' -import { onError } from '@orpc/server' - -const app = new H3() - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -app.use('/rpc/**', async (event) => { - const { matched, response } = await handler.handle(event.req, { - prefix: '/rpc', - context: {} // Provide initial context if needed - }) - - if (matched) { - return response - } -}) - -serve(app, { port: 3000 }) -``` - -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: diff --git a/apps/content/docs/adapters/hono.md b/apps/content/docs/adapters/hono.md deleted file mode 100644 index 464595115..000000000 --- a/apps/content/docs/adapters/hono.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -title: Hono Adapter -description: Use oRPC inside an Hono project ---- - -# Hono Adapter - -[Hono](https://honojs.dev/) is a high-performance web framework built on top of [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). For additional context, refer to the [HTTP Adapter](/docs/adapters/http) guide. - -## Basic - -```ts -import { Hono } from 'hono' -import { RPCHandler } from '@orpc/server/fetch' -import { onError } from '@orpc/server' - -const app = new Hono() - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -app.use('/rpc/*', async (c, next) => { - const { matched, response } = await handler.handle(c.req.raw, { - prefix: '/rpc', - context: {} // Provide initial context if needed - }) - - if (matched) { - return c.newResponse(response.body, response) - } - - await next() -}) - -export default app -``` - -::: details Body Already Used Error? - -If Hono middleware reads the request body before the oRPC handler processes it, an error will occur. You can solve this by using a proxy to intercept the request body parsers with Hono parsers. - -```ts -const BODY_PARSER_METHODS = new Set(['arrayBuffer', 'blob', 'formData', 'json', 'text'] as const) - -type BodyParserMethod = typeof BODY_PARSER_METHODS extends Set ? T : never - -app.use('/rpc/*', async (c, next) => { - const request = new Proxy(c.req.raw, { - get(target, prop) { - if (BODY_PARSER_METHODS.has(prop as BodyParserMethod)) { - return () => c.req[prop as BodyParserMethod]() - } - return Reflect.get(target, prop, target) - } - }) - - const { matched, response } = await handler.handle(request, { - prefix: '/rpc', - context: {} // Provide initial context if needed - }) - - if (matched) { - return c.newResponse(response.body, response) - } - - await next() -}) -``` - -::: - -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: diff --git a/apps/content/docs/adapters/http.md b/apps/content/docs/adapters/http.md deleted file mode 100644 index 5b888421c..000000000 --- a/apps/content/docs/adapters/http.md +++ /dev/null @@ -1,298 +0,0 @@ ---- -title: HTTP -description: How to use oRPC over HTTP? ---- - -# HTTP - -oRPC includes built-in HTTP support, making it easy to expose RPC endpoints in any environment that speaks HTTP. - -## Server Adapters - -| Adapter | Target | -| ------------ | -------------------------------------------------------------------------------------------------------------------------- | -| `fetch` | [MDN Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) (Browser, Bun, Deno, Cloudflare Workers, etc.) | -| `node` | Node.js built-in [`http`](https://nodejs.org/api/http.html)/[`http2`](https://nodejs.org/api/http2.html) | -| `fastify` | [Fastify](https://fastify.dev/) | -| `aws-lambda` | [AWS Lambda](https://aws.amazon.com/lambda/) | - -::: code-group - -```ts [node] -import { createServer } from 'node:http' // or 'node:http2' -import { RPCHandler } from '@orpc/server/node' -import { CORSPlugin } from '@orpc/server/plugins' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - plugins: [ - new CORSPlugin() - ], - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -const server = createServer(async (req, res) => { - const { matched } = await handler.handle(req, res, { - prefix: '/rpc', - context: {} // Provide initial context if needed - }) - - if (matched) { - return - } - - res.statusCode = 404 - res.end('Not found') -}) - -server.listen(3000, '127.0.0.1', () => console.log('Listening on 127.0.0.1:3000')) -``` - -```ts [bun] -import { RPCHandler } from '@orpc/server/fetch' -import { CORSPlugin } from '@orpc/server/plugins' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - plugins: [ - new CORSPlugin() - ], - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -Bun.serve({ - async 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 }) - } -}) -``` - -```ts [cloudflare] -import { RPCHandler } from '@orpc/server/fetch' -import { CORSPlugin } from '@orpc/server/plugins' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - plugins: [ - new CORSPlugin() - ], - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -export default { - async fetch(request: Request, env: any, ctx: ExecutionContext): Promise { - 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 }) - } -} -``` - -```ts [deno] -import { RPCHandler } from '@orpc/server/fetch' -import { CORSPlugin } from '@orpc/server/plugins' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - plugins: [ - new CORSPlugin() - ], - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -Deno.serve(async (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 }) -}) -``` - -```ts [fastify] -import Fastify from 'fastify' -import { RPCHandler } from '@orpc/server/fastify' -import { onError } from '@orpc/server' - -const rpcHandler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -const fastify = Fastify() - -fastify.addContentTypeParser('*', (request, payload, done) => { - // Fully utilize oRPC feature by allowing any content type - // And let oRPC parse the body manually by passing `undefined` - done(null, undefined) -}) - -fastify.all('/rpc/*', async (req, reply) => { - const { matched } = await rpcHandler.handle(req, reply, { - prefix: '/rpc', - }) - - if (!matched) { - reply.status(404).send('Not found') - } -}) - -fastify.listen({ port: 3000 }).then(() => console.log('Listening on 127.0.0.1:3000')) -``` - -```ts [aws-lambda] -import { APIGatewayProxyEventV2 } from 'aws-lambda' -import { RPCHandler } from '@orpc/server/aws-lambda' -import { onError } from '@orpc/server' - -const rpcHandler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -/** - * oRPC only supports [AWS Lambda response streaming](https://aws.amazon.com/blogs/compute/introducing-aws-lambda-response-streaming/). - * If you need support chunked responses, use a combination of Hono's `aws-lambda` adapter and oRPC. - */ -export const handler = awslambda.streamifyResponse(async (event, responseStream, context) => { - const { matched } = await rpcHandler.handle(event, responseStream, { - prefix: '/rpc', - context: {} // Provide initial context if needed - }) - - if (matched) { - return - } - - awslambda.HttpResponseStream.from(responseStream, { - statusCode: 404, - }) - - responseStream.write('Not found') - responseStream.end() -}) -``` - -::: - -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: - -## Client Adapters - -| Adapter | Target | -| ------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `fetch` | [MDN Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) (Browser, Node, Bun, Deno, Cloudflare Workers, etc.) | - -```ts -import { RPCLink } from '@orpc/client/fetch' - -const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - headers: () => ({ - 'x-api-key': 'my-api-key' - }), - // fetch: <-- polyfill fetch if needed -}) -``` - -::: info -The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or another custom handler. -::: - -::: info -This only shows how to configure the http link. For full client examples, see [Client-Side Clients](/docs/client/client-side). -::: - -## Event Iterator Options - -HTTP adapters provide reliability features for streaming [Event Iterators](/docs/event-iterator): - -```ts -const handler = new RPCHandler(router, { - eventIteratorInitialCommentEnabled: true, - eventIteratorInitialComment: 'start', - eventIteratorKeepAliveEnabled: true, - eventIteratorKeepAliveInterval: 5000, - eventIteratorKeepAliveComment: '', -}) - -const link = new OpenAPILink({ - eventIteratorInitialCommentEnabled: true, - eventIteratorInitialComment: 'start', - eventIteratorKeepAliveEnabled: true, - eventIteratorKeepAliveInterval: 5000, - eventIteratorKeepAliveComment: '', -}) -``` - -::: info -These options are available for HTTP-based handlers and links only. -::: - -::: warning -Link options apply when streaming from **client to server**, not server to client (as with handlers). In most cases, you don't need to configure these on the link. -::: - -### Initial Comment - -Sends an initial comment immediately when the stream starts to flush response headers early. This allows the receiving side to establish the connection without waiting for the first event. - -| Option | Default | Description | -| ------------------------------------ | ------- | ------------------------------ | -| `eventIteratorInitialCommentEnabled` | `true` | Enable/disable initial comment | -| `eventIteratorInitialComment` | `''` | Custom comment content | - -### Keep-Alive Comments - -Sends periodic comments during inactivity to prevent connection timeouts. - -| Option | Default | Description | -| -------------------------------- | ------- | ---------------------------------- | -| `eventIteratorKeepAliveEnabled` | `true` | Enable/disable keep-alive comments | -| `eventIteratorKeepAliveInterval` | `5000` | Interval in milliseconds | -| `eventIteratorKeepAliveComment` | `''` | Custom comment content | diff --git a/apps/content/docs/adapters/message-port.md b/apps/content/docs/adapters/message-port.md index 602e1be1f..ee9aebe23 100644 --- a/apps/content/docs/adapters/message-port.md +++ b/apps/content/docs/adapters/message-port.md @@ -1,29 +1,18 @@ ---- -title: Message Port -description: Using oRPC with Message Ports ---- +# Message Port Adapter -# Message Port - -oRPC offers built-in support for common Message Port implementations, enabling easy internal communication between different processes. - -| Environment | Documentation | -| ------------------------------------------------------------------------------------------ | ---------------------------------------------- | -| [Electron Message Port](https://www.electronjs.org/docs/latest/tutorial/message-ports) | [Adapter Guide](/docs/adapters/electron) | -| Browser (extension background to popup/content, window to window, etc.) | [Adapter Guide](/docs/adapters/browser) | -| [Node.js Worker Threads Port](https://nodejs.org/api/worker_threads.html#workerparentport) | [Adapter Guide](/docs/adapters/worker-threads) | +oRPC supports the [Message Port](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort) for communicating between different contexts, such as iframes, web workers, and service workers. ## Basic Usage Message Ports work by establishing two endpoints that can communicate with each other: -```ts [bridge] +```ts [Bridge] const channel = new MessageChannel() const serverPort = channel.port1 const clientPort = channel.port2 ``` -```ts [server] +```ts [Server] import { RPCHandler } from '@orpc/server/message-port' import { onError } from '@orpc/server' @@ -36,24 +25,40 @@ const handler = new RPCHandler(router, { }) handler.upgrade(serverPort, { - context: {}, // Provide initial context if needed + /** + * Provide initial context if needed. The context can be an async function + * that receives the per-call request as its first argument, and is **not** + * related to the initial upgrade request. + */ + context: request => ({}), }) serverPort.start() ``` -```ts [client] +```ts [Client] import { RPCLink } from '@orpc/client/message-port' +import { onError } from '@orpc/client' const link = new RPCLink({ port: clientPort, + interceptors: [ + onError((error) => { + console.error(error) + }), + ], + /** + * Optional headers to attach to each per-call request. + * These can be accessed in the server context or via the Request Headers Plugin. + */ + headers: () => ({}) }) clientPort.start() ``` -:::info -This only shows how to configure the link. For full client examples, see [Client-Side Clients](/docs/client/client-side). +::: info +The examples above only show how to configure the link. For examples of creating a typesafe client, see [RPC Link](/docs/rpc/link#typesafe-clients). ::: ## Transfer @@ -73,7 +78,6 @@ const handler = new RPCHandler(router, { ```ts [link] const link = new RPCLink({ - port: clientPort, experimental_transfer: (message) => { const transfer = deepFindTransferableObjects(message) // implement your own logic return transfer.length ? transfer : null // only enable when needed @@ -83,10 +87,6 @@ const link = new RPCLink({ ::: -::: warning -When `transfer` returns an array, messages using [the structured clone algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) for sending, which doesn't support all data types such as [Event Iterator's Metadata](/docs/event-iterator#last-event-id-event-metadata). So I recommend you only enable this when needed. -::: - -::: tip -The `transfer` option run after [RPC JSON Serializer](/docs/advanced/rpc-json-serializer) so you can combine them together to support more data types. +::: info +When `transfer` returns an array, messages are sent using [the structured clone algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm), which doesn't support all data types. If you need to support additional data types, consider customizing your [RPC Serializer](/docs/rpc/serializer). ::: diff --git a/apps/content/docs/adapters/next.md b/apps/content/docs/adapters/next.md deleted file mode 100644 index ec67e3b81..000000000 --- a/apps/content/docs/adapters/next.md +++ /dev/null @@ -1,193 +0,0 @@ ---- -title: Next.js Adapter -description: Use oRPC inside an Next.js project ---- - -# Next.js Adapter - -[Next.js](https://nextjs.org/) is a leading React framework for server-rendered apps. oRPC works with both the [App Router](https://nextjs.org/docs/app/getting-started/installation) and [Pages Router](https://nextjs.org/docs/pages/getting-started/installation). For additional context, refer to the [HTTP Adapter](/docs/adapters/http) guide. - -::: info -oRPC also provides out-of-the-box support for [Server Action](/docs/server-action) with no additional configuration required. -::: - -## Server - -You set up an oRPC server inside Next.js using its [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers). - -::: code-group - -```ts [app/rpc/[[...rest]]/route.ts] -import { RPCHandler } from '@orpc/server/fetch' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -async function handleRequest(request: Request) { - const { response } = await handler.handle(request, { - prefix: '/rpc', - context: {}, // Provide initial context if needed - }) - - return response ?? new Response('Not found', { status: 404 }) -} - -export const HEAD = handleRequest -export const GET = handleRequest -export const POST = handleRequest -export const PUT = handleRequest -export const PATCH = handleRequest -export const DELETE = handleRequest -``` - -::: - -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: - -::: details Pages Router Support? - -```ts [pages/api/rpc/[[...rest]].ts] -import { RPCHandler } from '@orpc/server/node' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -export const config = { - api: { - bodyParser: false, - }, -} - -export default async (req, res) => { - const { matched } = await handler.handle(req, res, { - prefix: '/api/rpc', - context: {}, // Provide initial context if needed - }) - - if (matched) { - return - } - - res.statusCode = 404 - res.end('Not found') -} -``` - -::: warning -Next.js [body parser](https://nextjs.org/docs/pages/building-your-application/routing/api-routes#custom-config) may handle common request body types, and oRPC will use the parsed body if available. However, it doesn't support features like [Bracket Notation](/docs/openapi/bracket-notation), and in case you upload a file with `application/json`, it may be parsed as plain JSON instead of a `File`. To avoid these issues, disable the body parser: - -```ts -export const config = { - api: { - bodyParser: false, - }, -} -``` - -::: - -## Client - -By leveraging `headers` from `next/headers`, you can configure the RPC link to work seamlessly in both browser and server environments: - -```ts [lib/orpc.ts] -import { RPCLink } from '@orpc/client/fetch' - -const link = new RPCLink({ - url: `${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/rpc`, - headers: async () => { - if (typeof window !== 'undefined') { - return {} - } - - const { headers } = await import('next/headers') - return await headers() - }, -}) -``` - -:::info -This only shows how to configure the link. For full client examples, see [Client-Side Clients](/docs/client/client-side). -::: - -## Optimize SSR - -To reduce HTTP requests and improve latency during SSR, you can utilize a [Server-Side Client](/docs/client/server-side) during SSR. Below is a quick setup, see [Optimize SSR](/docs/best-practices/optimize-ssr) for more details. - -::: code-group - -```ts [lib/orpc.ts] -import type { RouterClient } from '@orpc/server' -import { RPCLink } from '@orpc/client/fetch' -import { createORPCClient } from '@orpc/client' - -declare global { - var $client: RouterClient | undefined -} - -const link = new RPCLink({ - url: () => { - if (typeof window === 'undefined') { - throw new Error('RPCLink is not allowed on the server side.') - } - - return `${window.location.origin}/rpc` - }, -}) - -/** - * Fallback to client-side client if server-side client is not available. - */ -export const client: RouterClient = globalThis.$client ?? createORPCClient(link) -``` - -```ts [lib/orpc.server.ts] -import 'server-only' - -import { headers } from 'next/headers' -import { createRouterClient } from '@orpc/server' - -globalThis.$client = createRouterClient(router, { - /** - * Provide initial context if needed. - * - * Because this client instance is shared across all requests, - * only include context that's safe to reuse globally. - * For per-request context, use middleware context or pass a function as the initial context. - */ - context: async () => ({ - headers: await headers(), // provide headers if initial context required - }), -}) -``` - -```ts [instrumentation.ts] -export async function register() { - // Conditionally import if facing runtime compatibility issues - // if (process.env.NEXT_RUNTIME === "nodejs") { - await import('./lib/orpc.server') - // } -} -``` - -```ts [app/layout.tsx] -import '../lib/orpc.server' // for pre-rendering - -// Rest of the code -``` - -::: diff --git a/apps/content/docs/adapters/node-http.md b/apps/content/docs/adapters/node-http.md new file mode 100644 index 000000000..a1c125703 --- /dev/null +++ b/apps/content/docs/adapters/node-http.md @@ -0,0 +1,134 @@ +# Node HTTP Adapter + +oRPC supports [Node HTTP](https://nodejs.org/api/http.html), [Node HTTPS](https://nodejs.org/api/https.html), and [Node HTTP2](https://nodejs.org/api/http2.html) for servers. + +## Server Usage + +::: code-group + +```ts [RPC] +import { createServer } from 'node:http' // or 'node:https' or 'node:http2' +import { RPCHandler } from '@orpc/server/node' +import { CORSHandlerPlugin } from '@orpc/server/plugins' +import { onError } from '@orpc/server' + +const handler = new RPCHandler(router, { + plugins: [ + new CORSHandlerPlugin() + ], + interceptors: [ + onError((error) => { + console.error(error) + }), + ], +}) + +const server = createServer(async (req, res) => { + const { matched } = await handler.handle(req, res, { + prefix: '/rpc', + context: {} // Provide initial context if needed + }) + + if (matched) { + return + } + + res.statusCode = 404 + res.end('Not found') +}) + +server.listen(3000, '127.0.0.1', () => console.log('Listening on 127.0.0.1:3000')) +``` + +```ts [OpenAPI] +import { createServer } from 'node:http' // or 'node:https' or 'node:http2' +import { OpenAPIHandler } from '@orpc/openapi/node' +import { CORSHandlerPlugin } from '@orpc/server/plugins' +import { onError } from '@orpc/server' + +const handler = new OpenAPIHandler(router, { + plugins: [ + new CORSHandlerPlugin() + ], + interceptors: [ + onError((error) => { + console.error(error) + }), + ], +}) + +const server = createServer(async (req, res) => { + const { matched } = await handler.handle(req, res, { + prefix: '/api', + context: {} // Provide initial context if needed + }) + + if (matched) { + return + } + + res.statusCode = 404 + res.end('Not found') +}) + +server.listen(3000, '127.0.0.1', () => console.log('Listening on 127.0.0.1:3000')) +``` + +::: + + + +## Event Stream Options + +You can configure how [event iterators](/docs/event-iterator) are streamed to the client using the `sendStandardResponse.eventStream` options when creating the handler. + +```ts +const handler = new OpenAPIHandler(router, { + sendStandardResponse: { + eventStream: { + initialComment: { + /** + * If true, an initial comment is sent immediately upon stream start to flush headers. + * This allows the receiving side to establish the connection without waiting for the first event. + * + * @default true + */ + enabled: true, + /** + * The content of the initial comment sent upon stream start. Must not include newline characters. + * + * @default '' + */ + comment: '', + }, + keepAlive: { + /** + * If true, a ping comment is sent periodically to keep the connection alive. + * + * @default true + */ + enabled: true, + /** + * Interval (in milliseconds) between ping comments sent after the last event. + * + * @default 5000 + */ + interval: 5000, + /** + * The content of the ping comment. Must not include newline characters. + * + * @default '' + */ + comment: '', + }, + /** + * If true, a `close` event is sent even when the iterator completes with `undefined`. + * When the iterator returns a value, a `close` event is always emitted regardless of this setting. + * + * @default true + */ + emptyCloseEventEnabled: true, + }, + }, +}) +``` diff --git a/apps/content/docs/adapters/nuxt.md b/apps/content/docs/adapters/nuxt.md deleted file mode 100644 index 54c62b76b..000000000 --- a/apps/content/docs/adapters/nuxt.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -title: Nuxt.js Adapter -description: Use oRPC inside an Nuxt.js project ---- - -# Nuxt.js Adapter - -[Nuxt.js](https://nuxt.com/) is a popular Vue.js framework for building server-side applications. For more details, see the [HTTP Adapter](/docs/adapters/http) guide. - -## Server - -You set up an oRPC server inside Nuxt using its [Server Routes](https://nuxt.com/docs/guide/directory-structure/server#server-routes). - -::: code-group - -```ts [server/routes/rpc/[...].ts] -import { RPCHandler } from '@orpc/server/fetch' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -export default defineEventHandler(async (event) => { - const request = toWebRequest(event) - - const { response } = await handler.handle(request, { - prefix: '/rpc', - context: {}, // Provide initial context if needed - }) - - if (response) { - return response - } - - setResponseStatus(event, 404, 'Not Found') - return 'Not found' -}) -``` - -```ts [server/routes/rpc/index.ts] -export { default } from './[...]' -``` - -::: - -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: - -## Client - -To make the oRPC client compatible with SSR, set it up inside a [Nuxt Plugin](https://nuxt.com/docs/guide/directory-structure/plugins). - -```ts [app/plugins/orpc.ts] -export default defineNuxtPlugin(() => { - const event = useRequestEvent() - - const link = new RPCLink({ - url: `${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/rpc`, - headers: event?.headers, - }) - - const client: RouterClient = createORPCClient(link) - - return { - provide: { - client, - }, - } -}) -``` - -:::info -You can learn more about client setup in [Client-Side Clients](/docs/client/client-side). -::: - -## Optimize SSR - -To reduce HTTP requests and improve latency during SSR, you can utilize a [Server-Side Client](/docs/client/server-side) during SSR. Below is a quick setup, see [Optimize SSR](/docs/best-practices/optimize-ssr) for more details. - -::: code-group - -```ts [app/plugins/orpc.client.ts] -export default defineNuxtPlugin(() => { - const link = new RPCLink({ - url: `${window.location.origin}/rpc`, - headers: () => ({}), - }) - - const client: RouterClient = createORPCClient(link) - - return { - provide: { - client, - }, - } -}) -``` - -```ts [app/plugins/orpc.server.ts] -export default defineNuxtPlugin((nuxt) => { - const event = useRequestEvent() - - const client = createRouterClient(router, { - context: { - headers: event?.headers, // provide headers if initial context required - }, - }) - - return { - provide: { - client, - }, - } -}) -``` - -::: diff --git a/apps/content/docs/adapters/react-native.md b/apps/content/docs/adapters/react-native.md deleted file mode 100644 index d64ca8098..000000000 --- a/apps/content/docs/adapters/react-native.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: React Native Adapter -description: Use oRPC inside a React Native project ---- - -# React Native Adapter - -[React Native](https://reactnative.dev/) is a framework for building native apps using React. For additional context, refer to the [HTTP Adapter](/docs/adapters/http) guide. - -## Fetch Link - -React Native includes a [Fetch API](https://reactnative.dev/docs/network), so you can use oRPC out of the box. - -::: warning -However, the Fetch API in React Native has limitations. oRPC features like [File/Blob](/docs/file-upload-download), and [Event Iterator](/docs/event-iterator) aren't supported. Follow [Support Stream #27741](https://github.com/facebook/react-native/issues/27741) for updates. -::: - -::: tip -If you're using `RPCHandler/Link`, you can temporarily add support for binary data by extending the [RPC JSON Serializer](/docs/advanced/rpc-json-serializer#extending-native-data-types) to encode these types as Base64. -::: - -```ts -import { RPCLink } from '@orpc/client/fetch' - -const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - headers: async ({ context }) => ({ - 'x-api-key': context?.something ?? '' - }) - // fetch: <-- polyfill fetch if needed -}) -``` - -::: info -The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or another custom link. -::: - -### `expo/fetch` - -If you're using [Expo](https://expo.dev/), you can use the [`expo/fetch`](https://docs.expo.dev/versions/latest/sdk/expo/#expofetch-api) to expand support for [Event Iterator](/docs/event-iterator). - -```ts -export const link = new RPCLink({ - url: `http://localhost:3000/rpc`, - async fetch(request, init) { - const { fetch } = await import('expo/fetch') - - const resp = await fetch(request.url, { - body: await request.blob(), - headers: request.headers, - method: request.method, - signal: request.signal, - ...init, - }) - - return resp - }, -}) -``` diff --git a/apps/content/docs/adapters/remix.md b/apps/content/docs/adapters/remix.md deleted file mode 100644 index e1a0a8937..000000000 --- a/apps/content/docs/adapters/remix.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: Remix Adapter -description: Use oRPC inside an Remix project ---- - -# Remix Adapter - -[Remix](https://remix.run/) is a full stack JavaScript framework for building web applications with React. For additional context, refer to the [HTTP Adapter](/docs/adapters/http) guide. - -## Basic - -```ts [app/routes/rpc.$.ts] -import { RPCHandler } from '@orpc/server/fetch' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -export async function loader({ request }: LoaderFunctionArgs) { - const { response } = await handler.handle(request, { - prefix: '/rpc', - context: {} // Provide initial context if needed - }) - - return response ?? new Response('Not Found', { status: 404 }) -} -``` - -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: diff --git a/apps/content/docs/adapters/solid-start.md b/apps/content/docs/adapters/solid-start.md deleted file mode 100644 index d7467bb8f..000000000 --- a/apps/content/docs/adapters/solid-start.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -title: Solid Start Adapter -description: Use oRPC inside a Solid Start project ---- - -# Solid Start Adapter - -[Solid Start](https://start.solidjs.com/) is a full stack JavaScript framework for building web applications with SolidJS. For additional context, refer to the [HTTP Adapter](/docs/adapters/http) guide. - -## Server - -::: code-group - -```ts [src/routes/rpc/[...rest].ts] -import type { APIEvent } from '@solidjs/start/server' -import { RPCHandler } from '@orpc/server/fetch' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -async function handle({ request }: APIEvent) { - const { response } = await handler.handle(request, { - prefix: '/rpc', - context: {} // Provide initial context if needed - }) - - return response ?? new Response('Not Found', { status: 404 }) -} - -export const HEAD = handle -export const GET = handle -export const POST = handle -export const PUT = handle -export const PATCH = handle -export const DELETE = handle -``` - -```ts [src/routes/rpc/index.ts] -import { POST as handle } from './[...rest]' - -export const HEAD = handle -export const GET = handle -export const POST = handle -export const PUT = handle -export const PATCH = handle -export const DELETE = handle -``` - -::: - -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: - -## Client - -On the client, use `getRequestEvent` to provide a headers function that works seamlessly with SSR. This enables usage in both server and browser environments. - -```ts -import { RPCLink } from '@orpc/client/fetch' -import { getRequestEvent } from 'solid-js/web' - -const link = new RPCLink({ - url: `${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/rpc`, - headers: () => getRequestEvent()?.request.headers ?? {}, -}) -``` - -:::info -This only shows how to configure the link. For full client examples, see [Client-Side Clients](/docs/client/client-side). -::: - -## Optimize SSR - -To reduce HTTP requests and improve latency during SSR, you can utilize a [Server-Side Client](/docs/client/server-side) during SSR. Below is a quick setup, see [Optimize SSR](/docs/best-practices/optimize-ssr) for more details. - -::: code-group - -```ts [src/lib/orpc.ts] -if (typeof window === 'undefined') { - await import('./orpc.server') -} - -import type { RouterClient } from '@orpc/server' -import { RPCLink } from '@orpc/client/fetch' -import { createORPCClient } from '@orpc/client' - -declare global { - var $client: RouterClient | undefined -} - -const link = new RPCLink({ - url: () => { - if (typeof window === 'undefined') { - throw new Error('RPCLink is not allowed on the server side.') - } - - return `${window.location.origin}/rpc` - }, -}) - -/** - * Fallback to client-side client if server-side client is not available. - */ -export const client: RouterClient = globalThis.$client ?? createORPCClient(link) -``` - -```ts [src/lib/orpc.server.ts] -import { createRouterClient } from '@orpc/server' -import { getRequestEvent } from 'solid-js/web' - -if (typeof window !== 'undefined') { - throw new Error('This file should not be imported in the browser') -} - -globalThis.$client = createRouterClient(router, { - /** - * Provide initial context if needed. - * - * Because this client instance is shared across all requests, - * only include context that's safe to reuse globally. - * For per-request context, use middleware context or pass a function as the initial context. - */ - context: async () => { - const headers = getRequestEvent()?.request.headers - - return { - headers, // provide headers if initial context required - } - }, -}) -``` - -::: diff --git a/apps/content/docs/adapters/svelte-kit.md b/apps/content/docs/adapters/svelte-kit.md deleted file mode 100644 index 556efb939..000000000 --- a/apps/content/docs/adapters/svelte-kit.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -title: Svelte Kit Adapter -description: Use oRPC inside an Svelte Kit project ---- - -# Svelte Kit Adapter - -[Svelte Kit](https://svelte.dev/docs/kit/introduction) is a framework for rapidly developing robust, performant web applications using Svelte. For additional context, refer to the [HTTP Adapter](/docs/adapters/http) guide. - -## Server - -::: code-group - -```ts [src/routes/rpc/[...rest]/+server.ts] -import { error } from '@sveltejs/kit' -import { RPCHandler } from '@orpc/server/fetch' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -const handle: RequestHandler = async ({ request }) => { - const { response } = await handler.handle(request, { - prefix: '/rpc', - context: {} // Provide initial context if needed - }) - - return response ?? new Response('Not Found', { status: 404 }) -} - -export const GET = handle -export const POST = handle -export const PUT = handle -export const PATCH = handle -export const DELETE = handle -``` - -::: - -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: - -## Optimize SSR - -To reduce HTTP requests and improve latency during SSR, you can utilize [Svelte's special `fetch`](https://svelte.dev/docs/kit/web-standards#Fetch-APIs) during SSR. Below is a quick setup, see [Optimize SSR](/docs/best-practices/optimize-ssr) for more details. - -::: code-group - -```ts [src/lib/orpc.ts] -import type { RouterClient } from '@orpc/server' -import { RPCLink } from '@orpc/client/fetch' -import { createORPCClient } from '@orpc/client' - -declare global { - var $client: RouterClient | undefined -} - -const link = new RPCLink({ - url: () => { - if (typeof window === 'undefined') { - throw new Error('This link is not allowed on the server side.') - } - - return `${window.location.origin}/rpc` - }, -}) - -export const client: RouterClient = globalThis.$client ?? createORPCClient(link) -``` - -```ts [src/lib/orpc.server.ts] -import type { RouterClient } from '@orpc/server' -import { createORPCClient } from '@orpc/client' -import { RPCLink } from '@orpc/client/fetch' -import { getRequestEvent } from '$app/server' - -if (typeof window !== 'undefined') { - throw new Error('This file should only be imported on the server') -} - -const link = new RPCLink({ - url: async () => { - return `${getRequestEvent().url.origin}/rpc` - }, - async fetch(request, init) { - return getRequestEvent().fetch(request, init) - }, -}) - -const serverClient: RouterClient = createORPCClient(link) -globalThis.$client = serverClient -``` - -```ts [src/hooks.server.ts] -import './lib/orpc.server' -// ... -``` - -::: diff --git a/apps/content/docs/adapters/tanstack-start.md b/apps/content/docs/adapters/tanstack-start.md deleted file mode 100644 index 80a242035..000000000 --- a/apps/content/docs/adapters/tanstack-start.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -title: TanStack Start Adapter -description: Use oRPC inside a TanStack Start project ---- - -# TanStack Start Adapter - -[TanStack Start](https://tanstack.com/start) is a full-stack React framework built on [Vite](https://vitejs.dev/) and the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). For additional context, see the [HTTP Adapter](/docs/adapters/http) guide. - -## Server - -You set up an oRPC server inside TanStack Start using its [Server Routes](https://tanstack.com/start/latest/docs/framework/react/guide/server-routes). - -::: code-group - -```ts [src/routes/api/rpc.$.ts] -import { RPCHandler } from '@orpc/server/fetch' -import { createFileRoute } from '@tanstack/react-router' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -export const Route = createFileRoute('/api/rpc/$')({ - server: { - handlers: { - ANY: async ({ request }) => { - const { response } = await handler.handle(request, { - prefix: '/api/rpc', - context: {}, // Provide initial context if needed - }) - - return response ?? new Response('Not Found', { status: 404 }) - }, - }, - }, -}) -``` - -::: - -::: info -The `handler` can be any supported oRPC handler, including [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or a custom handler. -::: - -## Client - -Use `createIsomorphicFn` to configure the RPC link with environment-specific settings for both browser and SSR environments: - -```ts -import { RPCLink } from '@orpc/client/fetch' -import { createIsomorphicFn } from '@tanstack/react-start' -import { getRequestHeaders } from '@tanstack/react-start/server' - -const getClientLink = createIsomorphicFn() - .client(() => new RPCLink({ - url: `${window.location.origin}/api/rpc`, - })) - .server(() => new RPCLink({ - url: 'http://localhost:3000/api/rpc', - headers: () => getRequestHeaders(), - })) -``` - -:::info -This only shows how to configure the link. For full client examples, see [Client-Side Clients](/docs/client/client-side). -::: - -## Optimize SSR - -To reduce HTTP requests and improve latency during SSR, you can utilize a [Server-Side Client](/docs/client/server-side) during SSR. Below is a quick setup, see [Optimize SSR](/docs/best-practices/optimize-ssr) for more details. - -::: code-group - -```ts [src/lib/orpc.ts] -import { createRouterClient } from '@orpc/server' -import type { RouterClient } from '@orpc/server' -import { createORPCClient } from '@orpc/client' -import { RPCLink } from '@orpc/client/fetch' -import { getRequestHeaders } from '@tanstack/react-start/server' -import { createIsomorphicFn } from '@tanstack/react-start' - -const getORPCClient = createIsomorphicFn() - .server(() => createRouterClient(router, { - /** - * Provide initial context if needed. - * - * Because this client instance is shared across all requests, - * only include context that's safe to reuse globally. - * For per-request context, use middleware context or pass a function as the initial context. - */ - context: async () => ({ - headers: getRequestHeaders(), // provide headers if initial context required - }), - })) - .client((): RouterClient => { - const link = new RPCLink({ - url: `${window.location.origin}/api/rpc`, - }) - - return createORPCClient(link) - }) - -export const client: RouterClient = getORPCClient() -``` - -::: diff --git a/apps/content/docs/adapters/web-workers.md b/apps/content/docs/adapters/web-workers.md deleted file mode 100644 index 5e574e030..000000000 --- a/apps/content/docs/adapters/web-workers.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: Web Workers Adapter -description: Enable type-safe communication with Web Workers using oRPC. ---- - -# Web Workers Adapter - -[Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Worker) allow JavaScript code to run in background threads, separate from the main thread of a web page. This prevents blocking the UI while performing computationally intensive tasks. Web Workers are also supported in modern runtimes like [Bun](https://bun.com/docs/api/workers), [Deno](https://docs.deno.com/examples/web_workers/), etc. - -With oRPC, you can establish type-safe communication channels between your main thread and Web Workers. For additional context, see the [Message Port Adapter](/docs/adapters/message-port) guide. - -## Web Worker - -Configure your Web Worker to handle oRPC requests by upgrading it with a message port handler: - -```ts -import { RPCHandler } from '@orpc/server/message-port' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -handler.upgrade(self, { - context: {}, // Provide initial context if needed -}) -``` - -## Main Thread - -Create a link to communicate with your Web Worker: - -```ts -import { RPCLink } from '@orpc/client/message-port' - -export const link = new RPCLink({ - port: new Worker('some-worker.ts') -}) -``` - -:::details Using Web Workers in Vite Applications? -You can leverage [Vite Web Workers feature](https://vite.dev/guide/features.html#web-workers) for streamlined development: - -```ts -import SomeWorker from './some-worker.ts?worker' // [!code highlight] -import { RPCLink } from '@orpc/client/message-port' - -export const link = new RPCLink({ - port: new SomeWorker() -}) -``` - -::: - -:::info -This only shows how to configure the link. For full client examples, see [Client-Side Clients](/docs/client/client-side). -::: diff --git a/apps/content/docs/adapters/websocket.md b/apps/content/docs/adapters/websocket.md index 8496bd0ea..93df42685 100644 --- a/apps/content/docs/adapters/websocket.md +++ b/apps/content/docs/adapters/websocket.md @@ -1,24 +1,18 @@ ---- -title: Websocket -description: How to use oRPC over WebSocket? ---- +# WebSocket Adapters -# Websocket - -oRPC provides built-in WebSocket support for low-latency, bidirectional RPC. +oRPC supports WebSockets for low-latency, full-duplex communication between clients and servers. ## Server Adapters -| Adapter | Target | -| ----------- | ------------------------------------------------------------------------------------------------------------------------ | -| `websocket` | [MDN WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) (Browser, Deno, Cloudflare Worker, etc.) | -| `crossws` | [Crossws](https://github.com/h3js/crossws) library (Node, Bun, Deno, SSE, etc.) | -| `ws` | [ws](https://github.com/websockets/ws) library (Node.js) | -| `bun-ws` | [Bun Websocket Server](https://bun.sh/docs/api/websockets) | +| Adapter | Target | +| ----------- | ----------------------------------------------------------------------------------------------------------------------- | +| `websocket` | [MDN WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket), [ws](https://github.com/websockets/ws) | +| `crossws` | [crossws](https://github.com/h3js/crossws) | ::: code-group -```ts [Websocket] +```ts [ws] +import { WebSocketServer } from 'ws' import { RPCHandler } from '@orpc/server/websocket' import { onError } from '@orpc/server' @@ -30,22 +24,21 @@ const handler = new RPCHandler(router, { ], }) -Deno.serve((req) => { - if (req.headers.get('upgrade') !== 'websocket') { - return new Response(null, { status: 501 }) - } - - const { socket, response } = Deno.upgradeWebSocket(req) +const wss = new WebSocketServer({ port: 8080 }) - handler.upgrade(socket, { - context: {}, // Provide initial context if needed +wss.on('connection', (ws) => { + handler.upgrade(ws, { + /** + * Provide initial context if needed. The context can be an async function + * that receives the per-call request as its first argument, and is **not** + * related to the initial WebSocket upgrade request. + */ + context: request => ({}), }) - - return response }) ``` -```ts [CrossWS] +```ts [crossws] import { createServer } from 'node:http' import { experimental_RPCHandler as RPCHandler } from '@orpc/server/crossws' import { onError } from '@orpc/server' @@ -65,7 +58,12 @@ const ws = crossws({ hooks: { message: (peer, message) => { handler.message(peer, message, { - context: {}, // Provide initial context if needed + /** + * Provide initial context if needed. The context can be an async function + * that receives the per-call request as its first argument, and is **not** + * related to the initial WebSocket upgrade request. + */ + context: request => ({}), }) }, close: (peer) => { @@ -85,9 +83,8 @@ server.on('upgrade', (req, socket, head) => { }) ``` -```ts [WS] -import { WebSocketServer } from 'ws' -import { RPCHandler } from '@orpc/server/ws' +```ts [Deno] +import { RPCHandler } from '@orpc/server/websocket' import { onError } from '@orpc/server' const handler = new RPCHandler(router, { @@ -98,49 +95,27 @@ const handler = new RPCHandler(router, { ], }) -const wss = new WebSocketServer({ port: 8080 }) - -wss.on('connection', (ws) => { - handler.upgrade(ws, { - context: {}, // Provide initial context if needed - }) -}) -``` - -```ts [Bun WebSocket] -import { RPCHandler } from '@orpc/server/bun-ws' -import { onError } from '@orpc/server' +Deno.serve((req) => { + if (req.headers.get('upgrade') !== 'websocket') { + return new Response(null, { status: 501 }) + } -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) + const { socket, response } = Deno.upgradeWebSocket(req) -Bun.serve({ - fetch(req, server) { - if (server.upgrade(req)) { - return - } + handler.upgrade(socket, { + /** + * Provide initial context if needed. The context can be an async function + * that receives the per-call request as its first argument, and is **not** + * related to the initial WebSocket upgrade request. + */ + context: request => ({}), + }) - return new Response('Upgrade failed', { status: 500 }) - }, - websocket: { - message(ws, message) { - handler.message(ws, message, { - context: {}, // Provide initial context if needed - }) - }, - close(ws) { - handler.close(ws) - }, - }, + return response }) ``` -```ts [Websocket Hibernation] +```ts [Cloudflare Websocket Hibernation] import { RPCHandler } from '@orpc/server/websocket' import { onError } from '@orpc/server' @@ -166,7 +141,12 @@ export class ChatRoom extends DurableObject { async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { await handler.message(ws, message, { - context: {}, // Provide initial context if needed + /** + * Provide initial context if needed. The context can be an async function + * that receives the per-call request as its first argument, and is **not** + * related to the initial WebSocket upgrade request. + */ + context: request => ({}), }) } @@ -178,30 +158,84 @@ export class ChatRoom extends DurableObject { ::: -::: info -[Hibernation Plugin](/docs/plugins/hibernation) helps you fully leverage Hibernation APIs, making it especially useful for adapters like [Cloudflare Websocket Hibernation](https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server/). -::: - ## Client Adapters -| Adapter | Target | -| ----------- | ---------------------------------------------------------------------------------------------------------------- | -| `websocket` | [MDN WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) (Browser, Node, Bun, Deno, etc.) | +| Adapter | Target | +| ----------- | ------------------------------------------------------------------------------- | +| `websocket` | [MDN WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) | ```ts import { RPCLink } from '@orpc/client/websocket' -const websocket = new WebSocket('ws://localhost:3000') - const link = new RPCLink({ - websocket + connect: info => new WebSocket('ws://localhost:3000'), + /** + * Whether to connect immediately on initialization, instead of waiting + * for the first call. Reduces latency for the first request. + * + * @default false + */ + connectOnInit: true, + + /** + * Optional headers to attach to each per-call request. + * These can be accessed in the server context or via the Request Headers Plugin. + */ + headers: () => ({}) }) ``` -::: tip -Use [partysocket](https://www.npmjs.com/package/partysocket) library for manually/automatically reconnect logic. +::: info +The examples above only show how to configure the link. For examples of creating a typesafe client, see [RPC Link](/docs/rpc/link#typesafe-clients). ::: -:::info -This only shows how to configure the WebSocket link. For full client examples, see [Client-Side Clients](/docs/client/client-side). -::: +### Auto Reconnect + +The client adapter has built-in support for reconnecting when the connection is lost. You can configure reconnect behavior with the `reconnect` option when creating the link. + +```ts +const link = new RPCLink({ + reconnect: { + /** + * Whether to automatically reconnect when the connection is lost. + * + * @default false + */ + enabled: true, + + /** + * Delay before a (re)connect attempt, in milliseconds. + * + * @default info => info.attempt === 1 ? 0 : 2_000 + */ + delay: info => info.attempt === 1 ? 0 : 2_000, + + /** + * Maximum number of consecutive failed attempts before giving up. + * When exceeded, `getConnectedPeer` throws instead of retrying. + * Should greater than 1 + * + * @default Infinity + */ + maxAttempt: Infinity, + + onClose: { + /** + * Whether to proactively reconnect right after the socket closes, + * rather than waiting for the next call to trigger reconnection. + * Reduces latency for the next request. + * + * @default false + */ + enabled: false, + + /** + * Delay before reconnecting after the socket closes, in milliseconds. + * + * @default 0 + */ + delay: 0 + } + } +}) +``` diff --git a/apps/content/docs/adapters/worker-threads.md b/apps/content/docs/adapters/worker-threads.md deleted file mode 100644 index bcc002f7e..000000000 --- a/apps/content/docs/adapters/worker-threads.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: Worker Threads Adapter -description: Enable type-safe communication between Node.js Worker Threads using oRPC. ---- - -# Worker Threads Adapter - -Use [Node.js Worker Threads](https://nodejs.org/api/worker_threads.html) with oRPC for type-safe inter-thread communication via the [Message Port Adapter](/docs/adapters/message-port). Before proceeding, we recommend reviewing the [Node.js Worker Thread API](https://nodejs.org/api/worker_threads.html). - -## Worker Thread - -Listen for a `MessagePort` sent from the main thread and upgrade it: - -```ts -import { parentPort } from 'node:worker_threads' -import { RPCHandler } from '@orpc/server/message-port' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -parentPort.on('message', (message) => { - if (message instanceof MessagePort) { - handler.upgrade(message, { - context: {}, // Provide initial context if needed - }) - - message.start() - } -}) -``` - -## Main Thread - -Create a `MessageChannel`, send one port to the thread worker, and use the other to initialize the client link: - -```ts -import { MessageChannel, Worker } from 'node:worker_threads' -import { RPCLink } from '@orpc/client/message-port' - -const { port1: clientPort, port2: serverPort } = new MessageChannel() - -const worker = new Worker('some-worker.js') - -worker.postMessage(serverPort, [serverPort]) - -const link = new RPCLink({ - port: clientPort -}) - -clientPort.start() -``` - -:::info -This only shows how to configure the link. For full client examples, see [Client-Side Clients](/docs/client/client-side). -::: diff --git a/apps/content/docs/advanced/building-custom-plugins.md b/apps/content/docs/advanced/building-custom-plugins.md deleted file mode 100644 index 3d867370a..000000000 --- a/apps/content/docs/advanced/building-custom-plugins.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -title: Building Custom Plugins -description: Create powerful custom plugins to extend oRPC handlers and links with interceptors. ---- - -# Building Custom Plugins - -This guide explains how to create custom oRPC plugins for handlers and links. - -## What is a Plugin? - -In oRPC, a plugin is a collection of `interceptors` that can work together or independently. - -```ts -export class ResponseHeadersPlugin implements StandardHandlerPlugin { - init(options: StandardHandlerOptions): void { - options.rootInterceptors ??= [] - - options.rootInterceptors.push(async (interceptorOptions) => { - const resHeaders = interceptorOptions.context.resHeaders ?? new Headers() - - const result = await interceptorOptions.next({ - ...interceptorOptions, - context: { - ...interceptorOptions.context, - resHeaders, - }, - }) - - if (!result.matched) { - return result - } - - const responseHeaders = clone(result.response.headers) - - for (const [key, value] of resHeaders) { - if (Array.isArray(responseHeaders[key])) { - responseHeaders[key].push(value) - } - else if (responseHeaders[key] !== undefined) { - responseHeaders[key] = [responseHeaders[key], value] - } - else { - responseHeaders[key] = value - } - } - - return { - ...result, - response: { - ...result.response, - headers: responseHeaders, - }, - } - }) - } -} -``` - -Above is a snippet from the [Response Headers Plugin](/docs/plugins/response-headers). It contains a single interceptor that injects `resHeaders` into the context and merges them with the response headers after the handler executes. - -### Handler Plugins - -Handler plugins extend the functionality of your server-side handlers. You can create plugins for [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or any custom handlers you've built. - -When building a handler plugin, you'll work with interceptors from the [Handler Lifecycle](/docs/rpc-handler#lifecycle). These interceptors let you hook into different stages of request processing - from initial request parsing to final response formatting. - -Check out the [built-in handler plugins](https://github.com/middleapi/orpc/tree/main/packages/server/src/plugins) to see real-world examples of how different plugins solve common server-side challenges. - -### Link Plugins - -Link plugins enhance your client-side communication. They work with [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom links you've implemented. - -Link plugins use interceptors from the [Link Lifecycle](/docs/client/rpc-link#lifecycle) to modify requests before they're sent or responses after they're received. This is perfect for adding authentication, logging, retry logic, or request/response transformations. - -Browse the [built-in link plugins](https://github.com/middleapi/orpc/tree/main/packages/client/src/plugins) for inspiration on handling common client-side scenarios. - -## Communication Between Interceptors - -Sometimes you need interceptors to share data. For example, one interceptor might collect information that another interceptor uses later. You can achieve this by injecting context using a unique symbol. - -The [Strict Get Method Plugin](/docs/plugins/strict-get-method) ([Source Code](https://github.com/middleapi/orpc/blob/main/packages/server/src/plugins/strict-get-method.ts)) demonstrates this pattern. It uses `rootInterceptors` to collect HTTP methods and combines this data with procedure information in `clientInterceptors` to determine whether the method is allowed. - -## Plugin Order - -```ts -export class ExamplePlugin implements StandardHandlerPlugin { - order = 10 // [!code highlight] - - init(options: StandardHandlerOptions): void { - options.rootInterceptors ??= [] - options.clientInterceptors ??= [] - - options.rootInterceptors.push(async ({ next }) => { - return await next() - }) - - options.clientInterceptors.push(async ({ next }) => { - return await next() - }) - } -} -``` - -The `order` property controls plugin loading order, not interceptor execution order. To ensure your interceptor runs earlier, set a higher order value and use `.unshift` to add your interceptor, or use `.push` if you want your interceptor to run later. - -::: warning -In most cases, you **should not** define the `order` property unless you need your interceptors to always run before or after other interceptors. The `order` value should be less than `1_000_000` to avoid conflicts with built-in plugins. -::: diff --git a/apps/content/docs/advanced/exceeds-the-maximum-length-problem.md b/apps/content/docs/advanced/exceeds-the-maximum-length-problem.md index d43cb8d70..38722c281 100644 --- a/apps/content/docs/advanced/exceeds-the-maximum-length-problem.md +++ b/apps/content/docs/advanced/exceeds-the-maximum-length-problem.md @@ -1,10 +1,7 @@ ---- -title: Exceeds the Maximum Length Problem -description: How to address the Exceeds the Maximum Length Problem in oRPC. ---- - # Exceeds the Maximum Length Problem +TypeScript may report this error when you export a large or complex [router](/docs/router). This is a known TypeScript limitation, not an oRPC bug. TypeScript enforces it to maintain reasonable editor and type-checking performance for large types. + ```ts twoslash // @error: The inferred type of this node exceeds the maximum length the compiler will serialize. An explicit type annotation is needed. export const router = { @@ -12,11 +9,9 @@ export const router = { } ``` -Are you seeing this error? If so, congratulations! your project is now complex enough to encounter it! - -## Why It Happens +## When It Happens -This error is expected, not a bug. Typescript enforces this to keep your IDE suggestions fast. It appears when all three of these conditions are met: +You usually see this error when all of the following are true: 1. Your project uses `"declaration": true` in `tsconfig.json`. 2. Your project is large or your types are very complex. @@ -24,21 +19,30 @@ This error is expected, not a bug. Typescript enforces this to keep your IDE sug ## How to Fix It -### 1. Disable `"declaration": true` in `tsconfig.json` +### 1. Disable `"declaration": true` -This is the simplest option, though it may not be ideal for your project. +If you don't need this feature, disable this option in your `tsconfig.json`: + +```diff [tsconfig.json] + { + "compilerOptions": { +-- "declaration": true, +++ "declaration": false + } + } +``` -### 2. Define the `.output` Type for Your Procedures +### 2. Add Explicit Output Types -By explicitly specifying the `.output` or your `handler's return type`, you enable TypeScript to infer the output without parsing the handler's code. This approach can dramatically enhance both type-checking and IDE-suggestion speed. +Add `.output` or an explicit handler return type to your procedures. This lets TypeScript use the declared output shape instead of fully expanding the handler implementation, which often improves both type-checking and editor performance. :::tip Use the [type](/docs/procedure#type-utility) utility if you just want to specify the output type without validating the output. ::: -### 3. Export the Router in Parts +### 3. Split the Router into Smaller Exports -Instead of exporting one large object on the server (with `"declaration": true`), export each router segment individually and merge them on the client (where `"declaration": false`): +If you need `"declaration": true`, avoid exporting a single massive router object from the server. Instead, export smaller router segments and combine them on the client side, where `"declaration": false`: ```ts export const userRouter = { /** ... */ } @@ -46,7 +50,7 @@ export const planetRouter = { /** ... */ } export const publicRouter = { /** ... */ } ``` -Then, on the client side: +Then define the client type from those smaller exports: ```ts interface Router { diff --git a/apps/content/docs/advanced/expanding-type-support-for-openapi-link.md b/apps/content/docs/advanced/expanding-type-support-for-openapi-link.md new file mode 100644 index 000000000..e3177d47e --- /dev/null +++ b/apps/content/docs/advanced/expanding-type-support-for-openapi-link.md @@ -0,0 +1,84 @@ +# Expanding Type Support for OpenAPI Link + +Because of [OpenAPI Serializer limitations](/docs/openapi/serializer#limitations), values like `Date` and `bigint` are received by the client in JSON-friendly form. You can convert them back to native types on the client with either [Response Validation Plugin](/docs/plugins/response-validation) or [Smart Coercion Plugin](/docs/plugins/smart-coercion), but only under the conditions described below. + +## Choose a Plugin + +- Use [Response Validation Plugin](/docs/plugins/response-validation) when you want manual control over coercion logic and can define explicit coercion rules in your schemas. +- Use [Smart Coercion Plugin](/docs/plugins/smart-coercion) when you want automatic coercion based on schema instead of defining coercion logic yourself. + +::: warning +These plugins can only restore types that the [OpenAPI Serializer](/docs/openapi/serializer) can represent. If you need additional types, extend the serializer first. + +Nested `Blob` and `File` values are still limited by [Bracket Notation](/docs/openapi/bracket-notation#limitations). +::: + +## Use Response Validation Plugin + +Use [Response Validation Plugin](/docs/plugins/response-validation) when you want to manually control how values are converted back to native types. The coercion rules live in your contract schemas, so the behavior stays explicit and predictable. + +```ts +const contract = oc.output(z.object({ + date: z.coerce.date(), + bigint: z.coerce.bigint(), +})) + +const procedure = implement(contract).handler(() => ({ + date: new Date(), + bigint: 123n, +})) +``` + +The server still returns JSON-friendly data: + +```ts +const rawOutput = { + date: '2025-09-01T07:24:39.000Z', + bigint: '123', +} +``` + +With `ResponseValidationLinkPlugin`, the client validates that response and applies your schema coercion before your code uses it. + +```ts +const output = { + date: new Date('2025-09-01T07:24:39.000Z'), + bigint: 123n, +} +``` + +### Setup + +Add the plugin to your link, then remove the `JsonifiedClient` wrapper from the client type. + +```ts +import type { RouterContractClient } from '@orpc/contract' +import { ResponseValidationLinkPlugin } from '@orpc/contract/plugins' + +const link = new OpenAPILink(contract, { + plugins: [ + new ResponseValidationLinkPlugin(contract), // [!code ++] + ], +}) + +const client: JsonifiedClient> = createORPCClient(link) // [!code --] +const client: RouterContractClient = createORPCClient(link) // [!code ++] +``` + +## Use Smart Coercion Plugin + +Use [Smart Coercion Plugin](/docs/plugins/smart-coercion) when you want the client to coerce values automatically from schema instead of adding coercion logic to each schema manually. + +```ts +import type { RouterContractClient } from '@orpc/contract' +import { SmartCoercionLinkPlugin } from '@orpc/json-schema' + +const link = new OpenAPILink(contract, { + plugins: [ + new SmartCoercionLinkPlugin(contract), // [!code ++] + ], +}) + +const client: JsonifiedClient> = createORPCClient(link) // [!code --] +const client: RouterContractClient = createORPCClient(link) // [!code ++] +``` diff --git a/apps/content/docs/advanced/extend-body-parser.md b/apps/content/docs/advanced/extend-body-parser.md deleted file mode 100644 index 3e5f6f6a1..000000000 --- a/apps/content/docs/advanced/extend-body-parser.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: Extend Body Parser -description: Extend the body parser for more efficient handling of large payloads, extend the data types. ---- - -# Extend Body Parser - -In some cases, you may need to extend the body parser to handle larger payloads or additional data types. This can be done by creating a custom body parser that extends the default functionality. - -```ts -import { RPCHandler } from '@orpc/server/fetch' -import { getFilenameFromContentDisposition } from '@orpc/standard-server' - -const OVERRIDE_BODY_CONTEXT = Symbol('OVERRIDE_BODY_CONTEXT') - -interface OverrideBodyContext { - fetchRequest: Request -} - -const handler = new RPCHandler(router, { - adapterInterceptors: [ - (options) => { - return options.next({ - ...options, - context: { - ...options.context, - [OVERRIDE_BODY_CONTEXT as any]: { - fetchRequest: options.request, - }, - }, - }) - }, - ], - rootInterceptors: [ - (options) => { - const { fetchRequest } = (options.context as any)[OVERRIDE_BODY_CONTEXT] as OverrideBodyContext - - return options.next({ - ...options, - request: { - ...options.request, - async body() { - const contentDisposition = fetchRequest.headers.get('content-disposition') - const contentType = fetchRequest.headers.get('content-type') - - if (contentDisposition === null && contentType?.startsWith('multipart/form-data')) { - // Custom handling for multipart/form-data - // Example: use @mjackson/form-data-parser for streaming parsing - return fetchRequest.formData() - } - - // if has content-disposition always treat as file upload - if ( - contentDisposition !== null || ( - !contentType?.startsWith('application/json') - && !contentType?.startsWith('application/x-www-form-urlencoded') - ) - ) { - // Custom handling for file uploads - // Example: streaming file into disk to reduce memory usage - const fileName = getFilenameFromContentDisposition(contentDisposition ?? '') ?? 'blob' - const blob = await fetchRequest.blob() - return new File([blob], fileName, { - type: blob.type, - }) - } - - // fallback to default body parser - return options.request.body() - }, - }, - }) - }, - ], -}) -``` - -::: warning -The `adapterInterceptors` can be different based on the adapter you are using. The example above is for the Fetch adapter. -::: diff --git a/apps/content/docs/advanced/publish-client-to-npm.md b/apps/content/docs/advanced/publish-client-to-npm.md index 77e3fd3cd..c1f61202f 100644 --- a/apps/content/docs/advanced/publish-client-to-npm.md +++ b/apps/content/docs/advanced/publish-client-to-npm.md @@ -1,8 +1,3 @@ ---- -title: Publish Client to NPM -description: How to publish your oRPC client to NPM for users to consume your APIs as an SDK. ---- - # Publish Client to NPM Publishing your oRPC client to NPM allows users to easily consume your APIs as a software development kit (SDK). @@ -13,7 +8,7 @@ Before you start, we recommend watching some [publish typescript library to npm ## Prerequisites -You must have a project already set up with oRPC. [Contract First](/docs/contract-first/define-contract) is the preferred approach. If you haven't set one up yet, you can clone an [oRPC playground](/docs/playgrounds) and start from there. +You must have a project already set up with oRPC. [Contract First](/docs/contract/router) is the preferred approach. If you haven't set one up yet, you can clone an [oRPC playground](/docs/playgrounds) and start from there. ::: info In this guide, we'll use [pnpm](https://pnpm.io/) as the package manager and [tsdown](https://tsdown.dev/) for bundling the package. You can use other package managers and bundlers, but the commands may differ. @@ -26,11 +21,12 @@ First, create a `src/index.ts` file to set up and export your client. ```ts [src/index.ts] import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' -import type { ContractRouterClient } from '@orpc/contract' +import type { RouterContractClient } from '@orpc/contract' -export function createMyApi(apiKey: string): ContractRouterClient { +export function createMyApi(apiKey: string): RouterContractClient { const link = new RPCLink({ - url: 'https://example.com/rpc', + origin: 'https://example.com', + url: '/rpc', headers: { 'x-api-key': apiKey, } @@ -41,7 +37,7 @@ export function createMyApi(apiKey: string): ContractRouterClient data instanceof User, - serialize: data => data.toJSON(), - deserialize: data => new User(data.id, data.name, data.email, data.age), - } - ``` - - ::: warning - Ensure the `type` is unique and greater than `20` to avoid conflicts with [built-in types](/docs/advanced/rpc-protocol#supported-types) in the future. - ::: - -2. **Use Your Custom Serializer** - - ```ts twoslash - import type { StandardRPCCustomJsonSerializer } from '@orpc/client/standard' - import { RPCHandler } from '@orpc/server/fetch' - import { RPCLink } from '@orpc/client/fetch' - - declare const router: Record - declare const userSerializer: StandardRPCCustomJsonSerializer - // ---cut--- - const handler = new RPCHandler(router, { - customJsonSerializers: [userSerializer], // [!code highlight] - }) - - const link = new RPCLink({ - url: 'https://example.com/rpc', - customJsonSerializers: [userSerializer], // [!code highlight] - }) - ``` - -## Overriding Built-in Types - -You can override built-in types by matching their `type` with the [built-in types](/docs/advanced/rpc-protocol#supported-types). - -For example, oRPC represents `undefined` only in array items and ignores it in objects. To override this behavior: - -```ts twoslash -import { StandardRPCCustomJsonSerializer } from '@orpc/client/standard' - -export const undefinedSerializer: StandardRPCCustomJsonSerializer = { - type: 3, // Match the built-in undefined type. [!code highlight] - condition: data => data === undefined, - serialize: data => null, // JSON cannot represent undefined, so use null. - deserialize: data => undefined, -} -``` diff --git a/apps/content/docs/advanced/rpc-protocol.md b/apps/content/docs/advanced/rpc-protocol.md deleted file mode 100644 index 535db51e0..000000000 --- a/apps/content/docs/advanced/rpc-protocol.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -title: RPC Protocol -description: Learn about the RPC protocol used by RPCHandler. ---- - -# RPC Protocol - -The RPC protocol enables remote procedure calls over HTTP using JSON, supporting native data types. It is used by [RPCHandler](/docs/rpc-handler). - -## Routing - -The procedure to call is determined by the `pathname`. - -```bash -curl https://example.com/rpc/planet/create -``` - -This example calls the `planet.create` procedure, with `/rpc` as the prefix: - -```ts -const router = { - planet: { - create: os.handler(() => {}) // [!code highlight] - } -} -``` - -## Input - -Any HTTP method can be used. Input can be provided via URL query parameters or the request body, based on the HTTP method. - -::: warning -By default, [RPCHandler](/docs/rpc-handler) in the [HTTP Adapter](/docs/adapters/http) enabled [StrictGetMethodPlugin](/docs/rpc-handler#default-plugins) which blocks GET requests except for procedures explicitly allowed. Please refer to [StrictGetMethodPlugin](/docs/plugins/strict-get-method) for more details. -::: - -### Input in URL Query - -```ts -const url = new URL('https://example.com/rpc/planet/create') - -url.searchParams.append('data', JSON.stringify({ - json: { - name: 'Earth', - detached_at: '2022-01-01T00:00:00.000Z' - }, - meta: [[1, 'detached_at']] -})) - -const response = await fetch(url) -``` - -### Input in Request Body - -```bash -curl -X POST https://example.com/rpc/planet/create \ - -H 'Content-Type: application/json' \ - -d '{ - "json": { - "name": "Earth", - "detached_at": "2022-01-01T00:00:00.000Z" - }, - "meta": [[1, "detached_at"]] - }' -``` - -### Input with File - -```ts -const form = new FormData() - -form.set('data', JSON.stringify({ - json: { - name: 'Earth', - thumbnail: {}, - images: [{}, {}] - }, - meta: [[1, 'detached_at']], - maps: [['images', 0], ['images', 1]] -})) - -form.set('0', new Blob([''], { type: 'image/png' })) -form.set('1', new Blob([''], { type: 'image/png' })) - -const response = await fetch('https://example.com/rpc/planet/create', { - method: 'POST', - body: form -}) -``` - -## Success Response - -```http -HTTP/1.1 200 OK -Content-Type: application/json - -{ - "json": { - "id": "1", - "name": "Earth", - "detached_at": "2022-01-01T00:00:00.000Z" - }, - "meta": [[0, "id"], [1, "detached_at"]] -} -``` - -A success response has an HTTP status code between `200-299` and returns the procedure's output. - -## Error Response - -```http -HTTP/1.1 500 Internal Server Error -Content-Type: application/json - -{ - "json": { - "defined": false, - "code": "INTERNAL_SERVER_ERROR", - "status": 500, - "message": "Internal server error", - "data": {} - }, - "meta": [] -} -``` - -An error response has an HTTP status code between `400-599` and returns an `ORPCError` object. - -## Meta - -The `meta` field describes native data in the format `[type: number, ...path: (string | number)[]]`. - -- **type**: Data type (see [Supported Types](#supported-types)). -- **path**: Path to the data inside `json`. - -### Supported Types - -| Type | Description | -| ---- | ----------- | -| 0 | bigint | -| 1 | date | -| 2 | nan | -| 3 | undefined | -| 4 | url | -| 5 | regexp | -| 6 | set | -| 7 | map | - -## Maps - -The `maps` field is used with `FormData` to map a file or blob to a specific path in `json`. diff --git a/apps/content/docs/advanced/scaling-large-projects.md b/apps/content/docs/advanced/scaling-large-projects.md new file mode 100644 index 000000000..2ef26844b --- /dev/null +++ b/apps/content/docs/advanced/scaling-large-projects.md @@ -0,0 +1,104 @@ +# Scaling Large Projects + +A single root [client](/docs/client/client-side) is a great way to get started. As your project grows, though, it can lead to type performance issues and tangled dependencies. Splitting the client into smaller service-level clients can help for a while, but very large codebases can still outgrow that approach. + +This guide shows an alternative pattern for large projects: import and use individual [procedure contracts](/docs/contract/procedure) directly. + +## Requirements + +This pattern depends on one consistency rule: every [procedure contract](/docs/contract/procedure) must define `meta.path`, and that path must exactly match the procedure's location in the root contract. + +```ts +import { meta, oc } from '@orpc/contract' + +export const procedure = oc + .meta(meta.path(['real', 'path', 'to', 'procedure'])) + .input(z.object({ name: z.string() })) + .output(z.object({ message: z.string() })) +``` + +If you use `['real', 'path', 'to', 'procedure']` as the path, the procedure must be mounted at `real.path.to.procedure` in the root contract. This is required for the pattern to work correctly: + +```ts +import { procedure } from './path/to/procedure' + +const router = { + real: { + path: { + to: { + procedure, + }, + }, + } +} +``` + +## Contract Caller + +This pattern does not require a single root client. Instead, you configure a caller that communicates with the server. `createContractCaller` accepts an [RPC Link](/docs/rpc/link), an [OpenAPI Link](/docs/openapi/link), or a custom link. It also accepts options similar to [`createORPCClient`](/docs/client/client-side), but with less typesafe because the full contract is not known up front: + +```ts +import { createContractCaller } from '@orpc/contract' + +export const call = createContractCaller(link, { /** options */}) +``` + +::: warning +If you are using [OpenAPI Link](/docs/openapi/link), or any link that requires the client to be wrapped in `JsonifiedClient`, use `createContractJsonifiedCaller` from `@orpc/openapi` instead of `createContractCaller`. +::: + +You can then call a procedure by importing its contract directly in the client: + +```ts +import { procedure } from './path/to/procedure' + +const output = await call(procedure, input, {/** options */}) +``` + +### `contractRef` + +Some integrations still need a root contract. For example, [OpenAPI Link](/docs/openapi/link) and some plugins depend on one. In those cases, `contractRef` can help: + +```ts +import { RouterContract } from '@orpc/contract' + +const contractRef: RouterContract = {} + +const link = new OpenAPILink(contractRef, { + plugins: [ + new PluginRequireContract(contractRef) + ] +}) + +export const call = createContractCaller(link, { contractRef }) +``` + +The idea behind `contractRef` is simple: every time `call` is used, the caller automatically registers the called procedure contract into `contractRef` at the path defined by `meta.path`. + +::: info +Some features may not support `contractRef` well. In those cases, import the root contract instead and cast it with `as any` when needed. +::: + +## TanStack Query Integration + +[TanStack Query Integration](/docs/integrations/tanstack-query) also supports this pattern. First, create a factory that accepts a [contract caller](#contract-caller) and options similar to the [TanStack Query interceptor options](/docs/integrations/tanstack-query#interceptors), but with less type safety because the full contract is not known up front: + +```ts +import { createContractUtilsFactory } from '@orpc/tanstack-query' + +export const createUtils = createContractUtilsFactory(call, { /** options */}) +``` + +::: warning +If you are using [OpenAPI Link](/docs/openapi/link), or any link that requires the client to be wrapped in `JsonifiedClient`, use `createContractJsonifiedUtilsFactory` from `@orpc/tanstack-query` instead of `createContractUtilsFactory`. +::: + +You can then create utilities for each procedure contract: + +```ts +import { procedure } from './path/to/procedure' + +const utils = createUtils(procedure) + +const query = useQuery(utils.queryOptions({/** options */})) +``` diff --git a/apps/content/docs/advanced/superjson.md b/apps/content/docs/advanced/superjson.md deleted file mode 100644 index 1002fa269..000000000 --- a/apps/content/docs/advanced/superjson.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -title: SuperJson -description: Replace the default oRPC RPC serializer with SuperJson. ---- - -# SuperJson - -This guide explains how to replace the default oRPC RPC serializer with [SuperJson](https://github.com/blitz-js/superjson). - -:::info -While the default oRPC serializer is faster and more efficient, SuperJson is widely adopted and may be preferred for compatibility. -::: - -## SuperJson Serializer - -:::warning -The `SuperJsonSerializer` supports only the data types that SuperJson handles, plus `AsyncIteratorObject` at the root level for [Event Iterator](/docs/event-iterator). It does not support all [RPC supported types](/docs/rpc-handler#supported-data-types). -::: - -```ts twoslash -import { createORPCErrorFromJson, ErrorEvent, isORPCErrorJson, mapEventIterator, toORPCError } from '@orpc/client' -import type { StandardRPCSerializer } from '@orpc/client/standard' -import { isAsyncIteratorObject } from '@orpc/shared' -import SuperJSON from 'superjson' - -export class SuperJSONSerializer implements Pick { - serialize(data: unknown): object { - if (isAsyncIteratorObject(data)) { - return mapEventIterator(data, { - value: async (value: unknown) => SuperJSON.serialize(value), - error: async (e) => { - return new ErrorEvent({ - data: SuperJSON.serialize(toORPCError(e).toJSON()), - cause: e, - }) - }, - }) - } - - return SuperJSON.serialize(data) - } - - deserialize(data: any): unknown { - if (isAsyncIteratorObject(data)) { - return mapEventIterator(data, { - value: async value => SuperJSON.deserialize(value), - error: async (e) => { - if (!(e instanceof ErrorEvent)) - return e - - const deserialized = SuperJSON.deserialize(e.data as any) - - if (isORPCErrorJson(deserialized)) { - return createORPCErrorFromJson(deserialized, { cause: e }) - } - - return new ErrorEvent({ - data: deserialized, - cause: e, - }) - }, - }) - } - - return SuperJSON.deserialize(data) - } -} -``` - -## SuperJson Handler - -```ts twoslash -declare class SuperJSONSerializer implements Pick { - serialize(data: unknown): object - deserialize(data: unknown): unknown -} -// ---cut--- -import type { StandardRPCSerializer } from '@orpc/client/standard' -import type { Context, Router } from '@orpc/server' -import type { FetchHandlerOptions } from '@orpc/server/fetch' -import { FetchHandler } from '@orpc/server/fetch' -import { StrictGetMethodPlugin } from '@orpc/server/plugins' -import type { StandardHandlerOptions } from '@orpc/server/standard' -import { StandardHandler, StandardRPCCodec, StandardRPCMatcher } from '@orpc/server/standard' - -export interface SuperJSONHandlerOptions - extends FetchHandlerOptions, Omit, 'plugins'> { - /** - * Enable or disable the StrictGetMethodPlugin. - * - * @default true - */ - strictGetMethodPluginEnabled?: boolean -} - -export class SuperJSONHandler extends FetchHandler { - constructor(router: Router, options: NoInfer> = {}) { - options.plugins ??= [] - - const strictGetMethodPluginEnabled = options.strictGetMethodPluginEnabled ?? true - - if (strictGetMethodPluginEnabled) { - options.plugins.push(new StrictGetMethodPlugin()) - } - - const serializer = new SuperJSONSerializer() - const matcher = new StandardRPCMatcher() - const codec = new StandardRPCCodec(serializer as any) - - super(new StandardHandler(router, matcher, codec, options), options) - } -} -``` - -## SuperJson Link - -```ts twoslash -declare class SuperJSONSerializer implements Pick { - serialize(data: unknown): object - deserialize(data: unknown): unknown -} -// ---cut--- -import type { ClientContext } from '@orpc/client' -import { StandardLink, StandardRPCLinkCodec } from '@orpc/client/standard' -import type { StandardLinkOptions, StandardRPCLinkCodecOptions, StandardRPCSerializer } from '@orpc/client/standard' -import type { LinkFetchClientOptions } from '@orpc/client/fetch' -import { LinkFetchClient } from '@orpc/client/fetch' - -export interface SuperJSONLinkOptions - extends LinkFetchClientOptions, - Omit, 'plugins'>, - StandardRPCLinkCodecOptions { } - -export class SuperJSONLink extends StandardLink { - constructor(options: SuperJSONLinkOptions) { - const linkClient = new LinkFetchClient(options) - const serializer = new SuperJSONSerializer() - const linkCodec = new StandardRPCLinkCodec(serializer as any, options) - - super(linkCodec, linkClient, options) - } -} -``` diff --git a/apps/content/docs/advanced/testing-and-mocking.md b/apps/content/docs/advanced/testing-and-mocking.md new file mode 100644 index 000000000..2525e805d --- /dev/null +++ b/apps/content/docs/advanced/testing-and-mocking.md @@ -0,0 +1,46 @@ +# Testing and Mocking + +Testing and mocking are essential for building reliable applications. In this section, we'll explore how to test your procedures and routers effectively, as well as how to create mock implementations for testing purposes. + +## Testing + +For fast, focused tests, use [Server-Side Clients](/docs/client/server-side) or call your procedures directly with `call`. This lets you verify validation, middleware, and handler logic without going through HTTP. + +```ts +import { call } from '@orpc/server' + +it('lists planets', async () => { + await expect( + call(router.planet.list, { page: 1, size: 10 }) + ).resolves.toEqual([ + { id: '1', name: 'Earth' }, + { id: '2', name: 'Mars' }, + ]) +}) +``` + +::: info +For a production-like test setup, create [fetch-based internal clients](/docs/best-practices/optimizing-ssr#implementation). +::: + +## Mocking + +Use the [Implementer](/docs/contract/implementation) to create test-specific versions of a [procedure](/docs/procedure) or [router](/docs/router). This is useful when one part of your system depends on another procedure, but your test should not execute the real implementation. + +```ts twoslash +import { router } from './shared/planet' +// ---cut--- +import { implement } from '@orpc/server' + +const fakeListPlanet = implement(router.planet.list).handler(() => []) +``` + +Use `fakeListPlanet` anywhere your test would normally use the real `listPlanet` procedure. + +::: info +`implement` is also useful for building mock servers in frontend tests. +::: + +::: warning +`implement` does not support [lazy routers](/docs/router#lazy-router) directly. If you need to mock one, first [unlazy the router](/docs/contract/router#router-to-contract). +::: diff --git a/apps/content/docs/advanced/testing-mocking.md b/apps/content/docs/advanced/testing-mocking.md deleted file mode 100644 index bcb041731..000000000 --- a/apps/content/docs/advanced/testing-mocking.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: Testing & Mocking -description: How to test and mock oRPC routers and procedures? ---- - -# Testing & Mocking - -Testing and mocking are essential parts of the development process, ensuring your oRPC routers and procedures work as expected. This guide covers strategies for testing and mocking in oRPC applications. - -## Testing - -Using [Server-Side Clients](/docs/client/server-side), you can directly invoke your procedures in tests without additional setup. This approach allows you to test procedures in isolation, ensuring they behave correctly. - -```ts -import { call } from '@orpc/server' - -it('works', async () => { - await expect( - call(router.planet.list, { page: 1, size: 10 }) - ).resolves.toEqual([ - { id: '1', name: 'Earth' }, - { id: '2', name: 'Mars' }, - ]) -}) -``` - -::: info -You can also use the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to create production-like clients for testing purposes. [Learn more](/docs/best-practices/optimize-ssr#alternative-approach) -::: - -## Mocking - -The [Implementer](/docs/contract-first/implement-contract#the-implementer) is designed for contract-first development, but it can also create alternative versions of your [router](/docs/router) or [procedure](/docs/procedure) for testing. - -```ts twoslash -import { router } from './shared/planet' -// ---cut--- -import { implement, unlazyRouter } from '@orpc/server' - -const fakeListPlanet = implement(router.planet.list).handler(() => []) -``` - -You can use `fakeListPlanet` to replace the actual `listPlanet` implementation during tests. - -::: info -The `implement` function is also useful for creating mock servers for frontend testing scenarios. -::: - -::: warning -The `implement` function doesn't support [lazy routers](/docs/router#lazy-router) yet. Use the `unlazyRouter` utility to convert your lazy router before implementing. [Learn more](/docs/contract-first/router-to-contract#unlazy-the-router) -::: diff --git a/apps/content/docs/advanced/validation-errors.md b/apps/content/docs/advanced/validation-errors.md index 29d545910..ae40b4786 100644 --- a/apps/content/docs/advanced/validation-errors.md +++ b/apps/content/docs/advanced/validation-errors.md @@ -1,117 +1,72 @@ ---- -title: Validation Errors -description: Learn about oRPC's built-in validation errors and how to customize them. ---- - # Validation Errors -oRPC provides built-in validation errors that work well by default. However, you might sometimes want to customize them. +oRPC includes built-in validation errors that work well for most cases. Customize them when you need a different message or error shape. -## Customizing with Client Interceptors +## Customizing -[Client Interceptors](/docs/rpc-handler#lifecycle) are preferred because they run before error validation, ensuring that your custom errors are properly validated. +You can catch validation errors with [interceptors](/docs/rpc/handler#interceptors), [client interceptors](/docs/rpc/handler#client-interceptors), or [middleware](/docs/middleware) applied before `.input` or `.output`. ```ts twoslash import { RPCHandler } from '@orpc/server/fetch' import { router } from './shared/planet' // ---cut--- -import { onError, ORPCError, ValidationError } from '@orpc/server' import * as z from 'zod' +import { ORPCError, ValidationError } from '@orpc/server' const handler = new RPCHandler(router, { - clientInterceptors: [ - onError((error) => { - if ( - error instanceof ORPCError - && error.code === 'BAD_REQUEST' - && error.cause instanceof ValidationError - ) { - // If you only use Zod you can safely cast to ZodIssue[] - const zodError = new z.ZodError(error.cause.issues as z.core.$ZodIssue[]) - - throw new ORPCError('INPUT_VALIDATION_FAILED', { - status: 422, - message: z.prettifyError(zodError), - data: z.flattenError(zodError), - cause: error.cause, - }) + interceptors: [ + async ({ next }) => { + try { + return await next() } - - if ( - error instanceof ORPCError - && error.code === 'INTERNAL_SERVER_ERROR' - && error.cause instanceof ValidationError - ) { - throw new ORPCError('OUTPUT_VALIDATION_FAILED', { - cause: error.cause, - }) + catch (error) { + if ( + error instanceof ORPCError + && error.code === 'BAD_REQUEST' + && error.cause instanceof ValidationError + ) { + // If you only use Zod you can safely cast to ZodIssue[] + const zodError = new z.ZodError(error.cause.issues as z.core.$ZodIssue[]) + + throw new ORPCError('INPUT_VALIDATION_FAILED', { + message: z.prettifyError(zodError), + data: z.flattenError(zodError), + cause: error, + }) + } + + if ( + error instanceof ORPCError + && error.code === 'INTERNAL_SERVER_ERROR' + && error.cause instanceof ValidationError + ) { + // do not expose validation details for output validation errors + throw new ORPCError('OUTPUT_VALIDATION_FAILED', { + cause: error, + }) + } + + throw error } - }), + }, ], }) ``` -## Customizing with Middleware - -```ts twoslash -import { onError, ORPCError, os, ValidationError } from '@orpc/server' -import * as z from 'zod' - -const base = os.use(onError((error) => { - if ( - error instanceof ORPCError - && error.code === 'BAD_REQUEST' - && error.cause instanceof ValidationError - ) { - // If you only use Zod you can safely cast to ZodIssue[] - const zodError = new z.ZodError(error.cause.issues as z.core.$ZodIssue[]) - - throw new ORPCError('INPUT_VALIDATION_FAILED', { - status: 422, - message: z.prettifyError(zodError), - data: z.flattenError(zodError), - cause: error.cause, - }) - } - - if ( - error instanceof ORPCError - && error.code === 'INTERNAL_SERVER_ERROR' - && error.cause instanceof ValidationError - ) { - throw new ORPCError('OUTPUT_VALIDATION_FAILED', { - cause: error.cause, - }) - } -})) - -const getting = base - .input(z.object({ id: z.uuid() })) - .output(z.object({ id: z.uuid(), name: z.string() })) - .handler(async ({ input, context }) => { - return { id: input.id, name: 'name' } - }) -``` - -Every [procedure](/docs/procedure) built from `base` now uses these customized validation errors. +## Typesafe Validation Errors -:::warning -Middleware applied before `.input`/`.output` catches validation errors by default, but this behavior can be configured. -::: +As explained in the [error handling guide](/docs/error-handling#orpcerror-compatibility), if you throw an `ORPCError` whose `code` and `data` match an error defined with `.errors`, oRPC treats it the same as `errors.[code]`. -## Type‑Safe Validation Errors - -As explained in the [error handling guide](/docs/error-handling#combining-both-approaches), when you throw an `ORPCError` instance, if the `code`, `status` and `data` match with the errors defined in the `.errors` method, oRPC will treat it exactly as if you had thrown `errors.[code]` using the type‑safe approach. +This does not work in [interceptors](/docs/rpc/handler#interceptors). Use [client interceptors](/docs/rpc/handler#client-interceptors) or [middleware](/docs/middleware) applied before `.input` or `.output` instead. ```ts twoslash import { RPCHandler } from '@orpc/server/fetch' // ---cut--- -import { onError, ORPCError, os, ValidationError } from '@orpc/server' +import { ORPCError, os, ValidationError } from '@orpc/server' import * as z from 'zod' const base = os.errors({ INPUT_VALIDATION_FAILED: { - status: 422, data: z.object({ formErrors: z.array(z.string()), fieldErrors: z.record(z.string(), z.array(z.string()).optional()), @@ -125,23 +80,29 @@ const example = base const handler = new RPCHandler({ example }, { clientInterceptors: [ - onError((error) => { - if ( - error instanceof ORPCError - && error.code === 'BAD_REQUEST' - && error.cause instanceof ValidationError - ) { - // If you only use Zod you can safely cast to ZodIssue[] - const zodError = new z.ZodError(error.cause.issues as z.core.$ZodIssue[]) - - throw new ORPCError('INPUT_VALIDATION_FAILED', { - status: 422, - message: z.prettifyError(zodError), - data: z.flattenError(zodError), - cause: error.cause, - }) + async ({ next }) => { + try { + return await next() } - }), + catch (error) { + if ( + error instanceof ORPCError + && error.code === 'BAD_REQUEST' + && error.cause instanceof ValidationError + ) { + // If you only use Zod you can safely cast to ZodIssue[] + const zodError = new z.ZodError(error.cause.issues as z.core.$ZodIssue[]) + + throw new ORPCError('INPUT_VALIDATION_FAILED', { + message: z.prettifyError(zodError), + data: z.flattenError(zodError), + cause: error, + }) + } + + throw error + } + }, ], }) ``` diff --git a/apps/content/docs/best-practices/dedupe-middleware.md b/apps/content/docs/best-practices/dedupe-middleware.md index b0edf1692..7f42c02c2 100644 --- a/apps/content/docs/best-practices/dedupe-middleware.md +++ b/apps/content/docs/best-practices/dedupe-middleware.md @@ -1,73 +1,70 @@ ---- -title: Dedupe Middleware -description: Enhance oRPC middleware performance by avoiding redundant executions. ---- - # Dedupe Middleware -This guide explains how to optimize your [middleware](/docs/middleware) for fast and efficient repeated execution. +Use [context](/docs/context) to prevent the same [middleware](/docs/middleware) from repeating expensive work. ## Problem -When a procedure [calls](/docs/client/server-side#using-the-call-utility) another procedure, overlapping middleware might be applied in both. +The same middleware can run more than once during a single call. This often happens when: -Similarly, when using `.use(auth).router(router)`, some procedures inside `router` might already include the `auth` middleware. +- a procedure [calls](/docs/client/server-side#one-off-calls) another procedure that both use the same middleware +- you use `.use(authProvider).router(router)`, and some procedures in `router` already use `authProvider` :::warning -Redundant middleware execution can hurt performance, especially if the middleware is resource-intensive. +Repeated middleware work can hurt performance, especially for expensive operations such as opening a database connection. ::: ## Solution -Use the `context` to track middleware execution and prevent duplication. For example: +Store the computed value in `context` and reuse it when the middleware runs again. + +For example, this middleware loads auth at most once per call: ```ts twoslash import { os } from '@orpc/server' -declare function connectDb(): Promise<'a_fake_db'> +declare function loadAuth(headers: Headers): Promise<{ id: string } | undefined> // ---cut--- -const dbProvider = os - .$context<{ db?: Awaited> }>() +const authProvider = os + .$context<{ headers: Headers, auth?: { id: string } | undefined, authLoaded?: boolean | undefined }>() .middleware(async ({ context, next }) => { - /** - * If db already exists, skip the connection. - */ - const db = context.db ?? await connectDb() // [!code highlight] + // reuse the loaded auth value if it was already loaded + const auth = context.authLoaded + ? context.auth + : await loadAuth(context.headers) - return next({ context: { db } }) + return next({ context: { auth, authLoaded: true } }) }) ``` -Now `dbProvider` middleware can be safely applied multiple times without duplicating the database connection: +You can now apply `authProvider` multiple times without loading auth again: ```ts twoslash import { call, os } from '@orpc/server' -declare function connectDb(): Promise<'a_fake_db'> -const dbProvider = os - .$context<{ db?: Awaited> }>() +declare function loadAuth(headers: Headers): Promise<{ id: string } | undefined> +const authProvider = os + .$context<{ headers: Headers, auth?: { id: string } | undefined, authLoaded?: boolean | undefined }>() .middleware(async ({ context, next }) => { - const db = context.db ?? await connectDb() + // reuse the loaded auth value if it was already loaded + const auth = context.authLoaded + ? context.auth + : await loadAuth(context.headers) - return next({ context: { db } }) + return next({ context: { auth, authLoaded: true } }) }) // ---cut--- -const foo = os.use(dbProvider).handler(({ context }) => 'Hello World') +const base = os.$context<{ headers: Headers }>() -const bar = os.use(dbProvider).handler(({ context }) => { - /** - * Now when you call foo, the dbProvider middleware no need to connect to the database again. - */ - const result = call(foo, 'input', { context }) // [!code highlight] +const foo = base.use(authProvider).handler(({ context }) => 'Hello World') - return 'Hello World' +const bar = base.use(authProvider).handler(({ context }) => { + // Reuse the auth value that is already stored in context. + return call(foo, undefined, { context }) // [!code highlight] }) -/** - * Now even when `dbProvider` is applied multiple times, it still only connects to the database once. - */ -const router = os - .use(dbProvider) // [!code highlight] +// Applying authProvider again does not load auth a second time. +const router = base + .use(authProvider) // [!code highlight] .use(({ next }) => { // Additional middleware logic return next() @@ -77,49 +74,3 @@ const router = os bar, }) ``` - -## Built-in Dedupe Middleware - -oRPC can automatically dedupe some middleware under specific conditions. - -::: info -Deduplication occurs only if the router middlewares is a **subset** of the **leading** procedure middlewares and appears in the **same order**. -::: - -```ts -const router = os.use(logging).use(dbProvider).router({ - // ✅ Deduplication occurs: - ping: os.use(logging).use(dbProvider).use(auth).handler(({ context }) => 'ping'), - pong: os.use(logging).use(dbProvider).handler(({ context }) => 'pong'), - - // ⛔ Deduplication does not occur: - diff_subset: os.use(logging).handler(({ context }) => 'ping'), - diff_order: os.use(dbProvider).use(logging).handler(({ context }) => 'pong'), - diff_leading: os.use(monitor).use(logging).use(dbProvider).handler(({ context }) => 'bar'), -}) - -// --- equivalent to --- - -const router = { - // ✅ Deduplication occurs: - ping: os.use(logging).use(dbProvider).use(auth).handler(({ context }) => 'ping'), - pong: os.use(logging).use(dbProvider).handler(({ context }) => 'pong'), - - // ⛔ Deduplication does not occur: - diff_subset: os.use(logging).use(dbProvider).use(logging).handler(({ context }) => 'ping'), - diff_order: os.use(logging).use(dbProvider).use(dbProvider).use(logging).handler(({ context }) => 'pong'), - diff_leading: os.use(logging).use(dbProvider).use(monitor).use(logging).use(dbProvider).handler(({ context }) => 'bar'), -} -``` - -### Configuration - -Disable middleware deduplication by setting `dedupeLeadingMiddlewares` to `false` in `.$config`: - -```ts -const base = os.$config({ dedupeLeadingMiddlewares: false }) -``` - -:::warning -The deduplication behavior is safe unless you want to apply middleware multiple times. -::: diff --git a/apps/content/docs/best-practices/monorepo-setup.md b/apps/content/docs/best-practices/monorepo-setup.md index f72197f68..9217b2de5 100644 --- a/apps/content/docs/best-practices/monorepo-setup.md +++ b/apps/content/docs/best-practices/monorepo-setup.md @@ -1,8 +1,3 @@ ---- -title: Monorepo Setup -description: The most efficient way to set up a monorepo with oRPC ---- - # Monorepo Setup A monorepo stores multiple related projects in a single repository, a common practice for managing interconnected projects like web applications and their APIs. @@ -102,6 +97,7 @@ packages/ This is just a suggestion. You can structure your monorepo however you like. ::: -## Related +## Learn More +- [Scaling Large Projects](/docs/advanced/scaling-large-projects) - [Publish Client to NPM](/docs/advanced/publish-client-to-npm) diff --git a/apps/content/docs/best-practices/no-throw-literal.md b/apps/content/docs/best-practices/no-throw-literal.md index 051502f56..4dfa78696 100644 --- a/apps/content/docs/best-practices/no-throw-literal.md +++ b/apps/content/docs/best-practices/no-throw-literal.md @@ -1,8 +1,3 @@ ---- -title: No Throw Literal -description: Always throw `Error` instances instead of literal values. ---- - # No Throw Literal In JavaScript, you can throw any value, but it's best to throw only `Error` instances. @@ -19,30 +14,31 @@ oRPC treats thrown `Error` instances as best practice by default, as recommended ## Configuration -Customize oRPC's behavior by setting `throwableError` in the `Registry`: +Customize oRPC's behavior by setting `ThrowableError` in the `Registry`: ```ts declare module '@orpc/server' { // or '@orpc/contract', or '@orpc/client' interface Registry { - throwableError: Error // [!code highlight] + ThrowableError: Error // [!code highlight] } } ``` :::info -Avoid using `any` or `unknown` for `throwableError` because doing so prevents the client from inferring [type-safe errors](/docs/client/error-handling#using-safe-and-isdefinederror). Instead, use `null | undefined | {}` (equivalent to `unknown`) for stricter error type inference. +Avoid using `any` or `unknown` for `ThrowableError` because doing so prevents the client from inferring [typesafe errors](/docs/client/error-handling#using-safe-and-isinferableerror). Instead, use `null | undefined | {}` (equivalent to `unknown`) for stricter error type inference. ::: -:::tip -If you configure `throwableError` as `null | undefined | {}`, adjust your code to check the `isSuccess` property instead of `error`: +::: warning +If `ThrowableError` is configured as `null | undefined | {}`, check `isSuccess` instead of relying on `error`: ```ts const { error, data, isSuccess } = await safe(client('input')) if (!isSuccess) { - if (isDefinedError(error)) { - // handle type-safe error + if (isInferableError(error)) { + // handle typesafe errors } + // handle other errors } else { diff --git a/apps/content/docs/best-practices/optimize-ssr.md b/apps/content/docs/best-practices/optimize-ssr.md deleted file mode 100644 index 6b1dae110..000000000 --- a/apps/content/docs/best-practices/optimize-ssr.md +++ /dev/null @@ -1,250 +0,0 @@ ---- -title: Optimize Server-Side Rendering (SSR) for Fullstack Frameworks -description: Optimize SSR performance in Next.js, SvelteKit, and other frameworks by using oRPC to make direct server-side API calls, avoiding unnecessary network requests. ---- - -# Optimize Server-Side Rendering (SSR) for Fullstack Frameworks - -This guide demonstrates an optimized approach for setting up Server-Side Rendering (SSR) with oRPC in fullstack frameworks like Next.js, Nuxt, and SvelteKit. This method enhances performance by eliminating redundant network calls during the server rendering process. - -## The Problem with Standard SSR Data Fetching - -In a typical SSR setup within fullstack frameworks, data fetching often involves the server making an HTTP request back to its own API endpoints. - -![Standard SSR: Server calls its own API via HTTP.](/images/standard-ssr-diagram.svg) - -This pattern works, but it introduces unnecessary overhead: the server needs to make an HTTP request to itself to fetch the data, which can add extra latency and consume resources. - -Ideally, during SSR, the server should fetch data by directly invoking the relevant API logic within the same process. - -![Optimized SSR: Server calls API logic directly.](/images/optimized-ssr-diagram.svg) - -Fortunately, oRPC provides both a [server-side client](/docs/client/server-side) and [client-side client](/docs/client/client-side), so you can leverage the former during SSR and automatically fall back to the latter in the browser. - -## Conceptual approach - -```ts -// Use this for server-side calls -const orpc = createRouterClient(router) - -// Fallback to this for client-side calls -const orpc: RouterClient = createORPCClient(someLink) -``` - -But how? A naive `typeof window === 'undefined'` check works, but exposes your router logic to the client. We need a hack that ensures server‑only code never reaches the browser. - -## Implementation - -We'll use `globalThis` to share the server client without bundling it into client code. - -::: code-group - -```ts [lib/orpc.ts] -import type { RouterClient } from '@orpc/server' -import { RPCLink } from '@orpc/client/fetch' -import { createORPCClient } from '@orpc/client' - -declare global { - var $client: RouterClient | undefined -} - -const link = new RPCLink({ - url: () => { - if (typeof window === 'undefined') { - throw new Error('RPCLink is not allowed on the server side.') - } - - return `${window.location.origin}/rpc` - }, -}) - -/** - * Fallback to client-side client if server-side client is not available. - */ -export const client: RouterClient = globalThis.$client ?? createORPCClient(link) -``` - -```ts [lib/orpc.server.ts] -import 'server-only' - -import { createRouterClient } from '@orpc/server' - -globalThis.$client = createRouterClient(router, { - /** - * Provide initial context if needed. - * - * Because this client instance is shared across all requests, - * only include context that's safe to reuse globally. - * For per-request context, use middleware context or pass a function as the initial context. - */ - context: async () => ({ - headers: await headers(), // provide headers if initial context required - }), -}) -``` - -::: - -::: details `OpenAPILink` support? -When you use [OpenAPILink](/docs/openapi/client/openapi-link), its `JsonifiedClient` turns native values (like Date or URL) into plain JSON, so your client types no longer match the output of `createRouterClient`. To fix this, oRPC offers `createJsonifiedRouterClient`, which builds a router client that matches the output of OpenAPILink. - -::: code-group - -```ts [lib/orpc.ts] -import type { RouterClient } from '@orpc/server' -import type { JsonifiedClient } from '@orpc/openapi-client' -import { OpenAPILink } from '@orpc/openapi-client/fetch' -import { createORPCClient } from '@orpc/client' - -declare global { - var $client: JsonifiedClient> | undefined -} - -const link = new OpenAPILink(contract, { - url: () => { - if (typeof window === 'undefined') { - throw new Error('OpenAPILink is not allowed on the server side.') - } - - return `${window.location.origin}/api` - }, -}) - -/** - * Fallback to client-side client if server-side client is not available. - */ -export const client: JsonifiedClient> = globalThis.$client ?? createORPCClient(link) -``` - -```ts [lib/orpc.server.ts] -import 'server-only' - -import { createJsonifiedRouterClient } from '@orpc/openapi' - -globalThis.$client = createJsonifiedRouterClient(router, { - /** - * Provide initial context if needed. - * - * Because this client instance is shared across all requests, - * only include context that's safe to reuse globally. - * For per-request context, use middleware context or pass a function as the initial context. - */ - context: async () => ({ - headers: await headers(), // provide headers if initial context required - }), -}) -``` - -::: - -Finally, ensure `lib/orpc.server.ts` is imported before any other code on the server. In Next.js, add it to both `instrumentation.ts` and `app/layout.tsx`: - -::: code-group - -```ts [instrumentation.ts] -export async function register() { - // Conditionally import if facing runtime compatibility issues - // if (process.env.NEXT_RUNTIME === "nodejs") { - await import('./lib/orpc.server') - // } -} -``` - -```ts [app/layout.tsx] -import '../lib/orpc.server' // for pre-rendering - -// Rest of the code -``` - -::: - -Now, importing `client` from `lib/orpc.ts` gives you a server-side client during SSR and a client-side client on the client without leaking your router logic. - -## Alternative Approach - -The above approach is the most straightforward and performant, but you can also use a `fetch` adapter approach that enables plugins like `DedupeRequestsPlugin` and works with any `handler/link` pair that supports the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). - -::: info -oRPC doesn't restrict you to any specific approach for optimizing SSR - you can choose whatever approach works best for your framework or requirements. -::: - -```ts [lib/orpc.server.ts] -import 'server-only' - -import { createORPCClient } from '@orpc/client' -import { RPCLink } from '@orpc/client/fetch' -import type { RouterClient } from '@orpc/server' -import { handler } from '@/app/rpc/[[...rest]]/route' - -const link = new RPCLink({ - url: 'http://placeholder', - method: inferRPCMethodFromRouter(router), - plugins: [ - new DedupeRequestsPlugin({ - groups: [{ - condition: () => true, - context: {}, - }], - }), - ], - fetch: async (request) => { - const { response } = await handler.handle(request, { - context: { - headers: await headers(), // Provide headers if needed - }, - }) - - return response ?? new Response('Not Found', { status: 404 }) - }, -}) - -globalThis.$client = createORPCClient>(link) -``` - -## Using the client - -The `client` requires no special handling, just use it like regular clients. - -```tsx -export default async function PlanetListPage() { - const planets = await client.planet.list({ limit: 10 }) - - return ( -
- {planets.map(planet => ( -
{planet.name}
- ))} -
- ) -} -``` - -::: info -This example uses Next.js, but you can apply the same pattern in SvelteKit, Nuxt, or any framework. -::: - -## TanStack Query - -Combining this oRPC setup with TanStack Query (React Query, Solid Query, etc.) provides a powerful pattern for data fetching, and state management, especially with Suspense hooks. Refer to these details in [Tanstack Query Integration Guide](/docs/integrations/tanstack-query-old/basic) and [Tanstack Query SSR Guide](https://tanstack.com/query/latest/docs/framework/react/guides/ssr). - -```tsx -export default function PlanetListPage() { - const { data: planets } = useSuspenseQuery( - orpc.planet.list.queryOptions({ - input: { limit: 10 }, - }), - ) - - return ( -
- {planets.map(planet => ( -
{planet.name}
- ))} -
- ) -} -``` - -:::warning -Above example uses suspense hooks, you might need to wrap your app within `` (or corresponding APIs) to make it work. In Next.js, maybe you need create `loading.tsx`. -::: diff --git a/apps/content/docs/best-practices/optimizing-ssr.md b/apps/content/docs/best-practices/optimizing-ssr.md new file mode 100644 index 000000000..2336b8236 --- /dev/null +++ b/apps/content/docs/best-practices/optimizing-ssr.md @@ -0,0 +1,166 @@ +# Optimizing Server-Side Rendering (SSR) for Fullstack Frameworks + +This guide shows how to optimize Server-Side Rendering (SSR) with oRPC in fullstack frameworks such as Next.js, Nuxt, and SvelteKit. The goal is to avoid unnecessary network calls while the server renders a page. + +## The Problem with Standard SSR Data Fetching + +In many fullstack frameworks, SSR still fetches data by making an HTTP request from the server to its own API route. + +![Standard SSR: Server calls its own API via HTTP.](/images/standard-ssr-diagram.svg) + +This works, but it adds avoidable overhead. The server has to go through the HTTP layer just to reach code that is already running in the same process. That extra hop can increase latency and waste resources. + +Ideally, SSR should fetch data by calling the relevant API logic directly in the same process. + +![Optimized SSR: Server calls API logic directly.](/images/optimized-ssr-diagram.svg) + +With [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) support, you can easily create an internal link that reaches your API logic without making a real network request. For even lower overhead, you can use the [server-side client](#using-server-side-client-directly) directly. + +## Conceptual approach + +```ts +// During SSR, use an internal link +const orpc: RouterClient = createORPCClient(internalLink) + +// In the browser, use a normal remote link +const orpc: RouterClient = createORPCClient(remoteLink) +``` + +But how? A naive `typeof window === 'undefined'` check works, **but exposes your router logic to the client**. We need a hack that ensures server‑only code never reaches the browser. + +## Implementation + +We'll use `globalThis` to share an SSR client without bundling server-only code into the browser. + +::: info +This setup is not limited to [RPC Link](/docs/rpc/link) or [Next.js](https://nextjs.org/). You can use [OpenAPI Link](/docs/openapi/link) or a custom one, and the same pattern works in SvelteKit, Nuxt, and other fullstack frameworks. +::: + +::: code-group + +```ts [lib/orpc.ts] +import type { RouterClient } from '@orpc/server' +import { RPCLink } from '@orpc/client/fetch' +import { createORPCClient } from '@orpc/client' + +declare global { + var $client: RouterClient | undefined +} + +const link = new RPCLink({ + origin: () => { + if (typeof window === 'undefined') { + throw new Error('This link is not allowed on the server side.') + } + + return window.location.origin + }, +}) + +/** + * Fall back to a browser client when no SSR client is registered. + */ +export const client: RouterClient = globalThis.$client ?? createORPCClient(link) +``` + +```ts [lib/orpc.server.ts] +import 'server-only' + +import { createORPCClient } from '@orpc/client' +import { RPCLink } from '@orpc/client/fetch' +import type { RouterClient } from '@orpc/server' +import { headers } from 'next/headers' + +const internalLink = new RPCLink({ + origin: 'http://localhost', + fetch: async (url, init) => { + const request = new Request(url, init) + + // Use a fetch handler here + const { response } = await handler.handle(request, { + context: { // provide initial context if needed + headers: await headers(), + }, + }) + + return response ?? new Response('Not Found', { status: 404 }) + }, +}) + +globalThis.$client = createORPCClient(internalLink) +``` + +::: + +Import `lib/orpc.server.ts` before other server code so the SSR client is registered early. In Next.js, add it to both `instrumentation.ts` and `app/layout.tsx`: + +::: code-group + +```ts [instrumentation.ts] +export async function register() { + // Conditionally import if facing runtime compatibility issues + // if (process.env.NEXT_RUNTIME === "nodejs") { + await import('./lib/orpc.server') + // } +} +``` + +```ts [app/layout.tsx] +import '../lib/orpc.server' // for pre-rendering + +// Rest of the code +``` + +::: + +With this setup, importing `client` from `lib/orpc.ts` uses the internal-link client during SSR and the remote client in the browser. + +## Using Server-Side Client Directly + +Alternatively, you can use the [server-side client](/docs/client/server-side) directly for SSR. This approach is more efficient and straightforward, as it eliminates serialization and deserialization overhead entirely. + +::: info +Both a [fetch-based internal link](#implementation) and the [server-side client](/docs/client/server-side) are valid strategies for optimizing SSR. The fetch-based approach offers greater flexibility and plugin compatibility, while the server-side client is more efficient and easier to set up. Choose whichever best fits your needs. +::: + +```ts +import 'server-only' + +import { createRouterClient } from '@orpc/server' +import { headers } from 'next/headers' + +globalThis.$client = createRouterClient(router, { + /** + * Provide initial context if needed. + * + * Because this client instance is shared across all requests, + * only include context that's safe to reuse globally. + * For per-request context, use middleware context or pass a function as the initial context. + */ + context: async () => ({ + headers: await headers(), // provide headers if initial context required + }), +}) +``` + +## Using the client + +The `client` needs no special handling. Use it like any other oRPC client. + +```tsx +export default async function PlanetListPage() { + const planets = await client.planet.list({ limit: 10 }) + + return ( +
+ {planets.map(planet => ( +
{planet.name}
+ ))} +
+ ) +} +``` + +::: info +These examples use Next.js, but the same pattern also works in SvelteKit, Nuxt, and other fullstack frameworks. +::: diff --git a/apps/content/docs/binary-data.md b/apps/content/docs/binary-data.md new file mode 100644 index 000000000..036b75b01 --- /dev/null +++ b/apps/content/docs/binary-data.md @@ -0,0 +1,60 @@ +# Binary Data + +[File](https://developer.mozilla.org/en-US/docs/Web/API/File), [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob), and [ReadableStream\](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) are supported by the [RPC Serializer](/docs/rpc/serializer) and [OpenAPI Serializer](/docs/openapi/serializer). Use them to handle binary data in your procedures. + + + +## `File` and `Blob` + +Procedures can accept `File` and `Blob` as input and return them directly or inside nested structures. + +::: warning +`File` and `Blob` are usually buffered in memory by default. For large files, we recommend [extending the body parser](/docs/advanced/extend-body-parser) for better performance and reliability. +::: + +```ts twoslash +import { os } from '@orpc/server' +import * as z from 'zod' +// ---cut--- +const example = os + .input(z.file()) + .output(z.object({ anyFieldName: z.instanceof(File) })) + .handler(async ({ input }) => { + const file = input + + console.log(file.name) + + return { + anyFieldName: new File(['Hello World'], 'hello.txt', { type: 'text/plain' }), + } + }) +``` + +## `ReadableStream` + +Procedures can return `ReadableStream` to stream binary responses. The example below uses the [Response Headers Plugin](/docs/plugins/response-headers) to set the appropriate `Content-Type` header. + +```ts twoslash +import { os } from '@orpc/server' +import { ResponseHeadersHandlerPluginContext } from '@orpc/server/plugins' +import * as z from 'zod' + +interface ServerContext extends ResponseHeadersHandlerPluginContext {} + +const base = os.$context() +// ---cut--- +const example = base + .output(z.instanceof(ReadableStream)) + .handler(async ({ context }) => { + context.resHeaders?.set('Content-Type', 'text/plain') + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('Hello World')) + controller.close() + } + }) + + return stream + }) +``` diff --git a/apps/content/docs/client/client-side.md b/apps/content/docs/client/client-side.md index 8c598d41a..c9df8edb4 100644 --- a/apps/content/docs/client/client-side.md +++ b/apps/content/docs/client/client-side.md @@ -1,11 +1,6 @@ ---- -title: Client-Side Clients -description: Call your oRPC procedures remotely as if they were local functions. ---- - # Client-Side Clients -Call your [procedures](/docs/procedure) remotely as if they were local functions. +Client-side clients call procedures remotely, in a different process or on a different machine. They are useful in frontend applications, mobile apps, or any setup where the client and server run in different environments. ## Installation @@ -35,35 +30,22 @@ deno add npm:@orpc/client@latest ## Creating a Client -This guide uses [RPCLink](/docs/client/rpc-link), so make sure your server is set up with [RPCHandler](/docs/rpc-handler) or any API that follows the [RPC Protocol](/docs/advanced/rpc-protocol). +To create a client, first set up a link that defines how the client communicates with the server. This can be an [RPC Link](/docs/rpc/link), an [OpenAPI Link](/docs/openapi/link), or any custom link. Then create a client for your [router](/docs/router) or [contract](/docs/contract/router) using `createORPCClient`. ```ts -import { createORPCClient, onError } from '@orpc/client' -import { RPCLink } from '@orpc/client/fetch' +import { createORPCClient } from '@orpc/client' +import { RouterContractClient } from '@orpc/contract' import { RouterClient } from '@orpc/server' -import { ContractRouterClient } from '@orpc/contract' - -const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - headers: () => ({ - authorization: 'Bearer token', - }), - // fetch: <-- provide fetch polyfill fetch if needed - interceptors: [ - onError((error) => { - console.error(error) - }) - ], -}) -// Create a client for your router -const client: RouterClient = createORPCClient(link) -// Or, create a client using a contract -const client: ContractRouterClient = createORPCClient(link) +// if you are following contract-first approach +const contractClient: RouterContractClient = createORPCClient(link) + +// if you are following normal approach +const normalClient: RouterClient = createORPCClient(link) ``` :::tip -You can export `RouterClient` and `ContractRouterClient` from server instead. +You can export `RouterClient` or `RouterContractClient` from the server to avoid importing the contract or router in the client. ::: ## Calling Procedures @@ -71,20 +53,88 @@ You can export `RouterClient` and `ContractRouterClient 'pong'), + pong: os.handler(() => 'ping'), +} -const client = {} as RouterClient +declare const client: RouterClient // ---cut--- -const planet = await client.planet.find({ id: 1 }) +const pong = await client.ping() -client.planet.create -// ^| +client.ping +// ^| ``` -## Merge Clients +## Client Context + +Client context lets you pass values with each call, such as auth tokens or cache hints. + +```ts +interface ClientContext { + token?: string +} + +// if you are following contract-first approach +const client: RouterContractClient = createORPCClient(link) + +// if you are following normal approach +const client: RouterClient = createORPCClient(link) + +const output = await client.someProcedure(input, { + context: { + token: 'abc123', + }, +}) +``` + +## Interceptors + +Interceptors let you wrap client calls. They are similar to interceptors in links, but are more typesafe because the exact input, output, and error types of each client are known. You can provide per-client interceptors with `scoped`. + +```ts +import { isInferableError, safe } from '@orpc/client' + +const client: RouterClient = createORPCClient(link, { + interceptors: [ + async ({ context, path, next }) => { + const [error, data] = await safe(next()) + + if (error) { + if (isInferableError(error)) { + // handle typesafe errors + } + + throw error + } + + return data + } + ], + scoped: { + planet: { + find: { + interceptors: [ // <- these interceptors only apply to client.planet.find + async ({ context, path, next }) => { + return next() + } + ] + } + } + } +}) +``` + +::: info +You can use [`safe` and `isInferableError`](/docs/client/error-handling#using-safe-and-isinferableerror) together for typesafe error handling in interceptors. +::: + +## Merging Clients -In oRPC, a client is a simple object-like structure. To merge multiple clients, you simply assign each client to a property in a new object: +In oRPC, a client is just an object-like structure. To merge multiple clients, assign each client to a property on a new object: ```ts const clientA: RouterClient = createORPCClient(linkA) @@ -101,14 +151,14 @@ export const orpc = { ## Utilities ::: info -These utilities can be used for any kind of oRPC client. +These utilities can also be used for [server-side clients](/docs/client/server-side) and are not specific to client-side clients. ::: ### Infer Client Inputs -```ts twoslash -import type { orpc as client } from './shared/planet' -// ---cut--- +Infers input types for each procedure in a client. + +```ts import type { InferClientInputs } from '@orpc/client' type Inputs = InferClientInputs @@ -116,13 +166,11 @@ type Inputs = InferClientInputs type FindPlanetInput = Inputs['planet']['find'] ``` -Recursively infers the **input types** from a client. Produces a nested map where each endpoint's input type is preserved. - ### Infer Client Body Inputs -```ts twoslash -import type { orpc as client } from './shared/planet' -// ---cut--- +Infers body input types for each procedure in a client. If an endpoint's input includes `{ body: ... }`, only the `body` portion is extracted. Otherwise, the entire input type is used. + +```ts import type { InferClientBodyInputs } from '@orpc/client' type BodyInputs = InferClientBodyInputs @@ -130,13 +178,11 @@ type BodyInputs = InferClientBodyInputs type FindPlanetBodyInput = BodyInputs['planet']['find'] ``` -Recursively infers the **body input types** from a client. If an endpoint's input includes `{ body: ... }`, only the `body` portion is extracted. Produces a nested map of body input types. - ### Infer Client Outputs -```ts twoslash -import type { orpc as client } from './shared/planet' -// ---cut--- +Infers output types for each procedure in a client. + +```ts import type { InferClientOutputs } from '@orpc/client' type Outputs = InferClientOutputs @@ -144,13 +190,11 @@ type Outputs = InferClientOutputs type FindPlanetOutput = Outputs['planet']['find'] ``` -Recursively infers the **output types** from a client. Produces a nested map where each endpoint's output type is preserved. - ### Infer Client Body Outputs -```ts twoslash -import type { orpc as client } from './shared/planet' -// ---cut--- +Infers body output types for each procedure in a client. If an endpoint's output includes `{ body: ... }`, only the `body` portion is extracted. Otherwise, the entire output type is used. + +```ts import type { InferClientBodyOutputs } from '@orpc/client' type BodyOutputs = InferClientBodyOutputs @@ -158,13 +202,11 @@ type BodyOutputs = InferClientBodyOutputs type FindPlanetBodyOutput = BodyOutputs['planet']['find'] ``` -Recursively infers the **body output types** from a client. If an endpoint's output includes `{ body: ... }`, only the `body` portion is extracted. Produces a nested map of body output types. - ### Infer Client Errors -```ts twoslash -import type { orpc as client } from './shared/planet' -// ---cut--- +Infers the errors each procedure in a client can throw when using [type-safe error handling](/docs/error-handling#typesafe-errors). + +```ts import type { InferClientErrors } from '@orpc/client' type Errors = InferClientErrors @@ -172,28 +214,22 @@ type Errors = InferClientErrors type FindPlanetError = Errors['planet']['find'] ``` -Recursively infers the **error types** from a client when using [type-safe error handling](/docs/error-handling#type‐safe-error-handling). Produces a nested map where each endpoint's error type is preserved. +### Infer Client Error -### Infer Client Error Union +Infers all possible errors the entire client can throw. This is useful with [type-safe error handling](/docs/error-handling#typesafe-errors). -```ts twoslash -import type { orpc as client } from './shared/planet' -// ---cut--- -import type { InferClientErrorUnion } from '@orpc/client' +```ts +import type { InferClientError } from '@orpc/client' -type AllErrors = InferClientErrorUnion +type ClientError = InferClientError ``` -Recursively infers a **union of all error types** from a client when using [type-safe error handling](/docs/error-handling#type‐safe-error-handling). Useful when you want to handle all possible errors from any endpoint at once. - ### Infer Client Context -```ts twoslash -import type { orpc as client } from './shared/planet' -// ---cut--- +Infers the [client context](#client-context) type from a client. + +```ts import type { InferClientContext } from '@orpc/client' type Context = InferClientContext ``` - -Infers the client context type from a client. diff --git a/apps/content/docs/client/dynamic-link.md b/apps/content/docs/client/dynamic-link.md index ef888b81b..642f58bd7 100644 --- a/apps/content/docs/client/dynamic-link.md +++ b/apps/content/docs/client/dynamic-link.md @@ -1,20 +1,17 @@ ---- -title: DynamicLink -description: Dynamically switch between multiple oRPC's links. ---- - # DynamicLink -`DynamicLink` lets you dynamically choose between different oRPC's links based on your client context. This capability enables flexible routing of RPC requests. +`DynamicLink` lets you choose a link at runtime. Use it when different requests should be routed through different links. ## Example -This example shows how the client dynamically selects between two [RPCLink](/docs/client/rpc-link) instances based on the client context: one dedicated to cached requests and another for non-cached requests. - ```ts twoslash -import { router } from './shared/planet' -import { RouterClient } from '@orpc/server' +import { os, RouterClient } from '@orpc/server' import { RPCLink } from '@orpc/client/fetch' + +const router = { + ping: os.handler(() => 'pong'), + pong: os.handler(() => 'ping'), +} // ---cut--- import { createORPCClient, DynamicLink } from '@orpc/client' @@ -23,11 +20,11 @@ interface ClientContext { } const cacheLink = new RPCLink({ - url: 'https://cache.example.com/rpc', + origin: 'https://cache.example.com', }) const noCacheLink = new RPCLink({ - url: 'https://example.com/rpc', + origin: 'https://example.com', }) const link = new DynamicLink((options, path, input) => { @@ -41,6 +38,6 @@ const link = new DynamicLink((options, path, input) => { const client: RouterClient = createORPCClient(link) ``` -:::info -Any oRPC's link is supported, not strictly limited to `RPCLink`. +::: info +This example uses two [RPC Link](/docs/rpc/link) instances, but `DynamicLink` works with any other link. ::: diff --git a/apps/content/docs/client/error-handling.md b/apps/content/docs/client/error-handling.md index cd23f542e..c52f0f43b 100644 --- a/apps/content/docs/client/error-handling.md +++ b/apps/content/docs/client/error-handling.md @@ -1,21 +1,31 @@ ---- -title: Error Handling in oRPC Clients -description: Learn how to handle errors in a type-safe way in oRPC clients. ---- +# Client Error Handling -# Error Handling in oRPC Clients +oRPC supports several ways to handle client-side errors. In most cases, `try/catch` is enough. If you use [Typesafe Errors](/docs/error-handling#typesafe-errors), `safe` and `createSafeClient` let you handle them with full type inference. -This guide explains how to handle type-safe errors in oRPC clients using [type-safe error handling](/docs/error-handling#type‐safe-error-handling). Both [server-side](/docs/client/server-side) and [client-side](/docs/client/client-side) clients are supported. +## Using `try/catch` -## Using `safe` and `isDefinedError` +For most calls, use regular `try/catch`. + +```ts +try { + const data = await client.doSomething({ id: '123' }) +} +catch (error) { + // handle error +} +``` + +## Using `safe` and `isInferableError` + +When working with [Typesafe Errors](/docs/error-handling#typesafe-errors), use `safe` to preserve error type inference. It behaves like `try/catch`, but returns the typesafe result instead of throwing. ```ts twoslash -import { os } from '@orpc/server' +import { call, os } from '@orpc/server' import * as z from 'zod' // ---cut--- -import { isDefinedError, safe } from '@orpc/client' +import { isInferableError, safe } from '@orpc/client' -const doSomething = os +const exampleProcedure = os .input(z.object({ id: z.string() })) .errors({ RATE_LIMIT_EXCEEDED: { @@ -24,16 +34,17 @@ const doSomething = os }) .handler(async ({ input, errors }) => { throw errors.RATE_LIMIT_EXCEEDED({ data: { retryAfter: 1000 } }) - - return { id: input.id } }) - .callable() -const [error, data, isDefined] = await safe(doSomething({ id: '123' })) -// or const { error, data, isDefined } = await safe(doSomething({ id: '123' })) +// or { error, data, inferableError } +const [error, data, inferableError] = await safe( + call(exampleProcedure, { id: '123' }) +) + +if (isInferableError(error)) { // or inferableError + // handle inferable error -if (isDefinedError(error)) { // or isDefined - // handle known error + // or inferableError.data.retryAfter console.log(error.data.retryAfter) } else if (error) { @@ -45,18 +56,18 @@ else { } ``` -:::info +::: info +`safe` supports both tuple and object forms: -- `safe` works like `try/catch`, but can infer error types. -- `safe` supports both tuple `[error, data, isDefined]` and object `{ error, data, isDefined }` styles. -- `isDefinedError` checks if an error originates from `.errors`. -- `isDefined` can replace `isDefinedError` +- `[error, data, inferableError]` +- `{ error, data, inferableError }` +`inferableError` is the same value as `error` when `isInferableError(error)` returns `true`; otherwise it is `null`. ::: ## Safe Client -If you often use `safe` for error handling, `createSafeClient` can simplify your code by automatically wrapping all procedure calls with `safe`. It works with both [server-side](/docs/client/server-side) and [client-side](/docs/client/client-side) clients. +If you use `safe` often, `createSafeClient` can reduce repetition by wrapping entire client calls with `safe`. ```ts import { createSafeClient } from '@orpc/client' diff --git a/apps/content/docs/client/event-iterator.md b/apps/content/docs/client/event-iterator.md index 82cffede9..b4cde24ac 100644 --- a/apps/content/docs/client/event-iterator.md +++ b/apps/content/docs/client/event-iterator.md @@ -1,24 +1,18 @@ ---- -title: Event Iterator in oRPC Clients -description: Learn how to use event iterators in oRPC clients. ---- +# Event Iterator in Client -# Event Iterator in oRPC Clients - -An [Event Iterator](/docs/event-iterator) in oRPC behaves like an [AsyncGenerator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator). -Simply iterate over it and await each event. +Consume an [Event Iterator](/docs/event-iterator) like an [AsyncGenerator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator). Await the call, then iterate over events as they arrive. ## Basic Usage ```ts twoslash -import { ContractRouterClient, eventIterator, oc } from '@orpc/contract' -import * as z from 'zod' +import { eventIterator, oc, RouterContractClient } from '@orpc/contract' +import { z } from 'zod' const contract = { streaming: oc.output(eventIterator(z.object({ message: z.string() }))) } -declare const client: ContractRouterClient +declare const client: RouterContractClient // ---cut--- const iterator = await client.streaming() @@ -27,9 +21,9 @@ for await (const event of iterator) { } ``` -## Stopping the Stream Manually +## Stopping the Stream -You can rely on `signal` or `.return` to stop the iterator. +Use an `AbortSignal` or call `.return` to stop the iterator. ```ts const controller = new AbortController() @@ -38,8 +32,8 @@ const iterator = await client.streaming(undefined, { signal: controller.signal } // Stop the stream after 1 second setTimeout(async () => { controller.abort() - // or - await iterator.return() + + // Or call `await iterator.return()` if you already have the iterator instance. }, 1000) for await (const event of iterator) { @@ -50,7 +44,7 @@ for await (const event of iterator) { ## Error Handling ::: info -Unlike traditional SSE, the Event Iterator does not automatically retry on error. To enable automatic retries, refer to the [Client Retry Plugin](/docs/plugins/client-retry). +Unlike traditional SSE, Event Iterators do not retry automatically after an error. To add retries, use the [Retry Plugin](/docs/plugins/retry#event-source-simulation). ::: ```ts @@ -68,13 +62,24 @@ catch (error) { } ``` -::: info -Errors thrown by the server can be instances of `ORPCError`. -::: +## Event Metadata + +Use `getEventMeta` to read [event metadata](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format) for each item, such as the event ID and retry interval. + +```ts +import { getEventMeta } from '@orpc/client' + +const iterator = await client.streaming() + +for await (const event of iterator) { + const meta = getEventMeta(event) + console.log(event.message, meta?.id, meta?.retry) +} +``` ## Using `consumeEventIterator` -oRPC provides a utility function `consumeEventIterator` to consume an event iterator with lifecycle callbacks. +Use `consumeEventIterator` to consume an event iterator with lifecycle callbacks. It accepts either an event iterator or a promise that resolves to one. ```ts import { consumeEventIterator } from '@orpc/client' @@ -99,7 +104,3 @@ setTimeout(async () => { await cancel() }, 1000) ``` - -:::info -This utility accepts both promises and event iterators. Passing a promise directly lets it infer correct error type. -::: diff --git a/apps/content/docs/client/rpc-link.md b/apps/content/docs/client/rpc-link.md deleted file mode 100644 index bb0495911..000000000 --- a/apps/content/docs/client/rpc-link.md +++ /dev/null @@ -1,181 +0,0 @@ ---- -title: RPCLink -description: Details on using RPCLink in oRPC clients. ---- - -# RPCLink - -RPCLink enables communication with an [RPCHandler](/docs/rpc-handler) or any API that follows the [RPC Protocol](/docs/advanced/rpc-protocol) using HTTP/Fetch. - -:::warning -This documentation is focused on the [HTTP Adapter](/docs/adapters/http). -Other adapters may remove or change options to keep things simple. -::: - -## Overview - -Before using RPCLink, make sure your server is set up with [RPCHandler](/docs/rpc-handler) or any API that follows the [RPC Protocol](/docs/advanced/rpc-protocol). - -```ts -import { onError } from '@orpc/client' -import { RPCLink } from '@orpc/client/fetch' - -const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - headers: () => ({ - 'x-api-key': 'my-api-key' - }), - fetch: (request, init) => { - return globalThis.fetch(request, { - ...init, - credentials: 'include', // Include cookies for cross-origin requests - }) - }, - interceptors: [ - onError((error) => { - console.error(error) - }) - ], -}) - -export const client: RouterClient = createORPCClient(link) -``` - -## Using Client Context - -Client context lets you pass extra information when calling procedures and dynamically modify RPCLink's behavior. - -```ts twoslash -import { router } from './shared/planet' -import { RouterClient } from '@orpc/server' -import { createORPCClient } from '@orpc/client' -import { RPCLink } from '@orpc/client/fetch' - -interface ClientContext { - something?: string -} - -const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - headers: async ({ context }) => ({ - 'x-api-key': context?.something ?? '' - }) -}) - -const client: RouterClient = createORPCClient(link) - -const result = await client.planet.list( - { limit: 10 }, - { context: { something: 'value' } } -) -``` - -:::info -If a property in `ClientContext` is required, oRPC enforces its inclusion when calling procedures. -::: - -## Custom Request Method - -By default, RPCLink sends requests via `POST`. You can override this to use methods like `GET` (for browser or CDN caching) based on your requirements. - -::: warning -By default, [RPCHandler](/docs/rpc-handler) in the [HTTP Adapter](/docs/adapters/http) enabled [StrictGetMethodPlugin](/docs/rpc-handler#default-plugins) which blocks GET requests except for procedures explicitly allowed. Please refer to [StrictGetMethodPlugin](/docs/plugins/strict-get-method) for more details. -::: - -```ts twoslash -import { RPCLink } from '@orpc/client/fetch' - -interface ClientContext { - cache?: RequestCache -} - -const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - method: ({ context }, path) => { - // Use GET for cached responses - if (context?.cache) { - return 'GET' - } - - // Use GET for rendering requests - if (typeof window === 'undefined') { - return 'GET' - } - - // Use GET for read-like operations - if (path.at(-1)?.match(/^(?:get|find|list|search)(?:[A-Z].*)?$/)) { - return 'GET' - } - - return 'POST' - }, - fetch: (request, init, { context }) => globalThis.fetch(request, { - ...init, - cache: context?.cache, - }), -}) -``` - -::: details Automatically use method specified in contract? - -By using `inferRPCMethodFromContractRouter`, the `RPCLink` automatically uses the method specified in the contract when sending requests. - -```ts -import { inferRPCMethodFromContractRouter } from '@orpc/contract' - -const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - method: inferRPCMethodFromContractRouter(contract), -}) -``` - -::: info -A normal [router](/docs/router) works as a contract router as long as it does not include a [lazy router](/docs/router#lazy-router). For more advanced use cases, refer to the [Router to Contract](/docs/contract-first/router-to-contract) guide. -::: - -## Lazy URL - -You can define `url` as a function, ensuring compatibility with environments that may lack certain runtime APIs. - -```ts -const link = new RPCLink({ - url: () => { - if (typeof window === 'undefined') { - throw new Error('RPCLink is not allowed on the server side.') - } - - return `${window.location.origin}/rpc` - }, -}) -``` - -## SSE Like Behavior - -Unlike traditional SSE, the [Event Iterator](/docs/event-iterator) does not automatically retry on error. To enable automatic retries, refer to the [Client Retry Plugin](/docs/plugins/client-retry). - -## Lifecycle - -```mermaid -sequenceDiagram - actor A1 as Client - participant P1 as Input/Output/Error Encoder - participant P2 as Client Sender - participant P3 as Adapter - - A1 ->> P1: input, signal, lastEventId, ... - Note over P1: interceptors - P1 ->> P1: encode request - P1 ->> P2: standard request - Note over P2: clientInterceptors - P2 ->> P3: adapter request - Note over P3: adapterInterceptors - P3 ->> P3: send - P3 ->> P2: adapter response - P2 ->> P1: standard response - P1 ->> P1: decode response - P1 ->> A1: error/output -``` - -::: tip -Interceptors can be used to intercept and modify the lifecycle at various stages. -::: diff --git a/apps/content/docs/client/server-side.md b/apps/content/docs/client/server-side.md index 1b1d7f64e..92c2790a5 100644 --- a/apps/content/docs/client/server-side.md +++ b/apps/content/docs/client/server-side.md @@ -1,65 +1,58 @@ ---- -title: Server-Side Clients -description: Call your oRPC procedures in the same environment as your server like native functions. ---- - # Server-Side Clients -Call your [procedures](/docs/procedure) in the same environment as your server, no proxies required like native functions. - -## Calling Procedures - -oRPC offers multiple methods to invoke a [procedure](/docs/procedure). +Server-side clients call procedures locally, within the same process. They are useful in microservices, serverless functions, or any setup where the caller and procedures run in the same environment. -### Using `.callable` +## One-Off Calls -Define your procedure and turn it into a callable procedure: +Use `call` when you need to invoke a single procedure without creating a client instance. ```ts twoslash -import { os } from '@orpc/server' import * as z from 'zod' -const getProcedure = os - .input(z.object({ id: z.string() })) - .handler(async ({ input }) => ({ id: input.id })) - .callable({ - context: {} // Provide initial context if needed - }) - -const result = await getProcedure({ id: '123' }) -``` - -### Using the `call` Utility - -Alternatively, call your procedure using the `call` helper: - -```ts twoslash -import * as z from 'zod' +const exampleProcedure = os + .input(z.string()) + .handler(async ({ input }) => ({ id: input })) +// ---cut--- import { call, os } from '@orpc/server' -const getProcedure = os - .input(z.object({ id: z.string() })) - .handler(async ({ input }) => ({ id: input.id })) - -const result = await call(getProcedure, { id: '123' }, { - context: {} // Provide initial context if needed +const result = await call(exampleProcedure, 'input', { + context: {} // <- provide initial context if needed }) ``` -## Router Client +## Router Clients -Create a [router](/docs/router) based client to access multiple procedures: +Use `createRouterClient` to create a client for your [router](/docs/router). This is useful when you want to call multiple procedures. ```ts twoslash import * as z from 'zod' -// ---cut--- -import { createRouterClient, os } from '@orpc/server' - -const ping = os.handler(() => 'pong') -const pong = os.handler(() => 'ping') +import { os } from '@orpc/server' -const client = createRouterClient({ ping, pong }, { - context: {} // Provide initial context if needed +const router = { + ping: os.handler(() => 'pong'), + pong: os.handler(() => 'ping'), +} +// ---cut--- +import { createRouterClient } from '@orpc/server' + +const client = createRouterClient(router, { + context: {}, // <- provide initial context if needed, can be async function + interceptors: [ + async ({ next, path }) => { + console.time(path.join('.')) + + try { + return await next() + } + catch (err) { + console.error(`${path.join('.')}:`, err) + throw err + } + finally { + console.timeEnd(path.join('.')) + } + } + ] }) const result = await client.ping() @@ -67,73 +60,92 @@ const result = await client.ping() ### Client Context -You can define a client context to pass additional information when calling procedures. This is useful for modifying procedure behavior dynamically. +Client context is passed with each call. Use it to switch between contexts, such as different users or tenants, without creating multiple client instances. ```ts twoslash import * as z from 'zod' import { createRouterClient, os } from '@orpc/server' + +const router = { + ping: os.handler(() => 'pong'), + pong: os.handler(() => 'ping'), +} // ---cut--- interface ClientContext { cache?: boolean } -const ping = os.handler(() => 'pong') -const pong = os.handler(() => 'ping') - -const client = createRouterClient({ ping, pong }, { +const client = createRouterClient(router, { context: ({ cache }: ClientContext) => { // [!code highlight] if (cache) { - return {} // <-- context when cache enabled + return {} // <- context when cache enabled } - return {} // <-- context when cache disabled + return {} } }) const result = await client.ping(undefined, { context: { cache: true } }) ``` -:::info -If `ClientContext` contains a required property, oRPC enforces that the client provides it when calling a procedure. -::: +### Interceptors -## Lifecycle +Interceptors let you observe or modify an entire call. Common use cases include logging, error handling, and metrics collection. -```mermaid -sequenceDiagram - actor A1 as Client - participant P1 as Error Validator - participant P2 as Input/Output Validator - participant P3 as Handler - - A1 ->> P2: input, signal, lastEventId, ... - Note over P2: interceptors - Note over P2: middlewares before .input - P2 ->> P2: Validate Input - P2 ->> P1: if invalid input - P1 ->> P1: validate error - P1 ->> A1: invalid input error - Note over P2: middlewares after .input - P2 ->> P3: validated input, signal, lastEventId, ... - P3 ->> P3: handle - P3 ->> P2: error/output - P2 ->> P2: validate output - P2 ->> P1: error/validated output - P1 ->> P1: validate error - P1 ->> A1: validated error/output +```ts +const client = createRouterClient(router, { + interceptors: [ + async ({ next, path, context }) => { + console.time(path.join('.')) + + try { + const output = await next() + return output + } + catch (err) { + console.error(`${path.join('.')}:`, err) + throw err + } + finally { + console.timeEnd(path.join('.')) + } + } + ] +}) ``` -### Middlewares Order +## `.callable` extension -To ensure that all middlewares run after input validation and before output validation, apply the following configuration: +Import `@orpc/server/extensions/callable` from a module that always runs during initialization, such as the file where you define your base builder or create your server. This adds a `.callable` method to the decorated procedure, allowing you to call it directly like a regular function while still using it as a regular procedure. -```ts -const base = os.$config({ - initialInputValidationIndex: Number.NEGATIVE_INFINITY, - initialOutputValidationIndex: Number.NEGATIVE_INFINITY, -}) +::: code-group + +```ts [usage] +const ping = base + .input(z.object({ name: z.string(), })) + .handler(async ({ input }) => `Hello ${input.name}!`) + .callable({ + context: async () => ({}), // <- provide initial context if needed, can be async function + interceptors: [], // <- client interceptors + }) + +const router = { + ping, // <- still use it as a regular procedure +} + +const message = await ping({ name: 'World' }) // <- or call it directly +``` + +```ts [setup] +import '@orpc/server/extensions/callable' + +import { os } from '@orpc/server' + +export const base = os ``` -:::info -By default, oRPC executes middlewares based on their registration order relative to validation steps. Middlewares registered before `.input` run before input validation, and those registered after `.output` run before output validation. ::: + +## Lifecycle + +TODO: add lifecycle diagram diff --git a/apps/content/docs/comparison.md b/apps/content/docs/comparison.md deleted file mode 100644 index a07ed5272..000000000 --- a/apps/content/docs/comparison.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: Comparison -description: How is oRPC different from other RPC or REST solutions? ---- - -# Comparison - -This comparison table helps you understand how oRPC differs from other popular TypeScript RPC and REST solutions. - -- ✅ First-class, built-in support -- 🟡 Lacks features, or requires third-party integrations -- 🛑 Not supported or not documented - -| Feature | oRPC docs | oRPC | tRPC | ts-rest | Hono | -| ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ---- | ---- | ------- | ---- | -| End-to-end Typesafe Input/Output | | ✅ | ✅ | ✅ | ✅ | -| End-to-end Typesafe Errors | [1](/docs/client/error-handling), [2](/docs/error-handling#type%E2%80%90safe-error-handling) | ✅ | 🟡 | ✅ | ✅ | -| End-to-end Typesafe File/Blob | [1](/docs/file-upload-download) | ✅ | 🟡 | 🛑 | 🛑 | -| End-to-end Typesafe Streaming | [1](/docs/event-iterator) | ✅ | ✅ | 🛑 | 🛑 | -| Tanstack Query Integration (React) | [1](/docs/integrations/tanstack-query) | ✅ | ✅ | 🟡 | 🛑 | -| Tanstack Query Integration (Vue) | [1](/docs/integrations/tanstack-query) | ✅ | 🛑 | 🟡 | 🛑 | -| Tanstack Query Integration (Solid) | [1](/docs/integrations/tanstack-query) | ✅ | 🛑 | 🟡 | 🛑 | -| Tanstack Query Integration (Svelte) | [1](/docs/integrations/tanstack-query) | ✅ | 🛑 | 🛑 | 🛑 | -| Tanstack Query Integration (Angular) | [1](/docs/integrations/tanstack-query) | ✅ | 🛑 | 🛑 | 🛑 | -| Vue Pinia Colada Integration | [1](/docs/integrations/pinia-colada) | ✅ | 🛑 | 🛑 | 🛑 | -| With Contract-First Approach | [1](/docs/contract-first/define-contract) | ✅ | 🛑 | ✅ | ✅ | -| Without Contract-First Approach | | ✅ | ✅ | 🛑 | ✅ | -| OpenAPI Support | [1](/docs/openapi/openapi-handler) | ✅ | 🟡 | 🟡 | ✅ | -| OpenAPI Support for multiple schema | [1](/docs/openapi/openapi-handler) | ✅ | 🛑 | 🛑 | ✅ | -| OpenAPI Bracket Notation Support | [1](/docs/openapi/bracket-notation) | ✅ | 🛑 | 🛑 | 🛑 | -| Server Actions Support | [1](/docs/server-action) | ✅ | ✅ | 🛑 | 🛑 | -| Lazy Router | [1](/docs/router#lazy-router) | ✅ | ✅ | 🛑 | 🛑 | -| Native Types (Date, URL, Set, Maps, ...) | [1](/docs/rpc-handler#supported-data-types) | ✅ | 🟡 | 🛑 | 🛑 | -| Streaming response (SSE) | [1](/docs/event-iterator) | ✅ | ✅ | 🛑 | ✅ | -| Standard Schema (Zod, Valibot, ArkType, ...) | | ✅ | ✅ | 🛑 | 🟡 | -| Built-in Plugins (CORS, CSRF, Retry, ...) | | ✅ | 🛑 | 🛑 | ✅ | -| Batch Requests | [1](/docs/plugins/batch-requests) | ✅ | ✅ | 🛑 | 🛑 | -| WebSockets | [1](/docs/adapters/websocket) | ✅ | ✅ | 🛑 | 🛑 | -| [Cloudflare Websocket Hibernation](https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server/) | [1](/docs/plugins/hibernation) | ✅ | 🛑 | 🛑 | 🛑 | -| Nest.js integration | [1](/docs/openapi/integrations/implement-contract-in-nest) | ✅ | 🟡 | ✅ | 🛑 | -| Message Port (Electron, Browser, Workers, ...) | [1](/docs/adapters/message-port) | ✅ | 🟡 | 🛑 | 🛑 | diff --git a/apps/content/docs/context.md b/apps/content/docs/context.md index 4d78a68fa..49784c60c 100644 --- a/apps/content/docs/context.md +++ b/apps/content/docs/context.md @@ -1,156 +1,122 @@ ---- -title: Context -description: Understanding context in oRPC ---- +# Context -# Context in oRPC - -oRPC's context mechanism provides a type-safe dependency injection pattern. It lets you supply required dependencies either explicitly or dynamically through middleware. There are two types: - -- **Initial Context:** Provided explicitly when invoking a procedure. -- **Execution Context:** Generated during procedure execution, typically by middleware. +The context mechanism provides a type-safe dependency injection pattern. It lets you provide required dependencies explicitly or inject them dynamically through middleware. ## Initial Context -Initial context is used to define required dependencies (usually environment-specific) that must be passed when calling a procedure. +Use initial context for values that come from the environment. Declare it with `.$context`, then provide it when executing the procedure: ```ts twoslash import { os } from '@orpc/server' // ---cut--- -const base = os.$context<{ headers: Headers, env: { DB_URL: string } }>() +const base = os.$context<{ env: { DB_URL: string } }>() -const getting = base +export const getting = base .handler(async ({ context }) => { console.log(context.env) }) - -export const router = { getting } ``` -When calling that requires initial context, pass it explicitly: +::: info +When a procedure requires initial context when calling, you must manually pass it: ```ts twoslash -import { os } from '@orpc/server' +import { call, os } from '@orpc/server' + +const base = os.$context<{ env: { DB_URL: string } }>() +const getting = base.handler(async ({ context }) => {}) +// ---cut--- +const output = await call(getting, undefined, { + context: { // <- initial context must be passed when calling + env: { DB_URL: 'postgres://...' }, + }, +}) +``` -const base = os.$context<{ headers: Headers, env: { DB_URL: string } }>() +::: -const getting = base - .handler(async ({ context }) => { +### Default Initial Context - }) +To avoid repeating `.$context` declarations, you can define a default initial context type globally. -export const router = { getting } -// ---cut--- -import { RPCHandler } from '@orpc/server/fetch' - -const handler = new RPCHandler(router) - -export default function fetch(request: Request) { - handler.handle(request, { - context: { // <-- you must pass initial context here - headers: request.headers, - env: { - DB_URL: '***' - } - } - }) +```ts +declare module '@orpc/server' { + export interface DefaultInitialContext { + env: { DB_URL: string } + } } ``` -## Execution context +## Injected Context -Execution context is computed during the process lifecycle, usually via [middleware](/docs/middleware). It can be used independently or combined with initial context. +Injected context is injected at runtime through [middleware](/docs/middleware#middleware-context): ```ts twoslash import { os } from '@orpc/server' -// ---cut--- -import { cookies, headers } from 'next/headers' +declare const env: { DB_URL: string } +// ---cut--- const base = os.use(async ({ next }) => next({ context: { - headers: await headers(), - cookies: await cookies(), + env: { DB_URL: env.DB_URL }, }, })) -const getting = base.handler(async ({ context }) => { - context.cookies.set('key', 'value') +export const getting = base.handler(async ({ context }) => { + console.log(context.env) }) - -export const router = { getting } ``` -When using execution context, you don't need to pass any context manually: +::: info +When you use middleware context, you do not need to pass context manually when calling: ```ts twoslash -import { os } from '@orpc/server' -import { cookies, headers } from 'next/headers' +import { call, os } from '@orpc/server' + +declare const env: { DB_URL: string } const base = os.use(async ({ next }) => next({ context: { - headers: await headers(), - cookies: await cookies(), + env: { DB_URL: env.DB_URL }, }, })) -const getting = base.handler(async ({ context }) => { - context.cookies.set('key', 'value') -}) - -export const router = { getting } +const getting = base.handler(async ({ context }) => {}) // ---cut--- -import { RPCHandler } from '@orpc/server/fetch' - -const handler = new RPCHandler(router) - -export default function fetch(request: Request) { - handler.handle(request) // <-- no need to pass anything more -} +// no need to pass context manually when calling +const output = await call(getting) ``` -## Combining Initial and Execution Context +::: + +## Combining Initial and Injected Context -Often you need both static and dynamic dependencies. Use initial context for environment-specific values (e.g., database URLs) and middleware (execution context) for runtime data (e.g., user authentication). +In many cases, you will use both. Use initial context for environment-specific values, such as database URLs, and injected context for runtime data, such as authenticated users. ```ts twoslash import { ORPCError, os } from '@orpc/server' + +declare function parseJWT(token: string | undefined, secret: string): { userId: number } | null // ---cut--- -const base = os.$context<{ headers: Headers, env: { DB_URL: string } }>() +const base = os.$context<{ headers: Headers, env: { JWT_SECRET: string } }>() const requireAuth = base.middleware(async ({ context, next }) => { - const user = parseJWT(context.headers.get('authorization')?.split(' ')[1]) + const user = parseJWT( + context.headers.get('authorization')?.split(' ')[1], + context.env.JWT_SECRET + ) - if (user) { - return next({ context: { user } }) + if (!user) { + throw new ORPCError('UNAUTHORIZED') } - throw new ORPCError('UNAUTHORIZED') -}) - -const dbProvider = base.middleware(async ({ context, next }) => { - const client = new Client(context.env.DB_URL) - - try { - await client.connect() - return next({ context: { db: client } }) - } - finally { - await client.disconnect() - } + return next({ context: { user } }) }) const getting = base - .use(dbProvider) .use(requireAuth) .handler(async ({ context }) => { - console.log(context.db) + console.log(context.env) console.log(context.user) }) -// ---cut-after--- -declare function parseJWT(token: string | undefined): { userId: number } | null -declare class Client { - constructor(url: string) - connect(): Promise - disconnect(): Promise -} ``` diff --git a/apps/content/docs/contract-first/define-contract.md b/apps/content/docs/contract-first/define-contract.md deleted file mode 100644 index c01b9d827..000000000 --- a/apps/content/docs/contract-first/define-contract.md +++ /dev/null @@ -1,144 +0,0 @@ ---- -title: Define Contract -description: Learn how to define a contract for contract-first development in oRPC ---- - -# Define Contract - -**Contract-first development** is a design pattern where you define the API contract before writing any implementation code. This methodology promotes a well-structured codebase that adheres to best practices and facilitates easier maintenance and evolution over time. - -In oRPC, a **contract** specifies the rules and expectations for a procedure. It details the input, output, errors,... types and can include constraints or validations to ensure that both client and server share a clear, consistent interface. - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/contract@latest -``` - -```sh [yarn] -yarn add @orpc/contract@latest -``` - -```sh [pnpm] -pnpm add @orpc/contract@latest -``` - -```sh [bun] -bun add @orpc/contract@latest -``` - -```sh [deno] -deno add npm:@orpc/contract@latest -``` - -::: - -## Procedure Contract - -A procedure contract in oRPC is similar to a standard [procedure](/docs/procedure) definition, but with extraneous APIs removed to better support contract-first development. - -```ts twoslash -import * as z from 'zod' -// ---cut--- -import { oc } from '@orpc/contract' - -export const exampleContract = oc - .input( - z.object({ - name: z.string(), - age: z.number().int().min(0), - }), - ) - .output( - z.object({ - id: z.number().int().min(0), - name: z.string(), - age: z.number().int().min(0), - }), - ) -``` - -## Contract Router - -Similar to the standard [router](/docs/router) in oRPC, the contract router organizes your defined contracts into a structured hierarchy. The contract router is streamlined by removing APIs that are not essential for contract-first development. - -```ts -export const routerContract = { - example: exampleContract, - nested: { - example: exampleContract, - }, -} -``` - -## Full Example - -Below is a complete example demonstrating how to define a contract for a simple "Planet" service. This example extracted from our [Getting Started](/docs/getting-started) guide. - -```ts twoslash -import * as z from 'zod' -import { oc } from '@orpc/contract' -// ---cut--- -export const PlanetSchema = z.object({ - id: z.number().int().min(1), - name: z.string(), - description: z.string().optional(), -}) - -export const listPlanetContract = oc - .input( - z.object({ - limit: z.number().int().min(1).max(100).optional(), - cursor: z.number().int().min(0).default(0), - }), - ) - .output(z.array(PlanetSchema)) - -export const findPlanetContract = oc - .input(PlanetSchema.pick({ id: true })) - .output(PlanetSchema) - -export const createPlanetContract = oc - .input(PlanetSchema.omit({ id: true })) - .output(PlanetSchema) - -export const contract = { - planet: { - list: listPlanetContract, - find: findPlanetContract, - create: createPlanetContract, - }, -} -``` - -## Utilities - -### Infer Contract Router Input - -```ts twoslash -import type { contract } from './shared/planet' -// ---cut--- -import type { InferContractRouterInputs } from '@orpc/contract' - -export type Inputs = InferContractRouterInputs - -type FindPlanetInput = Inputs['planet']['find'] -``` - -This snippet automatically extracts the expected input types for each procedure in the router. - -### Infer Contract Router Output - -```ts twoslash -import type { contract } from './shared/planet' -// ---cut--- -import type { InferContractRouterOutputs } from '@orpc/contract' - -export type Outputs = InferContractRouterOutputs - -type FindPlanetOutput = Outputs['planet']['find'] -``` - -Similarly, this utility infers the output types, ensuring that your application correctly handles the results from each procedure. diff --git a/apps/content/docs/contract-first/implement-contract.md b/apps/content/docs/contract-first/implement-contract.md deleted file mode 100644 index da414aafe..000000000 --- a/apps/content/docs/contract-first/implement-contract.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -title: Implement Contract -description: Learn how to implement a contract for contract-first development in oRPC ---- - -# Implement Contract - -After defining your contract, the next step is to implement it in your server code. oRPC enforces your contract at runtime, ensuring that your API consistently adheres to its specifications. - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/server@latest -``` - -```sh [yarn] -yarn add @orpc/server@latest -``` - -```sh [pnpm] -pnpm add @orpc/server@latest -``` - -```sh [bun] -bun add @orpc/server@latest -``` - -```sh [deno] -deno add npm:@orpc/server@latest -``` - -::: - -## The Implementer - -The `implement` function converts your contract into an implementer instance. This instance compatible with the original `os` from `@orpc/server` provides a type-safe interface to define your procedures and supports features like [Middleware](/docs/middleware) and [Context](/docs/context). - -```ts twoslash -import { contract } from './shared/planet' -// ---cut--- -import { implement } from '@orpc/server' - -const os = implement(contract) // fully replaces the os from @orpc/server -``` - -## Implementing Procedures - -Define a procedure by attaching a `.handler` to its corresponding contract, ensuring it adheres to the contract's specifications. - -```ts twoslash -import { contract } from './shared/planet' -import { implement } from '@orpc/server' - -const os = implement(contract) -// ---cut--- -export const listPlanet = os.planet.list - .handler(({ input }) => { - // Your logic for listing planets - return [] - }) -``` - -## Building the Router - -To assemble your API, create a router at the root level using `.router`. This ensures that the entire router is type-checked and enforces the contract at runtime. - -```ts -const router = os.router({ // <-- Essential for full contract enforcement - planet: { - list: listPlanet, - find: findPlanet, - create: createPlanet, - }, -}) -``` - -## Full Implementation Example - -Below is a complete implementation of the contract defined in the [previous section](/docs/contract-first/define-contract). - -```ts twoslash -import { contract } from './shared/planet' -import { implement } from '@orpc/server' -// ---cut--- -const os = implement(contract) - -export const listPlanet = os.planet.list - .handler(({ input }) => { - return [] - }) - -export const findPlanet = os.planet.find - .handler(({ input }) => { - return { id: 123, name: 'Planet X' } - }) - -export const createPlanet = os.planet.create - .handler(({ input }) => { - return { id: 123, name: 'Planet X' } - }) - -export const router = os.router({ - planet: { - list: listPlanet, - find: findPlanet, - create: createPlanet, - }, -}) -``` diff --git a/apps/content/docs/contract-first/router-to-contract.md b/apps/content/docs/contract-first/router-to-contract.md deleted file mode 100644 index 994e27e4d..000000000 --- a/apps/content/docs/contract-first/router-to-contract.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: Router to Contract -description: Learn how to convert a router into a contract, safely export it, and prevent exposing internal details to the client. ---- - -# Router to Contract - -A normal [router](/docs/router) works as a contract router as long as it does not include a [lazy router](/docs/router#lazy-router). This guide not only shows you how to **unlazy** a router to make it compatible with contracts, but also how to **minify** it and **prevent internal business logic from being exposed to the client**. - -## Unlazy the Router - -If your router includes a [lazy router](/docs/router#lazy-router), you need to fully resolve it to make it compatible with contract. - -```ts -import { unlazyRouter } from '@orpc/server' - -const resolvedRouter = await unlazyRouter(router) -``` - -## Minify & Export the Contract Router for the Client - -Sometimes, you'll need to import the contract on the client - for example, to use [OpenAPILink](/docs/openapi/client/openapi-link) or define request methods in [RPCLink](/docs/client/rpc-link#custom-request-method). - -If you're using [Contract First](/docs/contract-first/define-contract), this is safe: your contract is already lightweight and free of business logic. - -However, if you're deriving the contract from a [router](/docs/router), importing it directly can be heavy and may leak internal logic. To prevent this, follow the steps below to safely minify and export your contract. - -1. **Minify the Contract Router and Export to JSON** - - ```ts - import fs from 'node:fs' - import { minifyContractRouter } from '@orpc/contract' - - const minifiedRouter = minifyContractRouter(router) - - fs.writeFileSync('./contract.json', JSON.stringify(minifiedRouter)) - ``` - - ::: warning - `minifyContractRouter` preserves only the metadata and routing information necessary for the client, all other data will be stripped out. - ::: - -2. **Import the Contract JSON on the Client Side** - - ```ts - import contract from './contract.json' // [!code highlight] - - const link = new OpenAPILink(contract as typeof router, { - url: 'http://localhost:3000/api', - }) - ``` - - ::: warning - Cast `contract` to `typeof router` to ensure type safety, since standard schema types cannot be serialized to JSON so we must manually cast them. - ::: diff --git a/apps/content/docs/contract/implementation.md b/apps/content/docs/contract/implementation.md new file mode 100644 index 000000000..0bb8341c2 --- /dev/null +++ b/apps/content/docs/contract/implementation.md @@ -0,0 +1,140 @@ +# Contract Implementation + +Implementing a contract means adding business logic to each procedure defined in that contract. It ensures every implementation stays consistent by verifying that each handler matches the procedure's expected shape. + +## Implementer + +The `implement` function turns a contract into an implementer. Use it to build procedures, routers, and create middleware with full type safety. + +```ts twoslash +import { contract } from './shared/planet' +// ---cut--- +import { implement } from '@orpc/server' + +const implementer = implement(contract) + .$context<{ something?: string }>() // <- define initial context + +implementer.planet.list +// ^| + +// + +// + +// +// +``` + +### Initial Context + +Use `.$context` to declare the initial context required for a procedure to execute. +Learn more in the [Context Documentation](/docs/context). + +## Implementing Procedures + +Define a `.handler` for a procedure contract to provide its business logic. + +```ts twoslash +import { contract } from './shared/planet' +import { implement } from '@orpc/server' + +const implementer = implement(contract) +const requireAuth = implementer.middleware(({ next }) => next()) +// ---cut--- +const listPlanet = implementer.planet.list + .use(requireAuth) // <- Apply authentication middleware + .handler(({ input }) => { + // Your logic for listing planets + return [] + }) +``` + +::: info +If middleware needs to wrap validation, apply it at the router level instead. In this example, use `implementer.use` to apply it globally or `implementer.planet.use` to apply it to the `planet` router before `.list`. + +```ts +const listPlanet = implementer + .planet + .use(requireAuth) // <- middleware wraps validation + .list + .handler(({ input }) => { + // Your logic for listing planets + return [] + }) +``` + +::: + +## Implementing Routers + +Create the root router with `.router` to assemble your API. This enables full type-checking and runtime contract enforcement. + +```ts +const router = implementer.router({ + planet: { + list: listPlanet, + find: findPlanet, + create: createPlanet, + }, +}) +``` + +### Extending Router + +Like a normal [router](/docs/router), an implementer router can also be extended with shared behavior. For example, you can apply authentication middleware to every procedure: + +```ts +const router = implementer.use(requireAuth).router({ + planet: { + list: listPlanet, + find: findPlanet, + create: createPlanet, + }, +}) +``` + +::: danger +If you apply middleware with `.use` at both the router and procedure levels, it may run more than once. That duplication can hurt performance. To avoid redundant middleware execution, see our [best practices for middleware deduplication](/docs/best-practices/dedupe-middleware). +::: + +## Creating Middleware + +The implementer can also create [middleware](/docs/middleware). Middleware created this way can infer the contract's [typesafe errors](/docs/error-handling#typesafe-errors). If not all contracts define the same errors, use the `in` operator to check that an error exists before using it. + +```ts +const ratelimit = implementer.middleware(async ({ next, errors }) => { + if ('TOO_MANY_REQUESTS' in errors) { + // Apply rate limiting only when TOO_MANY_REQUESTS is defined by the contract. + if (isRatelimitReached) { + throw errors.TOO_MANY_REQUESTS() + } + } + + return next() +}) +``` + +::: info +You do not have to create middleware from the implementer. Any type-compatible middleware can be used. +::: + +## Reusability + +Each implementer call creates a new instance, which avoids reference issues and makes contracts easy to reuse and extend. + +```ts +const pub = implementer // Base setup for procedures that publish +const authed = implementer.use(requireAuth) // Extends 'pub' with authentication + +const listPlanets = pub.planet.list.handler(({ input }) => { + // Your logic for listing planets without authentication + return [] +}) + +const createPlanet = authed.planet.create.handler(({ input }) => { + // Your logic for creating planets with authentication + return { } +}) +``` + +This pattern helps prevent duplication while maintaining flexibility. diff --git a/apps/content/docs/contract/procedure.md b/apps/content/docs/contract/procedure.md new file mode 100644 index 000000000..7a9b0a06b --- /dev/null +++ b/apps/content/docs/contract/procedure.md @@ -0,0 +1,85 @@ +# Procedure Contract + +Procedure contracts define the expected shape of a [procedure](/docs/procedure) without including any business logic. They are useful for documentation, testing, and keeping multiple implementations of the same procedure aligned. + +## Overview + +```ts twoslash +import { z } from 'zod' +import type { AnyMetaPlugin } from '@orpc/contract' + +declare const someMeta: AnyMetaPlugin +// ---cut--- +import { oc } from '@orpc/contract' + +const example = oc + .meta(someMeta) // <- attach metadata + .errors({ NOT_FOUND: {} }) // <- define errors + .input(z.object({ id: z.number(), name: z.string() })) // <- input validation + .output(z.object({ id: z.number(), name: z.string() })) // <- output validation +``` + +:::info +All of these chains are optional. You can create an empty contract with just `oc`. +::: + +## Metadata + +Use `.meta` to attach metadata to a contract. Middleware and plugins can read it later when you implement the contract. Learn more in the [Metadata documentation](/docs/metadata). + +## Typesafe Errors + +Use `.errors` to define the errors a contract can produce. These errors can be thrown from handlers or middleware when you implement the contract and remain properly typed on the client. Learn more in the [Typesafe Error Handling documentation](/docs/error-handling#typesafe-errors). + +## Input/Output Validation + +oRPC supports [Zod](https://zod.dev/), [Valibot](https://valibot.dev/), [Arktype](https://arktype.io/), and any other [Standard Schema](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec) library for validation. + +::: info +Unlike a [procedure](/docs/procedure), a contract has no `.handler` chain. If you want the client to infer the output type correctly, define `.output`. Otherwise, the output type will be `unknown`. +::: + +### Multiple Schemas + +`.input` and `.output` can be called multiple times. Each call adds another schema instead of replacing an earlier one. + +```ts +const example = oc + .input(z.looseObject({ name: z.string() })) + .input(z.looseObject({ id: z.number() })) + .output(z.looseObject({ name: z.string() })) + .output(z.looseObject({ id: z.number() })) +``` + +::: warning +When you stack schemas, the input or output must satisfy all of them, so the schemas need to be compatible. For example, with Zod, prefer `z.looseObject` over `z.object` to allow unknown properties. +::: + +### `type` Utility + +For simple use cases without external libraries, use oRPC's built-in `type` utility. It takes a mapping function as its first argument: + +```ts +import { type } from '@orpc/contract' + +const example = oc + .input(type<{ value: number }>()) + .output(type<{ value: number }, number>(({ value }) => value)) +``` + +## Reusability + +Each builder call creates a new instance, which avoids reference issues and makes contracts easy to reuse and extend. + +```ts +const pub = oc // Base setup for procedures that publish +const authed = pub.meta(requireAuthMeta) // Extends 'pub' with authentication + +const pubExample = pub + .input(z.object({ name: z.string() })) + +const authedExample = authed + .input(z.object({ id: z.number() })) +``` + +This pattern helps prevent duplication while maintaining flexibility. diff --git a/apps/content/docs/contract/router.md b/apps/content/docs/contract/router.md new file mode 100644 index 000000000..474248532 --- /dev/null +++ b/apps/content/docs/contract/router.md @@ -0,0 +1,158 @@ +# Router Contract + +Router contracts define the shape of a [router](/docs/router) without including any business logic. Use them for documentation, testing, and keeping multiple implementations of the same router aligned. + +::: info +A standalone [procedure contract](/docs/contract/procedure) is also a router contract, so you can use the same features with individual procedure contracts. +::: + +## Overview + +Define a router contract as a plain JavaScript object where each key maps to a procedure contract: + +```ts twoslash +import { z } from 'zod' +// ---cut--- +import { oc } from '@orpc/contract' + +const ping = oc.output(z.string()) +const pong = oc.output(z.string()) + +export const router = { + ping, + pong, + nested: { ping, pong } +} +``` + + + +## Extending Router + +You can extend a router contract with shared configuration, such as attaching metadata to every procedure: + +```ts +const router = oc.meta(requireAuthMeta).router({ + ping, + pong, + nested: { + ping, + pong, + } +}) +``` + +## Router to Contract + +A normal [router](/docs/router) can be used as a contract router as long as it does not include a [lazy router](/docs/router#lazy-router). If necessary, use `unlazyRouter` to fully resolve it and make it contract-compatible. + +```ts +import { unlazyRouter } from '@orpc/server' + +const compatibleContract = await unlazyRouter(router) +``` + +### Safely Importing Router on the Client + +Sometimes you need to import the contract on the client, for example when using [OpenAPI Link](/docs/openapi/link). If you derive the contract from a [router](/docs/router), importing it directly can be heavy and may expose internal logic. To avoid this, follow the steps below to safely minify and export the contract. + +1. **Minify the Contract Router and Export to JSON** + + ```ts + import fs from 'node:fs' + import { unlazyRouter } from '@orpc/server' + import { minifyRouterContract } from '@orpc/contract' + + const compatibleContract = await unlazyRouter(router) + const minifiedRouter = minifyRouterContract(compatibleContract) + + fs.writeFileSync('./contract.json', JSON.stringify(minifiedRouter)) + ``` + + ::: info + `minifyRouterContract` preserves only the metadata needed by the client; all other data is stripped out. + ::: + +2. **Import the Contract JSON on the Client Side** + + ```ts + import contract from './contract.json' // [!code highlight] + + const link = new OpenAPILink(contract as typeof router) + ``` + + ::: info + Cast `contract` to `typeof router` to preserve type safety, since standard schema types cannot be serialized to JSON and must be cast manually. + ::: + +## Utilities + +::: info +A standalone [procedure contract](/docs/contract/procedure) is also a router contract, so these utilities work with individual procedure contracts too. +::: + +### Infer Router Contract Inputs + +Infers the input type of each procedure contract in a router contract. + +```ts twoslash +import type { contract } from './shared/planet' +// ---cut--- +import type { InferRouterContractInputs } from '@orpc/contract' + +export type Inputs = InferRouterContractInputs + +type FindPlanetInput = Inputs['planet']['find'] +``` + +### Infer Router Contract Outputs + +Infers the output type of each procedure contract in a router contract. + +```ts twoslash +import type { contract } from './shared/planet' +// ---cut--- +import type { InferRouterContractOutputs } from '@orpc/contract' + +export type Outputs = InferRouterContractOutputs + +type FindPlanetOutput = Outputs['planet']['find'] +``` + +### Infer Router Contract Error Map + +Collects the error maps from every procedure contract in a router contract into a single type. + +```ts twoslash +import type { contract } from './shared/planet' +// ---cut--- +import type { InferRouterContractErrorMap } from '@orpc/contract' + +export type ErrorMap = InferRouterContractErrorMap +``` + +### Infer Router Contract Errors + +Infers the throwable errors each procedure contract in a router contract can describe. + +```ts twoslash +import type { contract } from './shared/planet' +// ---cut--- +import type { InferRouterContractErrors } from '@orpc/contract' + +export type Errors = InferRouterContractErrors + +type FindPlanetError = Errors['planet']['find'] +``` + +### Infer Router Contract Error + +Infers all possible throwable errors the entire router contract can describe. This is useful when you want a single type for contract-wide error handling. + +```ts twoslash +import type { contract } from './shared/planet' +// ---cut--- +import type { InferRouterContractError } from '@orpc/contract' + +export type ContractError = InferRouterContractError +``` diff --git a/apps/content/docs/ecosystem.md b/apps/content/docs/ecosystem.md deleted file mode 100644 index dd0924685..000000000 --- a/apps/content/docs/ecosystem.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: Ecosystem -description: oRPC ecosystem & community resources ---- - -# Ecosystem - -:::info -If your project is missing here, please [open a PR](https://github.com/middleapi/orpc/edit/main/apps/content/docs/ecosystem.md) to add it. -::: - -## Starter Kits - -| Name | Stars | Description | -| --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Zap.ts](https://github.com/zap-studio/monorepo) | [![Stars](https://img.shields.io/github/stars/zap-studio/monorepo?style=flat)](https://github.com/zap-studio/monorepo) | Next.js boilerplate designed to help you build applications faster using a modern set of tools. | -| [Better-T-Stack](https://github.com/AmanVarshney01/create-better-t-stack) | [![Stars](https://img.shields.io/github/stars/AmanVarshney01/create-better-t-stack?style=flat)](https://github.com/AmanVarshney01/create-better-t-stack) | A modern CLI tool for scaffolding end-to-end type-safe TypeScript projects with best practices and customizable configurations | -| [Vazen](https://github.com/stack-found/vazen) | [![Stars](https://img.shields.io/github/stars/stack-found/vazen?style=flat)](https://github.com/stack-found/vazen) | A production-grade monorepo starter for building full-stack TypeScript apps with scalable architecture and developer-first tooling. | -| [create-start-app](https://github.com/AmanVarshney01/create-better-t-stack) | [![Stars](https://img.shields.io/github/stars/TanStack/create-tsrouter-app?style=flat)](https://github.com/TanStack/create-tsrouter-app) | Quickly scaffold a new React project with TanStack Router and oRPC | -| [create-o3-app](https://github.com/Tony-ArtZ/create-o3-app) | [![Stars](https://img.shields.io/github/stars/Tony-ArtZ/create-o3-app?style=flat)](https://github.com/Tony-ArtZ/create-o3-app) | The O3 Stack is a "bleeding-edge" full-stack TypeScript framework that uses experimental technologies like oRPC, Drizzle ORM, and ArkType, positioning itself as T3 Stack's more adventurous sibling that prioritizes newest tools over stability. | -| [RT Stack](https://github.com/nktnet1/rt-stack) | [![Stars](https://img.shields.io/github/stars/nktnet1/rt-stack?style=flat)](https://github.com/nktnet1/rt-stack) | Lightweight fullstack turborepo with modular components, shared configs, containerized deployments and 100% type-safety. Features React + Vite, TanStack Router, oRPC + Valibot, Better Auth, and Drizzle ORM. | -| [Start UI](https://github.com/BearStudio/start-ui-web) | [![Stars](https://img.shields.io/github/stars/BearStudio/start-ui-web?style=flat)](https://github.com/BearStudio/start-ui-web) | 🚀 Start UI [web] is an opinionated UI starter from the 🐻 Beastudio Team with ⚙️ Node.js, 🟦 TypeScript, ⚛️ React, 📦 TanStack Start, 💨 Tailwind CSS, 🧩 shadcn/ui, 📋 React Hook Form, 🔌 oRPC, 🛠 Prisma, 🔐 Better Auth, 📚 Storybook, 🧪 Vitest, 🎭 Playwright | -| [ShipFullStack](https://github.com/sunshineLixun/ShipFullStack) | [![Stars](https://img.shields.io/github/stars/sunshineLixun/ShipFullStack?style=flat)](https://github.com/sunshineLixun/ShipFullStack) | A modern TypeScript stack that combines React, TanStack Start, Hono, ORPC, Expo, and more. | -| [WXT Starter](https://github.com/mefengl/wxt-starter) | [![Stars](https://img.shields.io/github/stars/mefengl/wxt-starter?style=flat)](https://github.com/mefengl/wxt-starter) | Maybe the best template based on wxt. | -| [Start Kit](https://github.com/CarlosZiegler/start-kit.dev) | [![Stars](https://img.shields.io/github/stars/CarlosZiegler/start-kit.dev?style=flat)](https://github.com/CarlosZiegler/start-kit.dev) | The production-ready SaaS starter kit for the modern TypeScript stack. | -| [tsu!stack](https://github.com/tsu-moe/tsu-stack) | [![Stars](https://img.shields.io/github/stars/tsu-moe/tsu-stack?style=flat)](https://github.com/tsu-moe/tsu-stack) | Batteries-included TanStack Start Vite+ Monorepo Template with Hono + oRPC + Drizzle ORM + Better Auth. 🐋 Dockerized and ⛅ Cloudflare Workers ready. shadcn/ui, Paraglide-js (i18n), Feature-Sliced Design, evlog, @t3-oss/env, AGENTS.md, and many more features -- out of the box! | - -## Tools - -| Name | Stars | Description | -| ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| [orpc-file-based-router](https://github.com/zeeeeby/orpc-file-based-router) | [![Stars](https://img.shields.io/github/stars/zeeeeby/orpc-file-based-router?style=flat)](https://github.com/zeeeeby/orpc-file-based-router) | Automatically creates an oRPC router configuration based on your file structure, similar to Next.js, express-file-routing | -| [Vertrag](https://github.com/Quatton/vertrag) | [![Stars](https://img.shields.io/github/stars/Quatton/vertrag?style=flat)](https://github.com/Quatton/vertrag) | A spec-first API development tool (oRPC contract + any backend language) | -| [Prisma oRPC Generator](https://github.com/omar-dulaimi/prisma-orpc-generator) | [![Stars](https://img.shields.io/github/stars/omar-dulaimi/prisma-orpc-generator?style=flat)](https://github.com/omar-dulaimi/prisma-orpc-generator) | Prisma generator that creates fully-featured ORPC routers | -| [DRZL](https://github.com/use-drzl/drzl) | [![Stars](https://img.shields.io/github/stars/use-drzl/drzl?style=flat)](https://github.com/use-drzl/drzl) | Zero‑friction codegen for Drizzle ORM. Analyze your schema. Generate validation, services, and routers — fast. | -| [orpc-msw](https://github.com/DanSnow/orpc-msw) | [![Stars](https://img.shields.io/github/stars/DanSnow/orpc-msw?style=flat)](https://github.com/DanSnow/orpc-msw) | A utility library for type-safe mocking of OpenAPI-based oRPC contracts using Mock Service Worker (MSW) for robust testing and development. | - -## Libraries - -| Name | Stars | Description | -| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Permix](https://permix.letstri.dev/) | [![Stars](https://img.shields.io/github/stars/letstri/permix?style=flat)](https://github.com/letstri/permix) | lightweight, framework-agnostic, type-safe permissions management library | -| [trpc-cli](https://github.com/mmkal/trpc-cli?tab=readme-ov-file#orpc) | [![Stars](https://img.shields.io/github/stars/mmkal/trpc-cli?style=flat)](https://github.com/mmkal/trpc-cli) | Turn a oRPC router into a type-safe, fully-functional, documented CLI | -| [@reliverse/rempts](https://github.com/reliverse/rempts) | [![Stars](https://img.shields.io/github/stars/reliverse/rempts?style=flat)](https://github.com/reliverse/rempts) | 🐦‍🔥 a modern, type-safe toolkit for building delightful cli experiences. it's fast, flexible, and made for developer happiness. file-based commands keep things simple—no clutter, just clean and easy workflows. this is how cli should feel. | -| [oRPC Shield](https://github.com/omar-dulaimi/orpc-shield) | [![Stars](https://img.shields.io/github/stars/omar-dulaimi/orpc-shield?style=flat)](https://github.com/omar-dulaimi/orpc-shield) | Type-safe authorization for modern oRPC apps — lightweight, composable, fast. | -| [Every Plugin](https://github.com/near-everything/every-plugin) | [![Stars](https://img.shields.io/github/stars/near-everything/every-plugin?style=flat)](https://github.com/near-everything/every-plugin) | A composable plugin runtime for loading, initializing, and executing remote plugins | -| [orpc-json-diff](https://github.com/NeilTheFisher/orpc-json-diff) | [![Stars](https://img.shields.io/github/stars/NeilTheFisher/orpc-json-diff?style=flat)](https://github.com/NeilTheFisher/orpc-json-diff) | Apply json patches for oRPC event iterators to save bandwidth | -| [effect-orpc](https://github.com/utopyin/effect-orpc) | [![Stars](https://img.shields.io/github/stars/utopyin/effect-orpc?style=flat)](https://github.com/utopyin/effect-orpc) | Effect-TS integration for oRPC | diff --git a/apps/content/docs/error-handling.md b/apps/content/docs/error-handling.md index cdf382fc3..2b056eef0 100644 --- a/apps/content/docs/error-handling.md +++ b/apps/content/docs/error-handling.md @@ -1,128 +1,175 @@ ---- -title: Error Handling -description: Manage errors in oRPC using both traditional and type‑safe strategies. ---- +# Error Handling -# Error Handling in oRPC +Error handling in oRPC is flexible and consistent. You can use the `ORPCError` class, define typesafe errors, and adapt custom error classes while still returning meaningful feedback to clients. -oRPC offers a robust error handling system. You can either throw standard JavaScript errors or, preferably, use the specialized `ORPCError` class to utilize oRPC features. +## `ORPCError` Class -There are two primary approaches: +`ORPCError` is the standard error type in oRPC. It includes a `code`, plus optional `message` and `data` fields. -- **Normal Approach:** Throw errors directly (using `ORPCError` is recommended for clarity). -- **Type‑Safe Approach:** Predefine error types so that clients can infer and handle errors in a type‑safe manner. - -:::warning -The `ORPCError.data` property is sent to the client. Avoid including sensitive information. +::: danger +`message` and `data` are sent to the client. Do not include sensitive information in either field. ::: -## Normal Approach - -In the traditional approach you may throw any JavaScript error. However, using the `ORPCError` class improves consistency and ensures that error codes and optional data are handled appropriately. - -**Key Points:** - -- The first argument is the error code. -- You may optionally include a message, additional error data, or any standard error options. +```ts twoslash +declare const notFound: boolean +// ---cut--- +import { ORPCError, os } from '@orpc/server' -```ts -const rateLimit = os.middleware(async ({ next }) => { +const rateLimitMiddleware = os.middleware(async ({ next }) => { throw new ORPCError('RATE_LIMITED', { message: 'You are being rate limited', data: { retryAfter: 60 } }) + return next() }) const example = os - .use(rateLimit) + .use(rateLimitMiddleware) .handler(async ({ input }) => { - throw new ORPCError('NOT_FOUND') - throw new Error('Something went wrong') // <-- will be converted to INTERNAL_SERVER_ERROR + if (notFound) { + throw new ORPCError('NOT_FOUND') + } }) ``` -::: danger -Do not pass sensitive data in the `ORPCError.data` field. -::: +## Typesafe Errors -## Type‑Safe Error Handling +For end-to-end type safety, define your errors with `.errors` or [return `ORPCError`](#returning-an-orpcerror). This lets the client infer each error's shape and handle it safely. You can use any [Standard Schema](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec) library to validate error data. -For a fully type‑safe error management experience, define your error types using the `.errors` method. This lets the client infer the error's structure and handle it accordingly. You can use any [Standard Schema](https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec) library to validate error data. +::: danger +`message` and `data` are sent to the client. Do not include sensitive information in either field. +::: ```ts twoslash import { os } from '@orpc/server' import * as z from 'zod' + +declare const notFound: boolean // ---cut--- -const base = os.errors({ // <-- common errors - RATE_LIMITED: { - data: z.object({ - retryAfter: z.number(), - }), - }, - UNAUTHORIZED: {}, -}) +const rateLimitMiddleware = os + .errors({ + RATE_LIMITED: { + data: z.object({ + retryAfter: z.number(), + }), + }, + }) + .middleware(async ({ next, errors }) => { + throw errors.RATE_LIMITED({ + message: 'You are being rate limited', + data: { retryAfter: 60 } + }) -const rateLimit = base.middleware(async ({ next, errors }) => { - throw errors.RATE_LIMITED({ - message: 'You are being rate limited', - data: { retryAfter: 60 } + return next() }) - return next() -}) -const example = base - .use(rateLimit) +const exampleProcedure = os + .use(rateLimitMiddleware) .errors({ NOT_FOUND: { - message: 'The resource was not found', // <-- default message + message: 'The resource was not found', // <- default message }, }) .handler(async ({ input, errors }) => { - throw errors.NOT_FOUND() + if (notFound) { + throw errors.NOT_FOUND() + } }) ``` -:::danger -Again, avoid including any sensitive data in the error data since it will be exposed to the client. +::: tip +You can use typesafe errors across your entire project, but we recommend reserving them for application-specific cases. For common errors like `UNAUTHORIZED` or `RATE_LIMITED`, the client usually already understands the meaning. Skipping explicit schemas for those errors can also reduce type complexity and improve TypeScript performance. ::: -Learn more about [Client Error Handling](/docs/client/error-handling). - -## Combining Both Approaches +### ORPCError Compatibility -You can combine both strategies seamlessly. When you throw an `ORPCError` instance, if the `code`, `status` and `data` match with the errors defined in the `.errors` method, oRPC will treat it exactly as if you had thrown `errors.[code]` using the type‑safe approach. +If you cannot access the `errors` object, for example in a utility function or another module, you can still throw `ORPCError`. oRPC will try to convert it to the matching typesafe error when its `code` and `data` match a defined error. If no match is found, it is treated as an unknown error. ```ts -const base = os.errors({ // <-- common errors - RATE_LIMITED: { - data: z.object({ - retryAfter: z.number().int().min(1).default(1), - }), - }, - UNAUTHORIZED: {}, -}) - -const rateLimit = base.middleware(async ({ next, errors }) => { - throw errors.RATE_LIMITED({ - message: 'You are being rate limited', - data: { retryAfter: 60 } +const exampleProcedure = os + .errors({ + NOT_FOUND: { + message: 'The resource was not found', + }, }) - // OR --- both are equivalent - throw new ORPCError('RATE_LIMITED', { - message: 'You are being rate limited', - data: { retryAfter: 60 } + .handler(async ({ errors }) => { + throw errors.NOT_FOUND() + + // Treated as errors.NOT_FOUND because the code and data match + throw new ORPCError('NOT_FOUND') + + // Treated as an unknown error because it does not match any defined error + throw new ORPCError('BAD_REQUEST') }) - return next() -}) +``` -const example = base - .use(rateLimit) - .handler(async ({ input }) => { - throw new ORPCError('BAD_REQUEST') // <-- unknown error +### Returning an `ORPCError` + +As an alternative to `.errors`, you can return an `ORPCError` directly from your handler or middleware to achieve end-to-end type safety. + +::: warning +When [implementing a contract](/docs/contract/implementation), returning an `ORPCError` is equivalent to throwing one. +::: + +```ts +const exampleProcedure = os + .handler(async ({ errors }) => { + if (reachRateLimit) { + return new ORPCError('RATE_LIMITED', { + message: 'You are being rate limited', + data: { retryAfter: 60 } + }) + } + + return 'Success' }) ``` -:::danger -Remember: Since `ORPCError.data` is transmitted to the client, do not include any sensitive information. +::: danger +`message` and `data` are sent to the client. Do not include sensitive information in either field. +::: + +## ORPC Error Codes + +By default, oRPC allows any string as an error code and suggests common HTTP codes like `NOT_FOUND` and `UNAUTHORIZED`. You can override this with your own set of allowed error codes for better type safety and consistency. + +```ts +declare module '@orpc/server' { // or '@orpc/client' + interface Registry { + ORPCErrorCode: 'NOT_FOUND' | 'UNAUTHORIZED' | 'RATE_LIMITED' | 'MY_CUSTOM_ERROR' | (string & {}) + } +} +``` + +With this configuration, only `NOT_FOUND`, `UNAUTHORIZED`, `RATE_LIMITED`, and `MY_CUSTOM_ERROR` will be suggested as error codes. The `(string & {})` fallback ensures you can still use any string value when needed. + +## Using Custom Error Classes + +You do not have to use `ORPCError` directly in your business logic. You can throw your own error classes and convert them to `ORPCError` in middleware or interceptors. + +::: info +By default, oRPC can convert non-`ORPCError` into an `ORPCError` with code `INTERNAL_SERVER_ERROR`, or leave them unchanged depending on the client you are using. ::: + +```ts +class MyCustomError extends Error { +} + +const customErrorConverterMiddleware = os.middleware(async ({ next }) => { + try { + return await next() + } + catch (err) { + if (err instanceof MyCustomError) { + throw new ORPCError('MY_CUSTOM_ERROR', { message: err.message, cause: err }) + } + + throw err + } +}) +``` + +## Client Error Handling + +To learn how to handle errors on the client side, see the [Client Error Handling documentation](/docs/client/error-handling). diff --git a/apps/content/docs/event-iterator.md b/apps/content/docs/event-iterator.md index 3a01e4687..a83635ca0 100644 --- a/apps/content/docs/event-iterator.md +++ b/apps/content/docs/event-iterator.md @@ -1,39 +1,38 @@ ---- -title: Event Iterator (SSE) -description: Learn how to streaming responses, real-time updates, and server-sent events using oRPC. ---- - # Event Iterator (SSE) -oRPC provides built‑in support for streaming responses, real‑time updates, and server-sent events (SSE) without any extra configuration. This functionality is ideal for applications that require live updates, such as AI chat responses, live sports scores, or stock market data. +Event Iterator enables **typesafe**, **realtime data streaming**. It is the recommended approach for building features like live notifications, chat messages, progress updates, and data feeds. ## Overview -The event iterator is defined by an asynchronous generator function. In the example below, the handler continuously yields a new event every second: +An event iterator is implemented as an [asynchronous generator function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function*) (or a compatible implementation). In the example below, the handler emits a new event every second: ```ts const example = os - .handler(async function* ({ input, lastEventId }) { + .handler(async function* ({ input, signal, lastEventId }) { while (true) { + signal?.throwIfAborted() yield { message: 'Hello, world!' } await new Promise(resolve => setTimeout(resolve, 1000)) } }) ``` -Learn how to consume the event iterator on the client [here](/docs/client/event-iterator) +::: info +Learn how to consume event iterators from the client in the [client guide](/docs/client/event-iterator). +::: -## Validate Event Iterator +## Validating Events -oRPC includes a built‑in `eventIterator` helper that works with any [Standard Schema](https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec) library to validate events. +Use the built‑in `eventIterator` helper that works with any [Standard Schema](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec) library to validate events. ```ts import { eventIterator } from '@orpc/server' const example = os .output(eventIterator(z.object({ message: z.string() }))) - .handler(async function* ({ input, lastEventId }) { + .handler(async function* ({ input, signal, lastEventId }) { while (true) { + signal?.throwIfAborted() yield { message: 'Hello, world!' } await new Promise(resolve => setTimeout(resolve, 1000)) } @@ -42,23 +41,27 @@ const example = os ## Last Event ID & Event Metadata -Using the `withEventMeta` helper, you can attach [additional event meta](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format) (such as an event ID or a retry interval) to each event. +Using the `withEventMeta` helper, you can attach [additional event metadata](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format) (such as an event ID or retry interval) to each event. When the client reconnects properly, the last received event ID is sent back to the server in `lastEventId`, allowing the stream to resume from where it left off. ::: info -When used with [Client Retry Plugin](/docs/plugins/client-retry) or [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource), the client will reconnect with the last event ID. This value is made available to your handler as `lastEventId`, allowing you to resume the stream seamlessly. +When used with the [Retry Plugin](/docs/plugins/retry) or [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource), reconnection with the last event ID is handled automatically. ::: ```ts import { withEventMeta } from '@orpc/server' const example = os - .handler(async function* ({ input, lastEventId }) { + .handler(async function* ({ input, signal, lastEventId }) { if (lastEventId) { // Resume streaming from lastEventId } else { while (true) { - yield withEventMeta({ message: 'Hello, world!' }, { id: 'some-id', retry: 10_000 }) + signal?.throwIfAborted() + yield withEventMeta( + { message: 'Hello, world!' }, + { id: 'some-id', retry: 10_000 } + ) await new Promise(resolve => setTimeout(resolve, 1000)) } } @@ -67,16 +70,18 @@ const example = os ## Stop Event Iterator -To signal the end of the stream, simply use a `return` statement. When the handler returns, oRPC marks the stream as successfully completed. +To end the stream, use either a `return` or `throw` statement. oRPC marks the stream as completed when the handler returns. :::warning -This behavior is exclusive to oRPC. Standard [SSE](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) clients, such as those using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) will automatically reconnect when the connection closes. +This behavior is specific to oRPC. Standard [SSE](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) clients, such as [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource), do not recognize this completion signal and will automatically attempt to reconnect. For details, see the [Standard Server documentation](https://github.com/middleapi/standardserver#event-stream-body). ::: ```ts const example = os - .handler(async function* ({ input, lastEventId }) { + .handler(async function* ({ input, signal, lastEventId }) { while (true) { + signal?.throwIfAborted() + if (done) { return } @@ -87,15 +92,16 @@ const example = os }) ``` -## Cleanup Side-Effects +## Signal and Side-Effects -If the client closes the connection or an unexpected error occurs, you can use a `finally` block to clean up any side effects (for example, closing database connections or stopping background tasks): +When the client closes the connection or an unexpected error occurs, oRPC aborts the provided `signal`. Use it to exit loops and avoid resource leaks. Put cleanup logic in a `finally` block so it runs whether the stream ends normally, errors, or is cancelled. ```ts const example = os - .handler(async function* ({ input, lastEventId }) { + .handler(async function* ({ input, signal, lastEventId }) { try { while (true) { + signal?.throwIfAborted() yield { message: 'Hello, world!' } await new Promise(resolve => setTimeout(resolve, 1000)) } @@ -118,8 +124,8 @@ const publisher = new MemoryPublisher<{ }>() const live = os - .handler(async function* ({ input, signal }) { - const iterator = publisher.subscribe('something-updated', { signal }) + .handler(async function* ({ input, signal, lastEventId }) { + const iterator = publisher.subscribe('something-updated', { signal, lastEventId }) for await (const payload of iterator) { // Handle payload here or yield directly to client yield payload @@ -132,54 +138,3 @@ const publish = os await publisher.publish('something-updated', { id: input.id }) }) ``` - -## Event Publisher - -Unlike the [Publisher Helper](/docs/helpers/publisher), the `EventPublisher` is more lightweight with synchronous publishing and no resume support. - -::: code-group - -```ts [Static Events] -import { EventPublisher } from '@orpc/server' - -const publisher = new EventPublisher<{ - 'something-updated': { - id: string - } -}>() - -const livePlanet = os - .handler(async function* ({ input, signal }) { - for await (const payload of publisher.subscribe('something-updated', { signal })) { // [!code highlight] - // handle payload here and yield something to client - } - }) - -const update = os - .input(z.object({ id: z.string() })) - .handler(({ input }) => { - publisher.publish('something-updated', { id: input.id }) // [!code highlight] - }) -``` - -```ts [Dynamic Events] -import { EventPublisher } from '@orpc/server' - -const publisher = new EventPublisher>() - -const onMessage = os - .input(z.object({ channel: z.string() })) - .handler(async function* ({ input, signal }) { - for await (const payload of publisher.subscribe(input.channel, { signal })) { // [!code highlight] - yield payload.message - } - }) - -const sendMessage = os - .input(z.object({ channel: z.string(), message: z.string() })) - .handler(({ input }) => { - publisher.publish(input.channel, { message: input.message }) // [!code highlight] - }) -``` - -::: diff --git a/apps/content/docs/examples/openai-streaming.md b/apps/content/docs/examples/openai-streaming.md deleted file mode 100644 index 57d2b575d..000000000 --- a/apps/content/docs/examples/openai-streaming.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: OpenAI Streaming Example -description: Combine oRPC with the OpenAI Streaming API to build a chatbot ---- - -# OpenAI Streaming Example - -This example shows how to integrate oRPC with the OpenAI Streaming API to build a chatbot. - -## Basic Example - -```ts twoslash -import { createORPCClient } from '@orpc/client' -import { RPCLink } from '@orpc/client/fetch' -import { os, RouterClient } from '@orpc/server' -import * as z from 'zod' -// ---cut--- -import OpenAI from 'openai' - -const openai = new OpenAI() - -const complete = os - .input(z.object({ content: z.string() })) - .handler(async function* ({ input }) { - const stream = await openai.chat.completions.create({ - model: 'gpt-4o', - messages: [{ role: 'user', content: input.content }], - stream: true, - }) - - yield* stream - }) - -const router = { complete } - -// --------------- CLIENT --------------- - -const link = new RPCLink({ - url: 'https://example.com/rpc', -}) - -const client: RouterClient = createORPCClient(link) - -const stream = await client.complete({ content: 'Hello, world!' }) - -for await (const chunk of stream) { - console.log(chunk.choices[0]?.delta?.content || '') -} -``` - -::: info -Learn more about [RPCLink](/docs/client/rpc-link) and [Event Iterator](/docs/client/event-iterator). -::: diff --git a/apps/content/docs/file-upload-download.md b/apps/content/docs/file-upload-download.md deleted file mode 100644 index dc3b3ecf8..000000000 --- a/apps/content/docs/file-upload-download.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: File Upload and Download -description: Learn how to upload and download files using oRPC. ---- - -# File Operations in oRPC - -oRPC natively supports standard [File](https://developer.mozilla.org/en-US/docs/Web/API/File) and [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) objects. You can even combine files with complex data structures like arrays and objects for upload and download operations. - -:::tip File Uploads -For uploading files larger than 100 MB, we recommend using a dedicated upload solution or [extending the body parser](/docs/advanced/extend-body-parser) for better performance and reliability, as oRPC does not support chunked or resumable uploads. -::: - -:::tip File Downloads -For downloading files, we recommend using **lazy file** libraries like [@mjackson/lazy-file](https://www.npmjs.com/package/@mjackson/lazy-file) or [Bun.file](https://bun.com/docs/api/file-io#reading-files-bun-file) to reduce memory usage. -::: - -## Example - -```ts twoslash -import { os } from '@orpc/server' -import * as z from 'zod' -// ---cut--- -const example = os - .input(z.file()) - .output(z.object({ anyFieldName: z.instanceof(File) })) - .handler(async ({ input }) => { - const file = input - - console.log(file.name) - - return { - anyFieldName: new File(['Hello World'], 'hello.txt', { type: 'text/plain' }), - } - }) -``` diff --git a/apps/content/docs/getting-started.md b/apps/content/docs/getting-started.md index 97a83b6b1..28e147520 100644 --- a/apps/content/docs/getting-started.md +++ b/apps/content/docs/getting-started.md @@ -1,184 +1,3 @@ ---- -title: Getting Started -description: Quick guide to oRPC ---- - # Getting Started -oRPC (OpenAPI Remote Procedure Call) combines RPC (Remote Procedure Call) with OpenAPI, allowing you to define and call remote (or local) procedures through a type-safe API while adhering to the OpenAPI specification. - -oRPC simplifies RPC service definition, making it easy to build scalable applications, from simple scripts to complex microservices. - -This guide covers the basics: defining procedures, handling errors, and integrating with popular frameworks. - -## Prerequisites - -- Node.js 18+ (20+ recommended) | Bun | Deno | Cloudflare Workers -- A package manager: npm | pnpm | yarn | bun | deno -- A TypeScript project (strict mode recommended) - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/server@latest @orpc/client@latest -``` - -```sh [yarn] -yarn add @orpc/server@latest @orpc/client@latest -``` - -```sh [pnpm] -pnpm add @orpc/server@latest @orpc/client@latest -``` - -```sh [bun] -bun add @orpc/server@latest @orpc/client@latest -``` - -```sh [deno] -deno add npm:@orpc/server@latest npm:@orpc/client@latest -``` - -::: - -## Define App Router - -We'll use [Zod](https://github.com/colinhacks/zod) for schema validation (optional, any [standard schema](https://github.com/standard-schema/standard-schema) is supported). - -```ts twoslash -import type { IncomingHttpHeaders } from 'node:http' -import { ORPCError, os } from '@orpc/server' -import * as z from 'zod' - -const PlanetSchema = z.object({ - id: z.number().int().min(1), - name: z.string(), - description: z.string().optional(), -}) - -export const listPlanet = os - .input( - z.object({ - limit: z.number().int().min(1).max(100).optional(), - cursor: z.number().int().min(0).default(0), - }), - ) - .handler(async ({ input }) => { - // your list code here - return [{ id: 1, name: 'name' }] - }) - -export const findPlanet = os - .input(PlanetSchema.pick({ id: true })) - .handler(async ({ input }) => { - // your find code here - return { id: 1, name: 'name' } - }) - -export const createPlanet = os - .$context<{ headers: IncomingHttpHeaders }>() - .use(({ context, next }) => { - const user = parseJWT(context.headers.authorization?.split(' ')[1]) - - if (user) { - return next({ context: { user } }) - } - - throw new ORPCError('UNAUTHORIZED') - }) - .input(PlanetSchema.omit({ id: true })) - .handler(async ({ input, context }) => { - // your create code here - return { id: 1, name: 'name' } - }) - -export const router = { - planet: { - list: listPlanet, - find: findPlanet, - create: createPlanet - } -} -// ---cut-after--- - -declare function parseJWT(token: string | undefined): { userId: number } | null -``` - -## Create Server - -Using [Node.js](/docs/adapters/http) as the server runtime, but oRPC also supports other runtimes like Bun, Deno, Cloudflare Workers, etc. - -```ts twoslash -import { router } from './shared/planet' -// ---cut--- -import { createServer } from 'node:http' -import { RPCHandler } from '@orpc/server/node' -import { CORSPlugin } from '@orpc/server/plugins' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - plugins: [new CORSPlugin()], - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -const server = createServer(async (req, res) => { - const result = await handler.handle(req, res, { - context: { headers: req.headers } - }) - - if (!result.matched) { - res.statusCode = 404 - res.end('No procedure matched') - } -}) - -server.listen( - 3000, - '127.0.0.1', - () => console.log('Listening on 127.0.0.1:3000') -) -``` - -Learn more about [RPCHandler](/docs/rpc-handler). - -## Create Client - -```ts twoslash -import { router } from './shared/planet' -// ---cut--- -import type { RouterClient } from '@orpc/server' -import { createORPCClient } from '@orpc/client' -import { RPCLink } from '@orpc/client/fetch' - -const link = new RPCLink({ - url: 'http://127.0.0.1:3000', - headers: { Authorization: 'Bearer token' }, -}) - -export const orpc: RouterClient = createORPCClient(link) -``` - -Supports both [client-side clients](/docs/client/client-side) and [server-side clients](/docs/client/server-side). - -## Call Procedure - -End-to-end type-safety and auto-completion out of the box. - -```ts twoslash -import { orpc } from './shared/planet' -// ---cut--- -const planet = await orpc.planet.find({ id: 1 }) - -orpc.planet.create -// ^| -``` - -## Next Steps - -This guide introduced the RPC aspects of oRPC. To explore OpenAPI integration, visit the [OpenAPI Guide](/docs/openapi/getting-started). +// TODO diff --git a/apps/content/docs/helpers/base64url.md b/apps/content/docs/helpers/base64url.md index af5d5d93a..0ea9b666a 100644 --- a/apps/content/docs/helpers/base64url.md +++ b/apps/content/docs/helpers/base64url.md @@ -1,12 +1,9 @@ ---- -title: Base64Url Helpers -description: Functions to encode and decode base64url strings, a URL-safe variant of base64 encoding. ---- - # Base64Url Helpers Base64Url helpers provide functions to encode and decode base64url strings, a URL-safe variant of base64 encoding used in web tokens, data serialization, and APIs. +## Basic Usage + ```ts twoslash import { decodeBase64url, encodeBase64url } from '@orpc/server/helpers' diff --git a/apps/content/docs/helpers/cookie.md b/apps/content/docs/helpers/cookie.md index bb5e2b099..e74bdfd92 100644 --- a/apps/content/docs/helpers/cookie.md +++ b/apps/content/docs/helpers/cookie.md @@ -1,11 +1,8 @@ ---- -title: Cookie Helpers -description: Functions for managing HTTP cookies in web applications. ---- - # Cookie Helpers -The Cookie helpers provide functions to set and get HTTP cookies. +Cookie helpers provide utilities for setting and reading HTTP cookies from fetch headers. + +## Basic Usage ```ts twoslash import { deleteCookie, getCookie, setCookie } from '@orpc/server/helpers' diff --git a/apps/content/docs/helpers/encryption.md b/apps/content/docs/helpers/encryption.md index eae94e3cf..87eab10b2 100644 --- a/apps/content/docs/helpers/encryption.md +++ b/apps/content/docs/helpers/encryption.md @@ -1,8 +1,3 @@ ---- -title: Encryption Helpers -description: Functions to encrypt and decrypt sensitive data using AES-GCM. ---- - # Encryption Helpers Encryption helpers provide functions to encrypt and decrypt sensitive data using AES-GCM with PBKDF2 key derivation. @@ -11,6 +6,8 @@ Encryption helpers provide functions to encrypt and decrypt sensitive data using Encryption secures data content but has performance trade-offs compared to [signing](/docs/helpers/signing). It requires more CPU resources and processing time. For edge runtimes like [Cloudflare Workers](https://developers.cloudflare.com/workers/), ensure you have sufficient CPU time budget (recommend >200ms per request) for encryption operations. ::: +## Basic Usage + ```ts twoslash import { decrypt, encrypt } from '@orpc/server/helpers' diff --git a/apps/content/docs/helpers/form-data.md b/apps/content/docs/helpers/form-data.md index 7d2638126..29088c0f4 100644 --- a/apps/content/docs/helpers/form-data.md +++ b/apps/content/docs/helpers/form-data.md @@ -1,8 +1,3 @@ ---- -title: Form Data Helpers -description: Utilities for parsing form data and handling validation errors with bracket notation support. ---- - # Form Data Helpers Form data helpers provide utilities for parsing HTML form data and extracting validation error messages, with full support for [bracket notation](/docs/openapi/bracket-notation) to handle complex nested structures. @@ -12,7 +7,7 @@ Form data helpers provide utilities for parsing HTML form data and extracting va Parses HTML form data using [bracket notation](/docs/openapi/bracket-notation) to deserialize complex nested objects and arrays. ```ts twoslash -import { parseFormData } from '@orpc/openapi-client/helpers' +import { parseFormData } from '@orpc/openapi/helpers' const form = new FormData() form.append('name', 'John') @@ -33,10 +28,10 @@ const parsed = parseFormData(form) ## `getIssueMessage` -Extracts validation error messages from [standard schema](https://github.com/standard-schema/standard-schema) issues using [bracket notation](/docs/openapi/bracket-notation) paths. +Extracts validation error messages from [standard schema](https://standardschema.dev/) issues using [bracket notation](/docs/openapi/bracket-notation) paths. ```ts twoslash -import { getIssueMessage } from '@orpc/openapi-client/helpers' +import { getIssueMessage } from '@orpc/openapi/helpers' const error = { data: { @@ -60,13 +55,13 @@ const anyError = getIssueMessage('anything', 'path') ``` ::: warning -The `getIssueMessage` utility works with any data type but requires validation errors to follow the [standard schema issue format](https://github.com/standard-schema/standard-schema?tab=readme-ov-file#the-interface). It looks for issues in the `data.issues` property. If you use custom [validation errors](/docs/advanced/validation-errors), store them elsewhere, or modify the issue format, `getIssueMessage` may not work as expected. +The `getIssueMessage` utility works with any data type but requires validation errors to follow the [standard schema issue format](https://standardschema.dev/#the-specifications). It looks for issues in the `data.issues` property. If you use custom [validation errors](/docs/advanced/validation-errors), store them elsewhere, or modify the issue format, `getIssueMessage` may not work as expected. ::: ## Usage Example ```tsx -import { getIssueMessage, parseFormData } from '@orpc/openapi-client/helpers' +import { getIssueMessage, parseFormData } from '@orpc/openapi/helpers' export function ContactForm() { const [error, setError] = useState() diff --git a/apps/content/docs/helpers/publisher.md b/apps/content/docs/helpers/publisher.md index cea90ee3e..0449aea1f 100644 --- a/apps/content/docs/helpers/publisher.md +++ b/apps/content/docs/helpers/publisher.md @@ -1,42 +1,39 @@ ---- -title: Publisher -description: Listen and publish events with resuming support in oRPC ---- +# Publisher Helpers -# Publisher - -The Publisher is a helper that enables you to listen to and publish events to subscribers. Combined with the [Event Iterator](/docs/client/event-iterator), it allows you to build streaming responses, real-time updates, and server-sent events with minimal requirements. +Publisher helpers provide a unified way to publish and subscribe to events across different storage backends in oRPC applications. They support both static and dynamic event names, along with optional replay of missed events for subscribers. ## Installation ::: code-group ```sh [npm] -npm install @orpc/experimental-publisher@latest +npm install @orpc/publisher@latest ``` ```sh [yarn] -yarn add @orpc/experimental-publisher@latest +yarn add @orpc/publisher@latest ``` ```sh [pnpm] -pnpm add @orpc/experimental-publisher@latest +pnpm add @orpc/publisher@latest ``` ```sh [bun] -bun add @orpc/experimental-publisher@latest +bun add @orpc/publisher@latest ``` ```sh [deno] -deno add npm:@orpc/experimental-publisher@latest +deno add npm:@orpc/publisher@latest ``` ::: ## Basic Usage +The core concept is the `Publisher` interface, which defines a standard way to publish events and subscribe to them. You can create your own publisher or use one of the provided adapters for popular storage backends. The `publish` method accepts an event name and payload, while `subscribe` lets you listen to specific events using either callback or iterator styles. + ```ts twoslash -import { MemoryPublisher } from '@orpc/experimental-publisher/memory' +import { MemoryPublisher } from '@orpc/publisher/memory' import { os } from '@orpc/server' import * as z from 'zod' // ---cut--- @@ -47,8 +44,8 @@ const publisher = new MemoryPublisher<{ }>() const live = os - .handler(async function* ({ input, signal }) { - const iterator = publisher.subscribe('something-updated', { signal }) + .handler(async function* ({ input, signal, lastEventId }) { + const iterator = publisher.subscribe('something-updated', { signal, lastEventId }) for await (const payload of iterator) { // Handle payload here or yield directly to client yield payload @@ -71,33 +68,74 @@ const publisher = new MemoryPublisher>() ::: -## Resume Feature +## Adapters -The resume feature uses `lastEventId` to determine where to resume from after a disconnection. +| Name | Replay Support | Adapter for | +| ------------------ | -------------- | ---------------------------------------------------- | +| `MemoryPublisher` | ✅ | In-memory storage | +| `RedisPublisher` | ✅ | [Redis](https://github.com/redis/redis) | +| `UpstashPublisher` | ✅ | [Upstash Redis](https://github.com/upstash/redis-js) | + +::: code-group + +```ts [memory] +import { MemoryPublisher } from '@orpc/publisher/memory' +``` + +```ts [redis] +import { createClient } from 'redis' +import { RedisPublisher } from '@orpc/publisher/redis' + +const client = createClient({ url: 'redis://localhost:6379' }) + +// RedisRateLimiter lazily connects to Redis when needed. +// You can still call `client.connect()` manually, but it is optional. +await client.connect() + +const publisher = new RedisPublisher(client, { + subscriber: client.duplicate(), // Redis client for subscribing to pub/sub (default: client.duplicate()) + prefix: 'orpc:', // Optional Redis key prefix + serializer: undefined, // Optional custom serializer +}) +``` + +```ts [upstash] +import { Redis } from '@upstash/redis' +import { UpstashPublisher } from '@orpc/publisher/upstash' + +const redis = Redis.fromEnv() + +const publisher = new UpstashPublisher(redis, { + prefix: 'orpc:', // Optional Redis key prefix + serializer: undefined, // Optional custom serializer +}) +``` -::: warning -By default, most adapters have this feature disabled. ::: -### Server Implementation +## Replay Missing Events -When subscribing, you must forward the `lastEventId` to the publisher to enable resuming: +Some adapters can replay events missed while a subscriber is offline. This feature is usually disabled by default, but you can enable it when creating the publisher. When enabled, the publisher automatically manages event ids and attempts to replay events since the last event id provided by the subscriber. ```ts -const live = os - .handler(async function* ({ input, signal, lastEventId }) { - const iterator = publisher.subscribe('something-updated', { signal, lastEventId }) - for await (const payload of iterator) { - yield payload - } - }) +const publisher = new MemoryPublisher({ + replay: { + enabled: true, // Enable replaying missed events + seconds: 60 * 5, // TTL in seconds + } +}) + +const iterator = publisher.subscribe('something-updated', { + signal, + lastEventId, // The publisher will attempt to replay missed events since this event id +}) ``` -::: warning Event ID Management -The publisher automatically manages event ids when resume is enabled. This means: +::: warning +When replay is enabled, the publisher manages event ids automatically. This means: -- Event ids you provide when publishing will be ignored -- When subscribing, you must forward the event id when yielding custom payloads +- Any event id provided during publishing is ignored +- When subscribing, you must preserve and forward the event id when yielding custom payloads ```ts import { getEventMeta, withEventMeta } from '@orpc/server' @@ -106,8 +144,9 @@ const live = os .handler(async function* ({ input, signal, lastEventId }) { const iterator = publisher.subscribe('something-updated', { signal, lastEventId }) for await (const payload of iterator) { - // Preserve event id when yielding custom data - yield withEventMeta({ custom: 'value' }, { ...getEventMeta(payload) }) + // Preserve event id when yielding custom payloads + const id = getEventMeta(payload)?.id + yield withEventMeta({ custom: 'value' }, { id }) } }) @@ -115,15 +154,18 @@ const publish = os .input(z.object({ id: z.string() })) .handler(async ({ input }) => { // The event id 'this-will-be-ignored' will be replaced by the publisher - await publisher.publish('something-updated', withEventMeta({ id: input.id }, { id: 'this-will-be-ignored' })) + await publisher.publish( + 'something-updated', + withEventMeta({ id: input.id }, { id: 'this-will-be-ignored' }) + ) }) ``` ::: -### Client Implementation +### Client Reconnection -On the client, you can use the [Client Retry Plugin](/docs/plugins/client-retry), which automatically controls and passes `lastEventId` to the server when reconnecting. Alternatively, you can manage `lastEventId` manually: +On the client, you can use the [Retry Plugin](/docs/plugins/retry), which automatically controls and passes `lastEventId` to the server when reconnecting. Alternatively, you can manage `lastEventId` manually: ```ts import { getEventMeta } from '@orpc/client' @@ -145,129 +187,3 @@ while (true) { } } ``` - -## Available Adapters - -| Name | Resume Support | Description | -| ------------------------ | -------------- | -------------------------------------------------------------------------------------------- | -| `MemoryPublisher` | ✅ | A simple in-memory publisher | -| `IORedisPublisher` | ✅ | Adapter for [ioredis](https://github.com/redis/ioredis) | -| `UpstashRedisPublisher` | ✅ | Adapter for [Upstash Redis](https://github.com/upstash/redis-js) | -| `PublisherDurableObject` | ✅ | Adapter for [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/) | - -::: info -If you'd like to add a new publisher adapter, please open an issue. -::: - -### Memory Publisher - -```ts -import { MemoryPublisher } from '@orpc/experimental-publisher/memory' - -const publisher = new MemoryPublisher<{ - 'something-updated': { - id: string - } -}>({ - resumeRetentionSeconds: 60 * 2, // Retain events for 2 minutes to support resume -}) -``` - -::: info -Resume support is disabled by default in `MemoryPublisher`. Enable it by setting `resumeRetentionSeconds` to an appropriate value. -::: - -### IORedis Publisher - -```ts -import { Redis } from 'ioredis' -import { IORedisPublisher } from '@orpc/experimental-publisher/ioredis' - -const publisher = new IORedisPublisher<{ - 'something-updated': { - id: string - } -}>({ - commander: new Redis(), // For executing short-lived commands - listener: new Redis(), // For subscribing to events - resumeRetentionSeconds: 60 * 2, // Retain events for 2 minutes to support resume - prefix: 'orpc:publisher:', // avoid conflict with other keys - customJsonSerializers: [] // optional custom serializers -}) -``` - -This adapter requires two Redis instances: one for executing short-lived commands and another for subscribing to events. - -::: info -Resume support is disabled by default in `IORedisPublisher`. Enable it by setting `resumeRetentionSeconds` to an appropriate value. -::: - -### Upstash Redis Publisher - -```ts -import { Redis } from '@upstash/redis' -import { UpstashRedisPublisher } from '@orpc/experimental-publisher/upstash-redis' - -const redis = Redis.fromEnv() - -const publisher = new UpstashRedisPublisher<{ - 'something-updated': { - id: string - } -}>(redis, { - resumeRetentionSeconds: 60 * 2, // Retain events for 2 minutes to support resume - prefix: 'orpc:publisher:', // avoid conflict with other keys - customJsonSerializers: [] // optional custom serializers -}) -``` - -::: info -Resume support is disabled by default in `UpstashRedisPublisher`. Enable it by setting `resumeRetentionSeconds` to an appropriate value. -::: - -### Cloudflare Durable Object - -```ts -import { DurablePublisher, PublisherDurableObject } from '@orpc/experimental-publisher-durable-object' - -export class PublisherDO extends PublisherDurableObject { - constructor(ctx: DurableObjectState, env: Env) { - super(ctx, env, { - resume: { - retentionSeconds: 60 * 2, // Retain events for 2 minutes to support resume - cleanupIntervalSeconds: 12 * 60 * 60, // Interval for inactivity checks; if inactive, the DO is cleaned up (default: 12 hours) - }, - }) - } -} - -export default { - async fetch(request, env) { - const publisher = new DurablePublisher<{ - 'something-updated': { - id: string - } - }>(env.PUBLISHER_DO, { - prefix: 'publisher1', // avoid conflict with other keys - customJsonSerializers: [] // optional custom serializers - }) - }, -} -``` - -::: warning -You must enable the [`enable_request_signal`](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#enable-requestsignal-for-incoming-requests) compatibility flag in your workers to support request abort signals, which are necessary for properly cleaning up subscriptions. - -```json -{ - "compatibility_flags": [ - "enable_request_signal" - ] -} -``` - -::: - -::: info -Resume support is disabled by default in `PublisherDurableObject`. Enable it by setting `resume.retentionSeconds` to an appropriate value. -::: diff --git a/apps/content/docs/helpers/ratelimit.md b/apps/content/docs/helpers/ratelimit.md index 40deb9d88..415df7b7f 100644 --- a/apps/content/docs/helpers/ratelimit.md +++ b/apps/content/docs/helpers/ratelimit.md @@ -1,220 +1,202 @@ ---- -title: Rate Limit -description: Rate limiting features for oRPC with multiple adapters support. ---- +# Rate Limit Helpers -# Rate Limit - -The Rate Limit package provides flexible rate limiting for oRPC with multiple storage backend support. It includes adapters for in-memory, Redis, and Upstash, along with middleware and plugin helpers for seamless integration. +Rate Limit helpers provide a unified set of adapters, middleware, and handler plugins for adding rate limiting to oRPC applications. They are flexible and composable, so you can use different rate-limiting strategies and storage backends without changing your procedure code. ## Installation ::: code-group ```sh [npm] -npm install @orpc/experimental-ratelimit@latest +npm install @orpc/ratelimit@latest ``` ```sh [yarn] -yarn add @orpc/experimental-ratelimit@latest +yarn add @orpc/ratelimit@latest ``` ```sh [pnpm] -pnpm add @orpc/experimental-ratelimit@latest +pnpm add @orpc/ratelimit@latest ``` ```sh [bun] -bun add @orpc/experimental-ratelimit@latest +bun add @orpc/ratelimit@latest ``` ```sh [deno] -deno add npm:@orpc/experimental-ratelimit@latest +deno add npm:@orpc/ratelimit@latest ``` ::: -## Available Adapters +## Basic Usage -### Memory Adapter +The core concept is the `RateLimiter` interface, which defines a standard way to check and enforce rate limits. You can create your own custom limiter or use one of the provided adapters for popular storage backends. The `limit` method accepts a key and an optional `weight` value, which defaults to `1`, so a single request can consume multiple points. -A simple in-memory rate limiter using a sliding window log algorithm. Ideal for single-instance applications or development. +```ts twoslash +import { MemoryRateLimiter } from '@orpc/ratelimit/memory' +// ---cut--- +import { ORPCError } from '@orpc/server' -```ts -import { MemoryRatelimiter } from '@orpc/experimental-ratelimit/memory' +const limiter = new MemoryRateLimiter({ + maxRequests: 5, + window: 60000, +}) + +const result = await limiter.limit('user:123', { weight: 2 }) + +if (!result.success) { + throw new ORPCError('TOO_MANY_REQUESTS', { + data: { + limit: result.limit, + remaining: result.remaining, + reset: result.reset, + }, + }) +} +``` + +## Adapters + +The package includes adapters for multiple storage backends and runtimes. +Each adapter might require `maxRequests` and `window` to configure the limit, along with adapter specific options. -const limiter = new MemoryRatelimiter({ +| Name | Blocking Mode | Adapter for | +| ----------------------- | ------------- | ----------------------------------------------------------------------------------------------------------- | +| `MemoryRateLimiter` | ✅ | In-memory storage | +| `RedisRateLimiter` | ✅ | [Redis](https://github.com/redis/redis) | +| `UpstashRateLimiter` | ✅ | [Upstash Rate Limit](https://www.npmjs.com/package/@upstash/ratelimit) | +| `CloudflareRateLimiter` | | [Cloudflare RateLimit Binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) | + +::: code-group + +```ts [memory] +import { MemoryRateLimiter } from '@orpc/ratelimit/memory' + +const limiter = new MemoryRateLimiter({ maxRequests: 10, // Maximum requests allowed window: 60000, // Time window in milliseconds (60 seconds) }) ``` -### Redis Adapter - -Redis-based rate limiter using atomic Lua scripts for distributed rate limiting. +```ts [redis] +import { RedisRateLimiter } from '@orpc/ratelimit/redis' +import { createClient } from 'redis' -```ts -import { RedisRatelimiter } from '@orpc/experimental-ratelimit/redis' -import { Redis } from 'ioredis' +const client = createClient({ url: 'redis://localhost:6379' }) -const redis = new Redis('redis://localhost:6379') +// RedisRateLimiter lazily connects to Redis when needed. +// You can still call `client.connect()` manually, but it is optional. +await client.connect() -const limiter = new RedisRatelimiter({ - eval: async (script, numKeys, ...rest) => { - return redis.eval(script, numKeys, ...rest) - }, - maxRequests: 100, - window: 60000, - prefix: 'orpc:ratelimit:', // Optional key prefix +const limiter = new RedisRateLimiter(client, { + prefix: 'orpc:', // Optional Redis key prefix + maxRequests: 10, // Maximum requests allowed + window: 60000, // Time window in milliseconds (60 seconds) }) ``` -::: info -You can use any Redis client that supports Lua script evaluation by providing an `eval` function. -::: +```ts [cloudflare] +import { CloudflareRateLimiter } from '@orpc/ratelimit/cloudflare' -### Upstash Ratelimit Adapter +export default { + async fetch(request, env) { + // env.MY_RATE_LIMITER is a Cloudflare Workers Rate Limiting binding + // https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/ -Adapter for [@upstash/ratelimit](https://www.npmjs.com/package/@upstash/ratelimit), optimized for serverless environments like Vercel Edge and Cloudflare Workers. + const limiter = new CloudflareRateLimiter(env.MY_RATE_LIMITER, { + prefix: 'orpc:', // Optional key prefix + }) + } +} +``` -```ts +```ts [upstash] import { Ratelimit } from '@upstash/ratelimit' import { Redis } from '@upstash/redis' -import { UpstashRatelimiter } from '@orpc/experimental-ratelimit/upstash-ratelimit' +import { UpstashRateLimiter } from '@orpc/ratelimit/upstash' const redis = Redis.fromEnv() - const ratelimit = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(10, '60 s'), - prefix: 'my-app:', + prefix: 'orpc:', // Optional key prefix }) -const limiter = new UpstashRatelimiter(ratelimit) -``` - -::: tip Edge Runtime Support -For Edge runtime like Vercel Edge or Cloudflare Workers, pass the `waitUntil` function to better handle background tasks: - -```ts -const limiter = new UpstashRatelimiter(ratelimit, { - waitUntil: ctx.waitUntil.bind(ctx), +const limiter = new UpstashRateLimiter(ratelimit, { + waitUntil: ctx.waitUntil.bind(ctx), // Pass waitUntil for Edge runtime support }) ``` ::: -### Cloudflare Ratelimit Adapter - -Adapter for [Cloudflare Workers Ratelimit](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/). - -```ts -import { CloudflareRatelimiter } from '@orpc/experimental-ratelimit/cloudflare-ratelimit' - -export default { - async fetch(request, env) { - const limiter = new CloudflareRatelimiter(env.MY_RATE_LIMITER) - - return new Response(`Hello World!`) - } -} -``` - -## Blocking Mode +### Blocking Mode -Some adapters support blocking mode, which waits for the rate limit to reset instead of immediately rejecting requests. +Some adapters support blocking mode, which waits until capacity becomes available instead of rejecting requests immediately. ```ts -const limiter = new MemoryRatelimiter({ +const limiter = new MemoryRateLimiter({ maxRequests: 10, window: 60000, blockingUntilReady: { - enabled: true, + enabled: true, // Disabled by default timeout: 5000, // Wait up to 5 seconds }, }) ``` -## Manual Usage - -You can use adapters directly without middleware for custom rate limiting logic: - -```ts twoslash -import { MemoryRatelimiter } from '@orpc/experimental-ratelimit/memory' -import { ORPCError } from '@orpc/server' - -const limiter = new MemoryRatelimiter({ - maxRequests: 5, - window: 60000, -}) - -const result = await limiter.limit('user:123') - -if (!result.success) { - throw new ORPCError('TOO_MANY_REQUESTS', { - data: { - limit: result.limit, - remaining: result.remaining, - reset: result.reset, - }, - }) -} -``` - -## `createRatelimitMiddleware` +## Ratelimit Middleware -The `createRatelimitMiddleware` helper creates middleware for oRPC procedures to enforce rate limits. +The `ratelimit` helper creates middleware that enforces rate limits for [procedures](/docs/procedure). -```ts twoslash -import { call, os } from '@orpc/server' -import { MemoryRatelimiter } from '@orpc/experimental-ratelimit/memory' -import { createRatelimitMiddleware, Ratelimiter } from '@orpc/experimental-ratelimit' -import { z } from 'zod' +```ts +import { ratelimit, RateLimiter } from '@orpc/ratelimit' -const loginProcedure = os - .$context<{ ratelimiter: Ratelimiter }>() +const procedure = os + .$context<{ ratelimiter: RateLimiter }>() .input(z.object({ email: z.email() })) .use( - createRatelimitMiddleware({ + ratelimit({ limiter: ({ context }) => context.ratelimiter, key: ({ context }, input) => `login:${input.email}`, + weight: 1, // Optional weight for each request, default is 1 }), ) .handler(({ input }) => { return { success: true } }) -const ratelimiter = new MemoryRatelimiter({ +const ratelimiter = new MemoryRateLimiter({ maxRequests: 10, window: 60000, }) const result = await call( - loginProcedure, + procedure, { email: 'user@example.com' }, { context: { ratelimiter } } ) ``` ::: info Automatic Deduplication -The `createRatelimitMiddleware` automatically deduplicates rate limit checks when the same `limiter` and `key` combination is used multiple times in a request chain. This behavior follows the [Dedupe Middleware Best Practice](/docs/best-practices/dedupe-middleware). To disable deduplication, set the `dedupe: false` option. +When the same `limiter` and `key` combination is used multiple times in a single request chain, the `ratelimit` middleware performs the rate limit check only once. This behavior follows the [Dedupe Middleware Best Practice](/docs/best-practices/dedupe-middleware). To disable deduplication, set `dedupe: false`. ::: ::: tip Conditional Limiter -You can dynamically choose different limiters based on context: +You can choose different limiters dynamically based on the request context: ```ts -const premiumLimiter = new MemoryRatelimiter({ +const premiumLimiter = new MemoryRateLimiter({ maxRequests: 100, window: 60000, }) -const standardLimiter = new MemoryRatelimiter({ +const standardLimiter = new MemoryRateLimiter({ maxRequests: 10, window: 60000, }) const result = await call( - loginProcedure, + procedure, { email: 'user@example.com' }, { context: { @@ -228,22 +210,20 @@ const result = await call( ## Handler Plugin -The `RatelimitHandlerPlugin` automatically adds HTTP rate-limiting headers (`RateLimit-*` and `Retry-After`) to responses when used with middleware created by [`createRatelimitMiddleware`](#createratelimitmiddleware). +The `RateLimitHandlerPlugin` automatically adds HTTP rate limiting headers (`RateLimit-*` and `Retry-After`) to responses when used with [Ratelimit Middleware](#ratelimit-middleware). This lets clients inspect the current limit state and know when they can retry after hitting a limit. ```ts -import { RatelimitHandlerPlugin } from '@orpc/experimental-ratelimit' +import { RateLimitHandlerPlugin } from '@orpc/ratelimit' const handler = new RPCHandler(router, { plugins: [ - new RatelimitHandlerPlugin(), + new RateLimitHandlerPlugin(), ], }) ``` ::: info -You can combine this plugin with [Retry After Plugin](/docs/plugins/retry-after) to enable automatic client-side retries based on server rate-limiting headers. +You can combine this plugin with [Retry After Plugin](/docs/plugins/retry-after) to enable automatic client-side retries based on server rate limiting headers. ::: -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or other custom handlers. -::: + diff --git a/apps/content/docs/helpers/signing.md b/apps/content/docs/helpers/signing.md index f2675d35b..c7d27b0b0 100644 --- a/apps/content/docs/helpers/signing.md +++ b/apps/content/docs/helpers/signing.md @@ -1,8 +1,3 @@ ---- -title: Signing Helpers -description: Functions to cryptographically sign and verify data using HMAC-SHA256. ---- - # Signing Helpers Signing helpers provide functions to cryptographically sign and verify data using HMAC-SHA256. @@ -11,6 +6,8 @@ Signing helpers provide functions to cryptographically sign and verify data usin Signing is faster than [encryption](/docs/helpers/encryption) but users can view the original data. ::: +## Basic Usage + ```ts twoslash import { getSignedValue, sign, unsign } from '@orpc/server/helpers' diff --git a/apps/content/docs/integrations/ai-sdk.md b/apps/content/docs/integrations/ai-sdk.md deleted file mode 100644 index 2ffce341f..000000000 --- a/apps/content/docs/integrations/ai-sdk.md +++ /dev/null @@ -1,220 +0,0 @@ ---- -title: AI SDK Integration -description: Seamlessly use AI SDK inside your oRPC projects without any extra overhead. ---- - -# AI SDK Integration - -[AI SDK](https://ai-sdk.dev/) is a free open-source library for building AI-powered products. You can seamlessly integrate it with oRPC without any extra overhead. - -::: warning -This documentation requires AI SDK v5.0.0 or later. For a refresher, review the [AI SDK documentation](https://ai-sdk.dev/docs). -::: - -## Server - -Use `streamToEventIterator` to convert AI SDK streams to [oRPC Event Iterators](/docs/event-iterator). - -```ts twoslash -import { os, streamToEventIterator, type } from '@orpc/server' -import { convertToModelMessages, streamText, UIMessage } from 'ai' -import { google } from '@ai-sdk/google' - -export const chat = os - .input(type<{ chatId: string, messages: UIMessage[] }>()) - .handler(async ({ input }) => { - const result = streamText({ - model: google('gemini-1.5-flash'), - system: 'You are a helpful assistant.', - messages: await convertToModelMessages(input.messages), - }) - - return streamToEventIterator(result.toUIMessageStream()) - }) -``` - -## Client - -On the client side, convert the event iterator back to a stream using `eventIteratorToStream` or `eventIteratorToUnproxiedDataStream`. - -```tsx twoslash -import React, { useState } from 'react' -import { os, streamToEventIterator, type } from '@orpc/server' -import { convertToModelMessages, streamText, UIMessage } from 'ai' -import { google } from '@ai-sdk/google' - -export const chat = os - .input(type<{ chatId: string, messages: UIMessage[] }>()) - .handler(async ({ input }) => { - const result = streamText({ - model: google('gemini-1.5-flash'), - system: 'You are a helpful assistant.', - messages: await convertToModelMessages(input.messages), - }) - - return streamToEventIterator(result.toUIMessageStream()) - }) - .callable() - -const client = { chat } -// ---cut--- -import { useChat } from '@ai-sdk/react' -import { eventIteratorToUnproxiedDataStream } from '@orpc/client' - -export function Example() { - const { messages, sendMessage, status } = useChat({ - transport: { - async sendMessages(options) { - return eventIteratorToUnproxiedDataStream(await client.chat({ - chatId: options.chatId, - messages: options.messages, - }, { signal: options.abortSignal })) - }, - reconnectToStream(options) { - throw new Error('Unsupported') - }, - }, - }) - const [input, setInput] = useState('') - - return ( - <> - {messages.map(message => ( -
- {message.role === 'user' ? 'User: ' : 'AI: '} - {message.parts.map((part, index) => - part.type === 'text' ? {part.text} : null, - )} -
- ))} - -
{ - e.preventDefault() - if (input.trim()) { - sendMessage({ text: input }) - setInput('') - } - }} - > - setInput(e.target.value)} - disabled={status !== 'ready'} - placeholder="Say something..." - /> - -
- - ) -} -``` - -::: info -The `reconnectToStream` function is not supported by default, which is fine for most use cases. If you need reconnection support, implement it similar to `sendMessages` with custom reconnection logic. See this [reconnect example](). -::: - -::: info -Prefer `eventIteratorToUnproxiedDataStream` over `eventIteratorToStream`. -AI SDK internally uses `structuredClone`, which doesn't support proxied data. -oRPC may proxy events for [metadata](/docs/event-iterator#last-event-id-event-metadata), so unproxy before passing to AI SDK. -::: - -## `implementTool` helper - -Implements [procedure contract](/docs/contract-first/define-contract) as an [AI SDK tools](https://ai-sdk.dev/docs/foundations/tools) by leveraging existing contract definitions. - -```ts twoslash -import { oc } from '@orpc/contract' -import { - AI_SDK_TOOL_META_SYMBOL, - AiSdkToolMeta, - implementTool -} from '@orpc/ai-sdk' -import { z } from 'zod' - -interface ORPCMeta extends AiSdkToolMeta {} // optional extend meta -const base = oc.$meta({}) - -const getWeatherContract = base - .meta({ - [AI_SDK_TOOL_META_SYMBOL]: { - title: 'Get Weather', // AI SDK tool title - }, - }) - .route({ - summary: 'Get the weather in a location', // AI SDK tool description - }) - .input(z.object({ - location: z.string().describe('The location to get the weather for'), - })) - .output(z.object({ - location: z.string().describe('The location the weather is for'), - temperature: z.number().describe('The temperature in Celsius'), - })) - -const getWeatherTool = implementTool(getWeatherContract, { - execute: async ({ location }) => ({ - location, - temperature: 72 + Math.floor(Math.random() * 21) - 10, - }), - // ...add any additional configuration or overrides here -}) -``` - -::: warning -The `implementTool` helper requires a contract with an `input` schema defined -::: - -::: info -Standard [procedures](/docs/procedure) are also compatible with [procedure contracts](/docs/contract-first/define-contract). -::: - -## `createTool` helper - -Converts a [procedure](/docs/procedure) into an [AI SDK Tool](https://ai-sdk.dev/docs/foundations/tools) by leveraging existing procedure definitions. - -```ts twoslash -import { os } from '@orpc/server' -import { - AI_SDK_TOOL_META_SYMBOL, - AiSdkToolMeta, - createTool -} from '@orpc/ai-sdk' -import { z } from 'zod' - -interface ORPCMeta extends AiSdkToolMeta {} // optional extend meta -const base = os.$meta({}) - -const getWeatherProcedure = base - .meta({ - [AI_SDK_TOOL_META_SYMBOL]: { - title: 'Get Weather', // AI SDK tool title - }, - }) - .route({ - summary: 'Get the weather in a location', - }) - .input(z.object({ - location: z.string().describe('The location to get the weather for'), - })) - .output(z.object({ - location: z.string().describe('The location the weather is for'), - temperature: z.number().describe('The temperature in Celsius'), - })) - .handler(async ({ input }) => ({ - location: input.location, - temperature: 72 + Math.floor(Math.random() * 21) - 10, - })) - -const getWeatherTool = createTool(getWeatherProcedure, { - context: {}, // provide initial context if needed - // ...add any additional configuration or overrides here -}) -``` - -::: warning -The `createTool` helper requires a procedure with an `input` schema defined -::: diff --git a/apps/content/docs/integrations/better-auth.md b/apps/content/docs/integrations/better-auth.md deleted file mode 100644 index ba8db9e2d..000000000 --- a/apps/content/docs/integrations/better-auth.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: Better Auth Integration -description: Seamlessly use Better Auth inside your oRPC projects without any extra overhead. ---- - -# Better Auth Integration - -[Better Auth](https://better-auth.com/) is a framework-agnostic, universal authentication and authorization framework for TypeScript. - -::: warning -This documentation assumes you are already familiar with [Better Auth](https://better-auth.com/). If you need a refresher, please review the official Better Auth documentation before proceeding. -::: - -## Step 1: Define Context Headers - -First, you need to access request headers in your context. You can do this either manually or by using the [Request Headers Plugin](/docs/plugins/request-headers). - -### Option A: Manual Context Definition - -```typescript -import { os } from '@orpc/server' - -export const base = os.$context<{ headers: Headers }>() -``` - -::: tip -Don't forget to provide the `headers` when creating the context. This is typically done in your server adapter. -::: - -### Option B: Using Request Headers Plugin - -Follow the setup instructions on the [Request Headers Plugin](/docs/plugins/request-headers) page. - -## Step 2: Create Auth Middleware - -Create a middleware that fetches the session and user from Better Auth, validates authentication, and adds them to the context. - -```typescript -import { auth } from './auth' // Your Better Auth instance -import { base } from './context' -import { ORPCError } from '@orpc/server' - -export const authMiddleware = base.middleware(async ({ context, next }) => { - const sessionData = await auth.api.getSession({ - headers: context.headers, // or reqHeaders if you're using the plugin - }) - - if (!sessionData?.session || !sessionData?.user) { - throw new ORPCError('UNAUTHORIZED') - } - - // Adds session and user to the context - return next({ - context: { - session: sessionData.session, - user: sessionData.user - }, - }) -}) -``` - -## Usage - -Instead of using `.use(authMiddleware)` every time you create a protected procedure, you can create an `authorized` base that already includes the auth middleware: - -```typescript -import { base } from './context' -import { authMiddleware } from './middlewares/auth' - -export const authorized = base.use(authMiddleware) -``` - -Now you can use `authorized` to create procedures that require authentication: - -```typescript -import { authorized } from './authorized' - -export const getMessages = authorized.handler(({ context }) => { - // context.session and context.user are guaranteed to be defined -}) -``` diff --git a/apps/content/docs/integrations/durable-iterator.md b/apps/content/docs/integrations/durable-iterator.md deleted file mode 100644 index 1a3dda83a..000000000 --- a/apps/content/docs/integrations/durable-iterator.md +++ /dev/null @@ -1,444 +0,0 @@ ---- -title: Durable Iterator Integration -description: Extends Event Iterator with durable event streams, automatic reconnections, and event recovery through a separate streaming service. ---- - -# Durable Iterator Integration - -Durable Iterator extends [Event Iterator](/docs/event-iterator) by offloading streaming to a separate service that provides durable event streams, automatic reconnections, and event recovery. - -::: info -See the complete example in our [Cloudflare Worker Playground](/docs/playgrounds). -::: - -::: info -While not limited to [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/), it's currently the only supported implementation. -::: - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/experimental-durable-iterator@latest -``` - -```sh [yarn] -yarn add @orpc/experimental-durable-iterator@latest -``` - -```sh [pnpm] -pnpm add @orpc/experimental-durable-iterator@latest -``` - -```sh [bun] -bun add @orpc/experimental-durable-iterator@latest -``` - -```sh [deno] -deno add npm:@orpc/experimental-durable-iterator@latest -``` - -::: - -::: warning -The `experimental-` prefix indicates that this feature is still in development and may change in the future. -::: - -## Durable Object - -::: warning -This section requires you to be familiar with [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/). Please learn it first before continuing. -::: - -### Define your Durable Object - -Simply extend the `DurableIteratorObject` class: - -```ts -import { DurableIteratorObject } from '@orpc/experimental-durable-iterator/durable-object' - -export class ChatRoom extends DurableIteratorObject<{ message: string }> { - constructor(ctx: DurableObjectState, env: Env) { - super(ctx, env, { - signingKey: 'secret-key', // Replace with your actual signing key - interceptors: [ - onError(e => console.error(e)), // log error thrown from rpc calls - ], - onSubscribed: (websocket, lastEventId) => { - console.log(`WebSocket Ready id=${websocket['~orpc'].deserializeId()}`) - } - }) - } - - someMethod() { - // publishEvent method inherited from DurableIteratorObject - this.publishEvent({ message: 'Hello, world!' }) - } -} -``` - -::: info -How to use `DurableIteratorObject` without extending it: [see here](https://github.com/middleapi/orpc/tree/main/packages/durable-iterator/src/durable-object/object.ts) -::: - -### Upgrade Durable Iterator Request - -Upgrade and validate WebSocket requests to your Durable Object by providing a signing key and the corresponding namespace: - -```ts -import { upgradeDurableIteratorRequest } from '@orpc/experimental-durable-iterator/durable-object' - -export default { - async fetch(request, env) { - const url = new URL(request.url) - - if (url.pathname === '/chat-room') { - return upgradeDurableIteratorRequest(request, { - signingKey: 'secret-key', // Replace with your actual signing key - namespace: env.CHAT_ROOM, - }) - } - - return new Response('Not Found', { status: 404 }) - }, -} satisfies ExportedHandler - -export { ChatRoom } -``` - -### Publish Events - -Use `publishEvent` to send events to connected clients. Three filtering options are available: - -- **`tags`**: Send events only to clients with matching tags -- **`targets`**: Send events to specific clients (accepts array or filter callback) -- **`exclude`**: Exclude specific clients from receiving events (accepts array or filter callback) - -```ts -this.publishEvent({ message: 'Hello, world!' }, { - tags: ['tag1', 'tag2'], - targets: ws => ws['~orpc'].deserializeTokenPayload().att.role === 'admin', - exclude: [senderWs], -}) -``` - -::: info -When using [Resume Events After Connection Loss](#resume-events-after-connection-loss) feature, prefer `tags` or `targets` filtering over `exclude` for security. Since clients control their own identity, `exclude` should only be used for UI convenience, not security enforcement. -::: - -### Resume Events After Connection Loss - -Event resumption is disabled by default. Enable it by configuring `resumeRetentionSeconds` to specify how long events are persisted for recovery: - -```ts -export class YourDurableObject extends DurableIteratorObject<{ message: string }> { - constructor( - ctx: DurableObjectState, - env: Env, - ) { - super(ctx, env, { - signingKey: 'secret-key', - resumeRetentionSeconds: 60 * 2, // 2 minutes [!code highlight] - }) - } -} -``` - -::: warning -This feature controls event IDs automatically, so custom event IDs will be ignored: - -```ts -import { withEventMeta } from '@orpc/experimental-durable-iterator' - -this.publishEvent(withEventMeta({ message: 'Hello, world!' }, { id: 'this-will-not-take-effect' })) -``` - -::: - -## Server Side - -Define two procedures: one for listening to chat room messages, and another for sending messages to all connected clients: - -::: info -This example assumes your server and Durable Object run in the same environment. For different environments, send a fetch request to your Durable Object instead of invoking methods directly. -::: - -```ts -import { DurableIterator } from '@orpc/experimental-durable-iterator' - -export const router = { - onMessage: base.handler(({ context }) => { - return new DurableIterator('some-room', { - tags: ['tag1', 'tag2'], - signingKey: 'secret-key', // Replace with your actual signing key - }) - }), - - sendMessage: base - .input(z.object({ message: z.string() })) - .handler(async ({ context, input }) => { - const id = context.env.CHAT_ROOM.idFromName('some-room') - const stub = context.env.CHAT_ROOM.get(id) - - await stub.publishEvent(input) - }), -} -``` - -Enable Durable Iterator support by adding `DurableIteratorHandlerPlugin` to your handler: - -```ts -import { DurableIteratorHandlerPlugin } from '@orpc/experimental-durable-iterator' - -const handler = new RPCHandler(router, { - plugins: [ - new DurableIteratorHandlerPlugin(), - ], -}) -``` - -::: warning CORS Policy -The `DurableIteratorHandlerPlugin` adds an `x-orpc-durable-iterator` header to responses, indicating that the response contains a durable iterator. For cross-origin requests, you must configure CORS to expose this header to clients. - -```ts -const handler = new RPCHandler(router, { - plugins: [ - new CORSPlugin({ - exposeHeaders: ['x-orpc-durable-iterator'], // [!code highlight] - }), - new DurableIteratorHandlerPlugin(), - ], -}) -``` - -::: - -## Client Side - -On the client side, simply configure the plugin. Usage is identical to [Event Iterator](/docs/client/event-iterator). The `url` in `DurableIteratorLinkPlugin` points to your Durable Object upgrade endpoint: - -```ts -import { DurableIteratorLinkPlugin } from '@orpc/experimental-durable-iterator/client' - -const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - plugins: [ - new DurableIteratorLinkPlugin({ - url: 'ws://localhost:3000/chat-room', - interceptors: [ - onError(e => console.error(e)), // log error thrown from rpc calls - ], - }), - ], -}) -``` - -::: info -`DurableIteratorLinkPlugin` establishes a WebSocket connection to the Durable Object for each durable iterator and automatically reconnects if the connection is lost. -::: - -### Example - -```ts -const iterator = await client.onMessage() - -for await (const { message } of iterator) { - console.log('Received message:', message) -} - -await client.sendMessage({ message: 'Hello, world!' }) -``` - -### Auto Refresh Token Before Expiration - -Token auto-refresh is disabled by default. Enable it by configuring `refreshTokenBeforeExpireInSeconds`: - -```ts -const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - plugins: [ - new DurableIteratorLinkPlugin({ - url: 'ws://localhost:3000/chat-room', - refreshTokenBeforeExpireInSeconds: 10 * 60, // 10 minutes [!code highlight] - }), - ], -}) -``` - -::: warning -Token refresh reuses the existing WebSocket connection if the refreshed token has identical `chn` (channel) and `tags`. Otherwise, the connection closes and a new one is established. -::: - -### Stopping the Durable Iterator - -Like [Event Iterator](/docs/client/event-iterator), you can rely on `signal` or `.return` to stop the iterator. - -```ts -const controller = new AbortController() -const iterator = await client.onMessage(undefined, { signal: controller.signal }) - -// Stop the iterator after 1 second -setTimeout(() => { - controller.abort() - // or - iterator.return() -}, 1000) - -for await (const { message } of iterator) { - console.log('Received message:', message) -} -``` - -## Method RPC - -Unlike [Cloudflare Durable Objects RPC](https://developers.cloudflare.com/durable-objects/best-practices/create-durable-object-stubs-and-send-requests/) (server-side only), this RPC uses oRPC's built-in system over the same WebSocket connection for fast client-to-Durable Object communication. Define methods that accept a `DurableIteratorWebsocket` instance as the first argument and return an [oRPC Client](/docs/client/server-side): - -```ts -import { DurableIteratorWebsocket } from '@orpc/experimental-durable-iterator/durable-object' - -export class ChatRoom extends DurableIteratorObject<{ message: string }> { - singleClient(ws: DurableIteratorWebsocket) { - return base - .input(z.object({ message: z.string() })) - .handler(({ input, context }) => { - const tokenPayload = ws['~orpc'].deserializeTokenPayload() - - this.publishEvent(input, { - exclude: [ws], // exclude the sender - }) - }) - .callable() - } - - routerClient(ws: DurableIteratorWebsocket) { - return { - ping: base.handler(() => 'pong').callable(), - echo: base - .input(z.object({ text: z.string() })) - .handler(({ input }) => `Echo: ${input.text}`) - .callable(), - } - } -} -``` - -### Server Side Usage - -```ts -import { DurableIterator } from '@orpc/experimental-durable-iterator' - -export const onMessage = base.handler(({ context }) => { - return new DurableIterator('some-room', { - signingKey: 'secret-key', // Replace with your actual signing key - att: { // Attach additional data to token - userId: 'user-123', - }, - }).rpc('singleClient', 'routerClient') // Allowed methods -}) -``` - -::: info -Clients can only call methods defined in the `rpc` method, providing fine-grained access control. -::: - -::: warning -The `att` (attachment) data is visible to clients. Only include non-sensitive metadata like user IDs or preferences. -::: - -### Client Side Usage - -Invoke methods defined in `rpc` directly from the client iterator: - -```ts -const iterator = await client.onMessage() - -// Listen for events -for await (const { message } of iterator) { - console.log('Received message:', message) -} - -// Call RPC methods -await iterator.singleClient({ message: 'Hello, world!' }) - -// Call nested router methods -const response = await iterator.routerClient.ping() -console.log(response) // "pong" - -const echoResponse = await iterator.routerClient.echo({ text: 'Hello' }) -console.log(echoResponse) // "Echo: Hello" -``` - -::: info -[Retry Plugin](/docs/plugins/client-retry) is enabled for all RPC methods. Configure retry attempts using the context: - -```ts -await iterator.singleClient({ message: 'Hello, world!' }, { context: { retry: 3 } }) -``` - -::: - -## Contract First - -This integration supports [Contract First](/docs/contract-first/define-contract). Define an interface that extends `DurableIteratorObject`: - -```ts -import type { ContractRouterClient } from '@orpc/contract' -import { oc, type } from '@orpc/contract' -import type { ClientDurableIterator } from '@orpc/experimental-durable-iterator/client' -import type { DurableIteratorObject } from '@orpc/experimental-durable-iterator' - -export const publishMessageContract = oc.input(z.object({ message: z.string() })) - -export interface ChatRoom extends DurableIteratorObject<{ message: string }> { - publishMessage(...args: any[]): ContractRouterClient -} - -export const contract = { - onMessage: oc.output(type>()), -} -``` - -## Advanced - -Durable Iterator is built on top of the [Hibernation Plugin](/docs/plugins/hibernation), essentially providing an oRPC instance within another oRPC. This architecture gives you access to the full oRPC ecosystem, including interceptors and plugins for both server and client sides. - -### Server-Side Customization - -```ts -export class YourDurableObject extends DurableIteratorObject<{ message: string }> { - constructor( - ctx: DurableObjectState, - env: Env, - ) { - super(ctx, env, { - signingKey: 'secret-key', - customJsonSerializers: [], // Custom JSON serializers - interceptors: [], // Handler interceptors - plugins: [], // Handler plugins - }) - } -} -``` - -### Client-Side Customization - -```ts -declare module '@orpc/experimental-durable-iterator/client' { - interface ClientDurableIteratorRpcContext { - // Custom client context - } -} - -const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - plugins: [ - new DurableIteratorLinkPlugin({ - url: 'ws://localhost:3000/chat-room', - customJsonSerializers: [], // Custom JSON serializers - interceptors: [], // Link interceptors - plugins: [], // Link plugins - }), - ], -}) -``` diff --git a/apps/content/docs/integrations/effect.md b/apps/content/docs/integrations/effect.md new file mode 100644 index 000000000..9f72ea4df --- /dev/null +++ b/apps/content/docs/integrations/effect.md @@ -0,0 +1,272 @@ +# Effect Integration + +[Effect](https://effect.website/) integration lets you seamlessly use Effect's powerful features, such as its effect system, concurrency model, and schema library, within oRPC. + +::: warning +This guide assumes familiarity with [Effect](https://effect.website/). Review the official documentation if needed. +::: + +## Installation + +::: code-group + +```sh [npm] +npm install @orpc/experimental-effect@latest effect@latest +``` + +```sh [yarn] +yarn add @orpc/experimental-effect@latest effect@latest +``` + +```sh [pnpm] +pnpm add @orpc/experimental-effect@latest effect@latest +``` + +```sh [bun] +bun add @orpc/experimental-effect@latest effect@latest +``` + +```sh [deno] +deno add npm:@orpc/experimental-effect@latest npm:effect@latest +``` + +::: + +## Effectful Handlers + +`handlerGen` allows you to write effectful handlers using generator functions. Inside the generator, you can yield Effect operations, and `handlerGen` will handle the execution and error handling for you. + +```ts twoslash +import { os } from '@orpc/server' +// ---cut--- +import { handlerGen } from '@orpc/experimental-effect' +import { Effect } from 'effect' + +const procedure = os.handler(handlerGen(function* ({ input, context }) { + // You can use Effect's features here, such as concurrency, error handling, etc. + const result = yield* Effect.promise(() => Promise.resolve(5)) + return result +})) +``` + +### `.effect` extension + +Import `@orpc/experimental-effect/extensions/effect` from a module that always runs during initialization, such as the file where you define your base builder or create your server. This adds an `.effect` method to the builder so you can write effectful handlers directly. + +::: code-group + +```ts [usage] +const procedure = base.effect(function* ({ input, context }) { + // You can use Effect's features here, such as concurrency, error handling, etc. + const result = yield* Effect.promise(() => Promise.resolve(5)) + return result +}) +``` + +```ts [setup] +import '@orpc/experimental-effect/extensions/effect' + +import { os } from '@orpc/server' + +export const base = os +``` + +::: + +### Effect Services + +You can provide Effect services through the oRPC context in a typesafe way with `WithEffectContext` and `~effect/context`: + +```ts twoslash +import { call, os } from '@orpc/server' +// ---cut--- +import { handlerGen, WithEffectContext } from '@orpc/experimental-effect' +import { Context, Effect } from 'effect' + +class Random extends Context.Tag('MyRandomService')< + Random, + { + readonly next: Effect.Effect + } +>() {} + +interface ServerContext extends WithEffectContext {} + +const procedure = os + .$context() + .handler(handlerGen(function* ({ input, context }) { + const random = yield* Random + const result = yield* random.next + return result + })) + +const random = await call(procedure, undefined, { + context: { + '~effect/context': Context.empty().pipe( + Context.add(Random, { + next: Effect.succeed(Math.random()), + }), + ) + } +}) +``` + +::: info +You can also extend the Effect context with [middleware](/docs/middleware): + +```ts +const procedure = os + .$context() + .use(({ context, next }) => { + return next({ + context: { + '~effect/context': context['~effect/context'].pipe( + Context.add(AdditionService, {}), + ) + } + }) + }) + .handler(handlerGen(function* ({ input, context }) { + const additionService = yield* AdditionService + })) +``` + +::: + +### Error Handling + +This integration preserves the original error whenever possible. If you call `Effect.fail(error)`, the error is forwarded to [middleware](/docs/middleware) and interceptors, just like a regular thrown error. + +To customize this behavior, wrap the effect before execution using `~effect/wrap` in the context: + +```ts +import { Context, Effect } from 'effect' + +interface ServerContext extends WithEffectContext {} + +export async function fetch(request: Request) { + const { response } = await handler.fetch(request, { + context: { + '~effect/context': Context.empty(), + '~effect/wrap': (effect, opts) => effect.pipe( + Effect.catchAllCause((cause) => { + + }) + ), + } + }) + + return response ?? new Response('Not Found', { status: 404 }) +} +``` + +::: info +For app level error handling, we recommend [middleware](/docs/middleware) or interceptors. +::: + +### Typesafe Errors + +When you `yield* Effect.fail(new ORPCError(...))` or `return new ORPCError(...)`, oRPC treats it as a [returned ORPCError](/docs/error-handling#returning-an-orpcerror). On the client, you can handle these errors in a typesafe way: + +```ts +const procedure = os.handler(handlerGen(function* ({ errors }) { + if (resourceNotFound) { + yield* Effect.fail(new ORPCError('NOT_FOUND', { + message: 'The resource you are looking for does not exist', + })) + // -- or - + return new ORPCError('NOT_FOUND', { + message: 'The resource you are looking for does not exist', + }) + } + + return 'Success' +})) + +const [error, result] = await call(procedure) + +if (isInferableError(error)) { + // typesafe error handling +} +``` + +## Effect Schema + +oRPC natively supports [Standard Schema](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec), and [Effect Schema](https://effect.website/docs/schema/introduction/) implements that spec through [Schema.standardSchemaV1](https://effect.website/docs/schema/standard-schema/): + +```ts +import { Schema } from 'effect' + +const procedure = os + .input(Schema.standardSchemaV1(Schema.Struct({ name: Schema.String }))) + .handler(handlerGen(function* ({ input, context }) { + return `Hello ${input.name}!` + })) +``` + +### `.input` and `.output` Extensions + +Import `@orpc/experimental-effect/extensions/input-output` from a module that always runs during initialization, such as the file where you define your base builder or create your server. This lets you define `.input` and `.output` directly with Effect Schema: + +::: code-group + +```ts [usage] +const procedure = base + .input(Schema.Struct({ name: Schema.String })) + .output(Schema.Struct({ greeting: Schema.String })) + .handler(handlerGen(function* ({ input, context }) { + return { greeting: `Hello ${input.name}!` } + })) +``` + +```ts [setup] +import '@orpc/experimental-effect/extensions/input-output' + +import { os } from '@orpc/server' + +export const base = os +``` + +::: + +::: info +You can also use these extensions with the [contract builder](/docs/contract/procedure). +::: + +### JSON Schema Converter + +This integration also provides `EffectSchemaToJsonSchemaConverter`, built on top of [Effect Schema to JSON Schema](https://effect.website/docs/schema/json-schema/). You can use it with tools such as the [OpenAPI Generator](/docs/openapi/specification#openapi-generator): + +```ts +import { EffectSchemaToJsonSchemaConverter } from '@orpc/experimental-effect' + +const generator = new OpenAPIGenerator({ + converters: [new EffectSchemaToJsonSchemaConverter()], +}) +``` + +## OpenTelemetry Integration + +First, set up the [oRPC OpenTelemetry integration](/docs/integrations/opentelemetry). Then instrument your Effect to work seamlessly with OpenTelemetry by providing `TracingLive` through `~effect/wrap` in the context. This makes Effect tracing equivalent to OpenTelemetry tracing: + +```ts +import { Resource, Tracer } from '@effect/opentelemetry' +import { Context, Effect, Layer } from 'effect' + +interface ServerContext extends WithEffectContext {} + +const TracingLive = Tracer.layerGlobal.pipe( + Layer.provide(Resource.layerFromEnv()), +) + +export async function fetch(request: Request) { + const { response } = await handler.fetch(request, { + context: { + '~effect/context': Context.empty(), + '~effect/wrap': (effect, opts) => effect.pipe(Effect.provide(TracingLive)), + } + }) + + return response ?? new Response('Not Found', { status: 404 }) +} +``` diff --git a/apps/content/docs/integrations/evlog.md b/apps/content/docs/integrations/evlog.md new file mode 100644 index 000000000..21ed26423 --- /dev/null +++ b/apps/content/docs/integrations/evlog.md @@ -0,0 +1,117 @@ +# Evlog Integration + +[Evlog](https://evlog.dev/) integration for oRPC adds structured logging so you can trace requests, monitor errors, and inspect application behavior. + +::: warning +This guide assumes familiarity with [Evlog](https://evlog.dev/). Review the official documentation if needed. +::: + +## Installation + +::: code-group + +```sh [npm] +npm install @orpc/evlog@latest evlog@latest +``` + +```sh [yarn] +yarn add @orpc/evlog@latest evlog@latest +``` + +```sh [pnpm] +pnpm add @orpc/evlog@latest evlog@latest +``` + +```sh [bun] +bun add @orpc/evlog@latest evlog@latest +``` + +```sh [deno] +deno add npm:@orpc/evlog@latest npm:evlog@latest +``` + +::: + +## Setup + +Use `EvlogHandlerPlugin` to instrument your handler with structured logs, request tracking, and error monitoring. + +```ts twoslash +import { RPCHandler } from '@orpc/server/fetch' +import { router } from './shared/planet' +// ---cut--- +import { EvlogHandlerPlugin } from '@orpc/evlog' + +const handler = new RPCHandler(router, { + plugins: [ + new EvlogHandlerPlugin({ + drain: undefined, // <- custom Evlog drain (optional) + plugins: [], // <- additional Evlog plugins (optional) + logAbort: true, // <- log when requests are aborted (disabled by default) + }), + ], +}) +``` + + + +## Using the Logger in Your Code + +This plugin supports using [AsyncLocalStorage](https://nodejs.org/api/async_context.html#class-asynclocalstorage) to access the logger throughout a request and enrich the final [wide event](https://www.evlog.dev/learn/wide-events#uselogger-retrieving-the-request-logger). It is the most convenient way to use Evlog's full feature set. If your runtime does not support AsyncLocalStorage, you can still [access the logger from the context](#without-asynclocalstorage). + +::: code-group + +```ts [business logic] +import { createLoggerStorage } from '@orpc/evlog/node' + +/** + * Pass `storage` to the plugin configuration. + * Call `useLogger` inside a procedure to access the request logger. + */ +export const { storage, useLogger } = createLoggerStorage() + +const procedure = os + .handler(async () => { + const logger = useLogger() // [!code highlight] + + logger?.set({ user: { id: 123, name: 'John Doe' } }) // [!code highlight] + + await logger.fork('child-procedure', () => { + const logger = useLogger() // [!code highlight] + }) + + return { success: true } + }) +``` + +```ts [handler setup] +const handler = new RPCHandler(router, { + plugins: [ + new EvlogHandlerPlugin({ + storage, // <- pass the storage to the plugin + }), + ], +}) +``` + +::: + +### Without AsyncLocalStorage + +If you do not want to use AsyncLocalStorage, or your runtime does not support it, you can still read the logger from the context. + +```ts +import { getLogger, LoggerContext } from '@orpc/evlog' + +interface ServerContext extends LoggerContext {} // [!code highlight] + +const procedure = os + .$context() + .handler(({ context }) => { + const logger = getLogger(context) // [!code highlight] + + logger?.set({ user: { id: 123, name: 'John Doe' } }) // [!code highlight] + + return { success: true } + }) +``` diff --git a/apps/content/docs/integrations/next.md b/apps/content/docs/integrations/next.md new file mode 100644 index 000000000..ab2e21b09 --- /dev/null +++ b/apps/content/docs/integrations/next.md @@ -0,0 +1,324 @@ +# Next.js Integration + +[Next.js](https://nextjs.org/) integration provides utilities for using oRPC in Next.js applications, including support for [server functions](https://nextjs.org/docs/app/api-reference/directives/use-server) and form actions. + +## Installation + +::: code-group + +```sh [npm] +npm install @orpc/next@latest +``` + +```sh [yarn] +yarn add @orpc/next@latest +``` + +```sh [pnpm] +pnpm add @orpc/next@latest +``` + +```sh [bun] +bun add @orpc/next@latest +``` + +```sh [deno] +deno add npm:@orpc/next@latest +``` + +::: + +## Server Functions + +Use `createServerFunction` to turn a [procedure](/docs/procedure) into a [server function](https://nextjs.org/docs/app/api-reference/directives/use-server). It accepts the same options as [server-side clients](/docs/client/server-side#router-clients), and the returned function accepts the same input as the original procedure. + +```ts twoslash +'use server' + +import { os } from '@orpc/server' +import { createServerFunction } from '@orpc/next' + +const procedure = os.handler(async () => 'Hello from oRPC + Next.js!') + +export const serverFunction = createServerFunction(procedure, { + context: async () => { // <- provide initial context if needed + return { user: { id: '123', name: 'Alice' } } + }, + interceptors: [] // <- add interceptors if needed +}) +``` + +You can call the returned `serverFunction` from a client component. + +```tsx +'use client' + +import { serverFunction } from './path/to/server/function' + +export default function Page() { + const handleClick = async () => { + const [error, message] = await serverFunction() + + if (!error) { + console.log({ message }) + } + } + + return ( +
+ +
+ ) +} +``` + +Special Next.js errors such as [redirect](https://nextjs.org/docs/app/api-reference/functions/redirect) and [notFound](https://nextjs.org/docs/app/api-reference/functions/not-found) are rethrown so Next.js handles them normally. All other errors are serialized to `ORPCErrorJSON` and returned as the first element of the tuple. + +### Typesafe Errors + +[Typesafe errors](/docs/error-handling#typesafe-errors) are supported as well. Because errors are serialized before they reach the client, use the `inferable` field to distinguish errors. + +::: code-group + +```tsx [client] +'use client' + +import { serverFunction } from './path/to/server/function' + +export default function Page() { + const handleClick = async () => { + const [error, message] = await serverFunction() + + if (error) { + if (error.inferable) { + // handle typesafe error + } + else { + // handle unknown error + } + } + else { + // handle success case + } + } + + return ( +
+ +
+ ) +} +``` + +```ts [server] +'use server' + +const procedure = os + .errors({ + NOT_FOUND: { + message: 'The resource was not found', + }, + }) + .handler(async ({ errors }) => { + throw errors.NOT_FOUND() + }) + +export const serverFunction = createServerFunction(procedure) +``` + +::: + +### `createServerFunctionable` + +If you reuse the same options across multiple server functions, `createServerFunctionable` creates a preconfigured helper. The helper takes a procedure and returns a value that works as both a server function and the original [procedure](/docs/procedure) on the server. + +```ts +import { createServerFunctionable } from '@orpc/next' + +const functionable = createServerFunctionable({ + context: async () => { // <- provide initial context if needed + return { user: { id: '123', name: 'Alice' } } + }, +}) + +// Works as both a server function and a procedure. +export const functionableProcedure = functionable( + os.handler(async () => 'Hello from oRPC + Next.js!') +) +``` + +### `.actionable` Extension + +Import `@orpc/next/extensions/actionable` from a module that always runs during initialization, such as the file where you define your base builder. This adds an `.actionable` method to decorated procedures. Like `createServerFunctionable`, it returns a value that works as both a server function and a [procedure](/docs/procedure). + +::: code-group + +```ts [usage] +export const functionableProcedure = base + .handler(async () => 'Hello from oRPC + Next.js!') + .actionable({ + context: async () => { // <- provide initial context if needed + return { user: { id: '123', name: 'Alice' } } + }, + }) +``` + +```ts [setup] +import '@orpc/next/extensions/actionable' + +import { os } from '@orpc/server' + +export const base = os +``` + +::: + +### Hooks + +This integration also includes React hooks for server functions. `useServerFunction` executes a server function and tracks its status. `useOptimisticServerFunction` does the same, with optimistic updates. Unlike direct server function calls, hook errors are deserialized into native `ORPCError` instances instead of plain JSON (`ORPCErrorJSON`) for a more natural developer experience. + +::: code-group + +```tsx [useServerFunction] +'use client' + +import { useServerFunction } from '@orpc/next/hooks' +import { + getIssueMessage, + isInferableError, + onErrorDeferred, + parseFormData, +} from '@orpc/next/hooks' + +export function MyComponent() { + const { execute, data, error, status } = useServerFunction(serverFunction, { + interceptors: [ + onErrorDeferred((error) => { + if (isInferableError(error)) { + console.error(error.data) + // ^ Typed error data + } + }), + ], + }) + + return ( +
execute(parseFormData(form))}> + + {getIssueMessage(error, 'name')} + + + {status === 'pending' &&

Loading...

} +
+ ) +} +``` + +```tsx [useOptimisticServerFunction] +'use client' + +import { useOptimisticServerAction } from '@orpc/next/hooks' +import { + getIssueMessage, + onSuccessDeferred, + parseFormData, +} from '@orpc/next' + +export function MyComponent() { + const [todos, setTodos] = useState([]) + const { execute, optimisticState } = useOptimisticServerAction(someAction, { + optimisticPassthrough: todos, + optimisticReducer: (currentState, newTodo) => [...currentState, newTodo], + interceptors: [ + onSuccessDeferred(({ data }) => { + setTodos(prevTodos => [...prevTodos, data]) + }), + ], + }) + + return ( +
+
    + {optimisticState.map(todo => ( +
  • {todo.todo}
  • + ))} +
+
execute(parseFormData(form))}> + + {getIssueMessage(error, 'todo')} + + +
+
+ ) +} +``` + +::: + +::: info +Besides hooks, this integration also re-exports [form-data helpers](/docs/helpers/form-data) for working with `FormData`, as well as deferred interceptors for updating UI states: `onStartDeferred`, `onSuccessDeferred`, `onErrorDeferred`, and `onFinishDeferred`. +::: + +::: info +You can use [`safe` and `isInferableError`](/docs/client/error-handling#using-safe-and-isinferableerror) together for typesafe error handling in interceptors. +::: + +## Server Form Functions + +Use `createServerFormFunction` to turn a procedure into a form action for `
`. Unlike `createServerFunction`, the returned function accepts `FormData` instead of the procedure input. It deserializes that data using [Bracket Notation](/docs/openapi/bracket-notation), then passes the result to the procedure. + +::: code-group + +```tsx [client] +export default function Page() { + return ( + + + +
+ ) +} +``` + +```ts [server] +'use server' + +import { redirect } from 'next/navigation' + +const procedure = os + .input(z.object({ name: z.string() })) + .handler(async ({ input }) => { + // do something + }) + +export const serverFormFunction = createServerFormFunction(procedure, { + interceptors: [ + async ({ next }) => { + await next() + redirect('/thank-you') // redirect on success + } + ] +}) +``` + +::: + +### `createServerFormFunctionable` + +If you reuse the same options across multiple form actions, `createServerFormFunctionable` creates a preconfigured helper. Like [`createServerFunctionable`](#createserverfunctionable), it takes a procedure and returns a value that works as both a server form function and the original [procedure](/docs/procedure). + +```ts +import { createServerFormFunctionable } from '@orpc/next' + +const formFunctionable = createServerFormFunctionable({ + context: async () => { // <- provide initial context if needed + return { user: { id: '123', name: 'Alice' } } + }, +}) + +// Works as both a server form function and a procedure. +export const formFunctionableProcedure = formFunctionable( + os.handler(async () => 'Hello from oRPC + Next.js!') +) +``` diff --git a/apps/content/docs/integrations/opentelemetry.md b/apps/content/docs/integrations/opentelemetry.md index 8f465fc0f..63fbd2dfd 100644 --- a/apps/content/docs/integrations/opentelemetry.md +++ b/apps/content/docs/integrations/opentelemetry.md @@ -1,11 +1,6 @@ ---- -title: OpenTelemetry Integration -description: Seamlessly integrate oRPC with OpenTelemetry for distributed tracing ---- - # OpenTelemetry Integration -[OpenTelemetry](https://opentelemetry.io/) provides observability APIs and instrumentation for applications. oRPC integrates seamlessly with OpenTelemetry to instrument your APIs for distributed tracing. +[OpenTelemetry](https://opentelemetry.io/) integration adds automatic instrumentation to oRPC applications, enabling distributed tracing and performance monitoring with minimal setup. ::: warning This guide assumes familiarity with [OpenTelemetry](https://opentelemetry.io/). Review the official documentation if needed. @@ -22,36 +17,36 @@ See the complete example in our [Bun WebSocket + OpenTelemetry Playground](/docs ::: code-group ```sh [npm] -npm install @orpc/otel@latest +npm install @orpc/opentelemetry@latest ``` ```sh [yarn] -yarn add @orpc/otel@latest +yarn add @orpc/opentelemetry@latest ``` ```sh [pnpm] -pnpm add @orpc/otel@latest +pnpm add @orpc/opentelemetry@latest ``` ```sh [bun] -bun add @orpc/otel@latest +bun add @orpc/opentelemetry@latest ``` ```sh [deno] -deno add npm:@orpc/otel@latest +deno add npm:@orpc/opentelemetry@latest ``` ::: ## Setup -To set up OpenTelemetry with oRPC, use the `ORPCInstrumentation` class. This class automatically instruments your oRPC client and server for distributed tracing. +To integrate OpenTelemetry with oRPC, use `ORPCInstrumentation`. It automatically instruments both client and server for distributed tracing. ::: code-group ```ts twoslash [server] import { NodeSDK } from '@opentelemetry/sdk-node' -import { ORPCInstrumentation } from '@orpc/otel' +import { ORPCInstrumentation } from '@orpc/opentelemetry' const sdk = new NodeSDK({ instrumentations: [ @@ -65,7 +60,7 @@ sdk.start() ```ts twoslash [client] import { WebTracerProvider } from '@opentelemetry/sdk-trace-web' import { registerInstrumentations } from '@opentelemetry/instrumentation' -import { ORPCInstrumentation } from '@orpc/otel' +import { ORPCInstrumentation } from '@orpc/opentelemetry' const provider = new WebTracerProvider() @@ -81,7 +76,21 @@ registerInstrumentations({ ::: ::: info -While OpenTelemetry can be used on both server and client sides, using it on the server only is sufficient in most cases. +You can configure OpenTelemetry for your server, client, or both, depending on your needs. +::: + +## Context Propagation + +By default, `ORPCInstrumentation` enables [context propagation](https://opentelemetry.io/docs/concepts/context-propagation/) between the client and server. You can disable it by setting `propagationEnabled` to `false` if you do not need it or if another instrumentation already handles it. + +```ts +const instrumentation = new ORPCInstrumentation({ + propagationEnabled: false, +}) +``` + +::: warning +Popular instrumentations that already handle context propagation include [@hono/otel](https://www.npmjs.com/package/@hono/otel), [@opentelemetry/instrumentation-http](https://www.npmjs.com/package/@opentelemetry/instrumentation-http), and [@opentelemetry/instrumentation-fetch](https://www.npmjs.com/package/@opentelemetry/instrumentation-fetch). ::: ## Middleware Span @@ -109,41 +118,6 @@ Object.defineProperty(someMiddleware, 'name', { Define the `name` property on your middleware to improve span naming and make traces easier to read. ::: -## Handling Uncaught Exceptions - -oRPC may throw errors before they reach the error handling layer, such as invalid WebSocket messages or adapter interceptor errors. We recommend capturing these errors: - -```ts -import { SpanStatusCode, trace } from '@opentelemetry/api' - -const tracer = trace.getTracer('uncaught-errors') - -function recordError(eventName: string, reason: unknown) { - const span = tracer.startSpan(eventName) - const message = String(reason) - - if (reason instanceof Error) { - span.recordException(reason) - } - else { - span.recordException({ message }) - } - - span.setStatus({ code: SpanStatusCode.ERROR, message }) - span.end() -} - -process.on('uncaughtException', (reason) => { - recordError('uncaughtException', reason) - // process.exit(1) // uncomment to restore default Node.js behavior -}) - -process.on('unhandledRejection', (reason) => { - recordError('unhandledRejection', reason) - // process.exit(1) // uncomment to restore default Node.js behavior -}) -``` - ## Capture Abort Signals If your application heavily uses [Event Iterator](/docs/event-iterator) or similar streaming patterns, we recommend capturing an event when the `signal` is aborted to properly track and detach unexpected long-running operations: @@ -165,11 +139,3 @@ const handler = new RPCHandler(router, { ], }) ``` - -## Context Propagation - -When using oRPC with HTTP/fetch adapters, you should set up proper HTTP instrumentation for [context propagation](https://opentelemetry.io/docs/concepts/context-propagation/) on both client and server. This ensures trace context propagates between services, maintaining distributed tracing integrity. - -::: info -Common libraries for HTTP instrumentation include [@hono/otel](https://www.npmjs.com/package/@hono/otel), [@opentelemetry/instrumentation-http](https://www.npmjs.com/package/@opentelemetry/instrumentation-http), [@opentelemetry/instrumentation-fetch](https://www.npmjs.com/package/@opentelemetry/instrumentation-fetch), etc. -::: diff --git a/apps/content/docs/integrations/pinia-colada.md b/apps/content/docs/integrations/pinia-colada.md deleted file mode 100644 index c8be463b7..000000000 --- a/apps/content/docs/integrations/pinia-colada.md +++ /dev/null @@ -1,171 +0,0 @@ ---- -title: Pinia Colada Integration -description: Seamlessly integrate oRPC with Pinia Colada ---- - -# Pinia Colada Integration - -[Pinia Colada](https://pinia-colada.esm.dev/) is the data fetching layer for Pinia and Vue. oRPC's integration with Pinia Colada is lightweight and straightforward - there's no extra overhead. - -::: warning -This documentation assumes you are already familiar with [Pinia Colada](https://pinia-colada.esm.dev/). If you need a refresher, please review the official Pinia Colada documentation before proceeding. -::: - -::: warning -[Pinia Colada](https://pinia-colada.esm.dev/) is still in an unstable stage. As a result, this integration may introduce breaking changes in the future to keep up with its ongoing development. -::: - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/vue-colada@latest @pinia/colada@latest -``` - -```sh [yarn] -yarn add @orpc/vue-colada@latest @pinia/colada@latest -``` - -```sh [pnpm] -pnpm add @orpc/vue-colada@latest @pinia/colada@latest -``` - -```sh [bun] -bun add @orpc/vue-colada@latest @pinia/colada@latest -``` - -```sh [deno] -deno add npm:@orpc/vue-colada@latest npm:@pinia/colada@latest -``` - -::: - -## Setup - -Before you begin, ensure you have already configured a [server-side client](/docs/client/server-side) or a [client-side client](/docs/client/client-side). - -```ts twoslash -import { router } from './shared/planet' -import { RouterClient } from '@orpc/server' - -declare const client: RouterClient -// ---cut--- -import { createORPCVueColadaUtils } from '@orpc/vue-colada' - -export const orpc = createORPCVueColadaUtils(client) - -orpc.planet.find.queryOptions({ input: { id: 123 } }) -// ^| - -// -``` - -## Avoiding Query/Mutation Key Conflicts - -Prevent key conflicts by passing a unique base key when creating your utils: - -```ts -const userORPC = createORPCVueColadaUtils(userClient, { - path: ['user'] -}) -const postORPC = createORPCVueColadaUtils(postClient, { - path: ['post'] -}) -``` - -## Query Options Utility - -Use `.queryOptions` to configure queries. Use it with hooks like `useQuery`, `useSuspenseQuery`, or `prefetchQuery`. - -```ts twoslash -import { router } from './shared/planet' -import { RouterClient } from '@orpc/server' -import { RouterUtils } from '@orpc/vue-colada' -import { useQuery } from '@pinia/colada' - -declare const orpc: RouterUtils> -// ---cut--- -const query = useQuery(orpc.planet.find.queryOptions({ - input: { id: 123 }, // Specify input if needed - context: { cache: true }, // Provide client context if needed - // additional options... -})) -``` - -## Mutation Options - -Use `.mutationOptions` to create options for mutations. Use it with hooks like `useMutation`. - -```ts twoslash -import { router } from './shared/planet' -import { RouterClient } from '@orpc/server' -import { RouterUtils } from '@orpc/vue-colada' -import { useMutation } from '@pinia/colada' - -declare const orpc: RouterUtils> -// ---cut--- -const mutation = useMutation(orpc.planet.create.mutationOptions({ - context: { cache: true }, // Provide client context if needed - // additional options... -})) - -mutation.mutate({ name: 'Earth' }) -``` - -## Query/Mutation Key - -Use `.key` to generate a `QueryKey` or `MutationKey`. This is useful for tasks such as revalidating queries, checking mutation status, etc. - -```ts twoslash -import { router } from './shared/planet' -import { RouterClient } from '@orpc/server' -import { RouterUtils } from '@orpc/vue-colada' -import { useQueryCache } from '@pinia/colada' - -declare const orpc: RouterUtils> -// ---cut--- -const queryCache = useQueryCache() - -// Invalidate all planet queries -queryCache.invalidateQueries({ - key: orpc.planet.key(), -}) - -// Invalidate the planet find query with id 123 -queryCache.invalidateQueries({ - key: orpc.planet.find.key({ input: { id: 123 } }) -}) -``` - -## Calling Procedure Clients - -Use `.call` to call a procedure client directly. It's an alias for corresponding procedure client. - -```ts -const result = orpc.planet.find.call({ id: 123 }) -``` - -## Error Handling - -Easily manage type-safe errors using our built-in `isDefinedError` helper. - -```ts -import { isDefinedError } from '@orpc/client' - -const mutation = useMutation(orpc.planet.create.mutationOptions({ - onError: (error) => { - if (isDefinedError(error)) { - // Handle the error here - } - }, -})) - -mutation.mutate({ name: 'Earth' }) - -if (mutation.error.value && isDefinedError(mutation.error.value)) { - // Handle the error here -} -``` - -For more details, see our [type-safe error handling guide](/docs/error-handling#type‐safe-error-handling). diff --git a/apps/content/docs/integrations/pino.md b/apps/content/docs/integrations/pino.md index e0d0e5f09..4f2eb2139 100644 --- a/apps/content/docs/integrations/pino.md +++ b/apps/content/docs/integrations/pino.md @@ -1,11 +1,6 @@ ---- -title: Pino Integration -description: Integrate oRPC with Pino for structured logging and request tracking. ---- - # Pino Integration -[Pino](https://getpino.io/) is a fast and lightweight JSON logger. This guide explains how to integrate oRPC with Pino to add structured logging, request tracking, and error monitoring to your applications. +[Pino](https://getpino.io/) integration for oRPC provides structured logging capabilities, allowing you to easily track requests, monitor errors, and gain insights into your application's behavior. ::: warning This guide assumes familiarity with [Pino](https://getpino.io/). Review the official documentation if needed. @@ -16,52 +11,53 @@ This guide assumes familiarity with [Pino](https://getpino.io/). Review the offi ::: code-group ```sh [npm] -npm install @orpc/experimental-pino@latest pino@latest +npm install @orpc/pino@latest pino@latest ``` ```sh [yarn] -yarn add @orpc/experimental-pino@latest pino@latest +yarn add @orpc/pino@latest pino@latest ``` ```sh [pnpm] -pnpm add @orpc/experimental-pino@latest pino@latest +pnpm add @orpc/pino@latest pino@latest ``` ```sh [bun] -bun add @orpc/experimental-pino@latest pino@latest +bun add @orpc/pino@latest pino@latest ``` ```sh [deno] -deno add npm:@orpc/experimental-pino@latest npm:pino@latest +deno add npm:@orpc/pino@latest npm:pino@latest ``` ::: ## Setup -To set up Pino with oRPC, use the `LoggingHandlerPlugin` class. This plugin automatically instruments your handler with structured logging, request tracking, and error monitoring. +To set up Pino with oRPC, use the `PinoHandlerPlugin` class. This plugin automatically instruments your handler with structured logging, request tracking, and error monitoring. -```ts -import { LoggingHandlerPlugin } from '@orpc/experimental-pino' +```ts twoslash +import { RPCHandler } from '@orpc/server/fetch' +import { router } from './shared/planet' +// ---cut--- +import { PinoHandlerPlugin } from '@orpc/pino' import pino from 'pino' const logger = pino() const handler = new RPCHandler(router, { plugins: [ - new LoggingHandlerPlugin({ - logger, // Custom logger instance - generateId: ({ request }) => crypto.randomUUID(), // Custom ID generator - logRequestResponse: true, // Log request start/end (disabled by default) - logRequestAbort: true, // Log when requests are aborted (disabled by default) + new PinoHandlerPlugin({ + logger, // <- custom logger instance + generateRequestId: ({ request }) => crypto.randomUUID(), // <- custom request id generator + logLifecycle: true, // <- log information about request lifecycle (disabled by default) + logAbort: true, // <- log information when requests are aborted (disabled by default) }), ], }) ``` -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: + ::: tip For improved log readability during development, consider using [pino-pretty](https://github.com/pinojs/pino-pretty) to format your logs in a human-friendly way. @@ -77,12 +73,12 @@ npm run dev | npx pino-pretty You can access the logger from the context object using the `getLogger` function: ```ts -import { getLogger, LoggerContext } from '@orpc/experimental-pino' +import { getLogger, LoggerContext } from '@orpc/pino' -interface ORPCContext extends LoggerContext {} // [!code highlight] +interface ServerContext extends LoggerContext {} // [!code highlight] const procedure = os - .$context() + .$context() .handler(({ context }) => { const logger = getLogger(context) // [!code highlight] @@ -99,23 +95,23 @@ You can provide a custom logger instance for specific requests by passing it thr ```ts import { - CONTEXT_LOGGER_SYMBOL, + LOGGER_CONTEXT_SYMBOL, LoggerContext, - LoggingHandlerPlugin -} from '@orpc/experimental-pino' + PinoHandlerPlugin +} from '@orpc/pino' const logger = pino() const httpLogger = pinoHttp({ logger }) -interface ORPCContext extends LoggerContext {} // [!code highlight] +interface ServerContext extends LoggerContext {} // [!code highlight] const router = { - ping: os.$context().handler(() => 'pong') + ping: os.$context().handler(() => 'pong') } const handler = new RPCHandler(router, { plugins: [ - new LoggingHandlerPlugin({ logger }), // [!code highlight] + new PinoHandlerPlugin({ logger }), // [!code highlight] ], }) @@ -125,7 +121,7 @@ const server = createServer(async (req, res) => { const { matched } = await handler.handle(req, res, { prefix: '/api', context: { - [CONTEXT_LOGGER_SYMBOL]: req.log, // [!code highlight] + [LOGGER_CONTEXT_SYMBOL]: req.log, // [!code highlight] }, }) diff --git a/apps/content/docs/integrations/react-swr.md b/apps/content/docs/integrations/react-swr.md deleted file mode 100644 index a5ec5ebcf..000000000 --- a/apps/content/docs/integrations/react-swr.md +++ /dev/null @@ -1,204 +0,0 @@ ---- -title: React SWR Integration -description: Integrate oRPC with React SWR for efficient data fetching and caching. ---- - -# React SWR Integration - -[SWR](https://swr.vercel.app/) is a React Hooks library for data fetching that provides features like caching, revalidation, and more. oRPC SWR integration is very lightweight and straightforward - there's no extra overhead. - -::: warning -This documentation assumes you are already familiar with [SWR](https://swr.vercel.app/). If you need a refresher, please review the official SWR documentation before proceeding. -::: - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/experimental-react-swr@latest -``` - -```sh [yarn] -yarn add @orpc/experimental-react-swr@latest -``` - -```sh [pnpm] -pnpm add @orpc/experimental-react-swr@latest -``` - -```sh [bun] -bun add @orpc/experimental-react-swr@latest -``` - -```sh [deno] -deno add npm:@orpc/experimental-react-swr@latest -``` - -::: - -::: warning -The `experimental-` prefix indicates that this integration is still in development and may change in the future. -::: - -## Setup - -Before you begin, ensure you have already configured a [server-side client](/docs/client/server-side) or a [client-side client](/docs/client/client-side). - -```ts twoslash -import { router } from './shared/planet' -import { RouterClient } from '@orpc/server' - -declare const client: RouterClient -// ---cut--- -import { createSWRUtils } from '@orpc/experimental-react-swr' - -export const orpc = createSWRUtils(client) - -orpc.planet.find.key({ input: { id: 123 } }) -// ^| - -// - -// - -// - -// -``` - -::: details Avoiding Key Conflicts? - -You can easily avoid key conflicts by passing a unique base key when creating your utils: - -```ts -const userORPC = createSWRUtils(userClient, { - path: ['user'] -}) - -const postORPC = createSWRUtils(postClient, { - path: ['post'] -}) -``` - -::: - -## Data Fetching - -Use `.key` and `.fetcher` methods to configure `useSWR` for data fetching: - -```ts -import useSWR from 'swr' - -const { data, error, isLoading } = useSWR( - orpc.planet.find.key({ input: { id: 123 } }), - orpc.planet.find.fetcher({ context: { cache: true } }), // Provide client context if needed -) -``` - -## Infinite Queries - -Use `.key` and `.fetcher` methods to configure `useSWRInfinite` for infinite queries: - -```ts -import useSWRInfinite from 'swr/infinite' - -const { data, error, isLoading, size, setSize } = useSWRInfinite( - (index, previousPageData) => { - if (previousPageData && !previousPageData.nextCursor) { - return null // reached the end - } - - return orpc.planet.list.key({ input: { cursor: previousPageData?.nextCursor } }) - }, - orpc.planet.list.fetcher({ context: { cache: true } }), // Provide client context if needed -) -``` - -## Subscriptions - -Use `.key` and `.subscriber` methods to configure `useSWRSubscription` to subscribe to an [Event Iterator](/docs/event-iterator): - -```ts -import useSWRSubscription from 'swr/subscription' - -const { data, error } = useSWRSubscription( - orpc.streamed.key({ input: { id: 3 } }), - orpc.streamed.subscriber({ context: { cache: true }, maxChunks: 10 }), // Provide client context if needed -) -``` - -Use `.liveSubscriber` to subscribe to the latest events without chunking: - -```ts -import useSWRSubscription from 'swr/subscription' - -const { data, error } = useSWRSubscription( - orpc.streamed.key({ input: { id: 3 } }), - orpc.streamed.liveSubscriber({ context: { cache: true } }), // Provide client context if needed -) -``` - -## Mutations - -Use `.key` and `.mutator` methods to configure `useSWRMutation` for mutations with automatic revalidation on success: - -```ts -import useSWRMutation from 'swr/mutation' - -const { trigger, isMutating } = useSWRMutation( - orpc.planet.list.key(), - orpc.planet.create.mutator({ context: { cache: true } }), // Provide client context if needed -) - -trigger({ name: 'New Planet' }) // auto revalidate orpc.planet.list.key() on success -``` - -## Manual Revalidation - -Use `.matcher` to invalidate data manually: - -```ts -import { mutate } from 'swr' - -mutate(orpc.matcher()) // invalidate all orpc data -mutate(orpc.planet.matcher()) // invalidate all planet data -mutate(orpc.planet.find.matcher({ input: { id: 123 }, strategy: 'exact' })) // invalidate specific planet data -``` - -## Calling Clients - -Use `.call` to call a procedure client directly. It's an alias for corresponding procedure client. - -```ts -const planet = await orpc.planet.find.call({ id: 123 }) -``` - -## Operation Context - -When clients are invoked through the SWR integration, an **operation context** is automatically added to the [client context](/docs/client/rpc-link#using-client-context). This context can be used to configure the request behavior, like setting the HTTP method. - -```ts -import { - SWR_OPERATION_CONTEXT_SYMBOL, - SWROperationContext, -} from '@orpc/experimental-react-swr' - -interface ClientContext extends SWROperationContext { -} - -const GET_OPERATION_TYPE = new Set(['fetcher', 'subscriber', 'liveSubscriber']) - -const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - method: ({ context }, path) => { - const operationType = context[SWR_OPERATION_CONTEXT_SYMBOL]?.type - - if (operationType && GET_OPERATION_TYPE.has(operationType)) { - return 'GET' - } - - return 'POST' - }, -}) -``` diff --git a/apps/content/docs/integrations/sentry.md b/apps/content/docs/integrations/sentry.md deleted file mode 100644 index e0b23a967..000000000 --- a/apps/content/docs/integrations/sentry.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -title: Sentry Integration -description: Integrate oRPC with Sentry for error tracking and performance monitoring. ---- - -# Sentry Integration - -[Sentry](https://sentry.io/) is a powerful tool for error tracking and performance monitoring. This guide explains how to integrate oRPC with Sentry to capture errors and performance metrics in your applications. - -::: warning -This guide assumes familiarity with [Sentry](https://sentry.io/). Review the official documentation if needed. -::: - -::: info -This integration is based on the [OpenTelemetry Integration](/docs/integrations/opentelemetry), so you can refer to that guide for more details on setting up OpenTelemetry with oRPC. -::: - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/otel@latest -``` - -```sh [yarn] -yarn add @orpc/otel@latest -``` - -```sh [pnpm] -pnpm add @orpc/otel@latest -``` - -```sh [bun] -bun add @orpc/otel@latest -``` - -```sh [deno] -deno add npm:@orpc/otel@latest -``` - -::: - -## Setup - -To set up OpenTelemetry with oRPC, use the `ORPCInstrumentation` class. This class automatically instruments your oRPC client and server for distributed tracing. - -```ts twoslash -import * as Sentry from '@sentry/node' -import { ORPCInstrumentation } from '@orpc/otel' - -Sentry.init({ - dsn: '...', - sendDefaultPii: true, - - tracesSampleRate: 1.0, // enable tracing [!code highlight] - - openTelemetryInstrumentations: [ - new ORPCInstrumentation(), // [!code highlight] - ] -}) -``` - -## Capturing Errors - -Since Sentry does not yet support collecting [OpenTelemetry span events](https://opentelemetry.io/docs/concepts/signals/traces/#span-events), you should capture errors that occur in business logic manually. You can use `interceptors`, `middleware`, or other error handling mechanisms. - -```ts twoslash -import * as Sentry from '@sentry/node' -import { os } from '@orpc/server' - -export const sentryMiddleware = os.middleware(async ({ next }) => { - try { - return await next() - } - catch (error) { - Sentry.captureException(error) // [!code highlight] - throw error - } -}) - -export const base = os.use(sentryMiddleware) -``` diff --git a/apps/content/docs/integrations/tanstack-query-old/basic.md b/apps/content/docs/integrations/tanstack-query-old/basic.md deleted file mode 100644 index 67b00be8e..000000000 --- a/apps/content/docs/integrations/tanstack-query-old/basic.md +++ /dev/null @@ -1,144 +0,0 @@ ---- -title: Tanstack Query Integration -description: Seamlessly integrate oRPC with Tanstack Query ---- - -# Tanstack Query Integration - -[Tanstack Query](https://tanstack.com/query/latest) is a robust solution for asynchronous state management. oRPC's integration with Tanstack Query is lightweight and straightforward - there's no extra overhead. - -| Library | Tanstack Query | oRPC Integration | -| ------- | -------------- | ------------------------- | -| React | ✅ | ✅ | -| Vue | ✅ | ✅ | -| Angular | ✅ | ✅ (New Integration Only) | -| Solid | ✅ | ✅ | -| Svelte | ✅ | ✅ | - -::: warning -This documentation assumes you are already familiar with [Tanstack Query](https://tanstack.com/query/latest). If you need a refresher, please review the official Tanstack Query documentation before proceeding. -::: - -## Query Options Utility - -Use `.queryOptions` to configure queries. Use it with hooks like `useQuery`, `useSuspenseQuery`, or `prefetchQuery`. - -```ts -const query = useQuery(orpc.planet.find.queryOptions({ - input: { id: 123 }, // Specify input if needed - context: { cache: true }, // Provide client context if needed - // additional options... -})) -``` - -## Infinite Query Options Utility - -Use `.infiniteOptions` to configure infinite queries. Use it with hooks like `useInfiniteQuery`, `useSuspenseInfiniteQuery`, or `prefetchInfiniteQuery`. - -::: info -The `input` parameter must be a function that accepts the page parameter and returns the query input. Be sure to define the type for `pageParam` if it can be `null` or `undefined`. -::: - -```ts -const query = useInfiniteQuery(orpc.planet.list.infiniteOptions({ - input: (pageParam: number | undefined) => ({ limit: 10, offset: pageParam }), - context: { cache: true }, // Provide client context if needed - initialPageParam: undefined, - getNextPageParam: lastPage => lastPage.nextPageParam, - // additional options... -})) -``` - -## Mutation Options - -Use `.mutationOptions` to create options for mutations. Use it with hooks like `useMutation`. - -```ts -const mutation = useMutation(orpc.planet.create.mutationOptions({ - context: { cache: true }, // Provide client context if needed - // additional options... -})) - -mutation.mutate({ name: 'Earth' }) -``` - -## Query/Mutation Key - -Use `.key` to generate a `QueryKey` or `MutationKey`. This is useful for tasks such as revalidating queries, checking mutation status, etc. - -:::info -The `.key` accepts partial deep input, there's no need to supply full input. -::: - -```ts -const queryClient = useQueryClient() - -// Invalidate all planet queries -queryClient.invalidateQueries({ - queryKey: orpc.planet.key(), -}) - -// Invalidate only regular (non-infinite) planet queries -queryClient.invalidateQueries({ - queryKey: orpc.planet.key({ type: 'query' }) -}) - -// Invalidate the planet find query with id 123 -queryClient.invalidateQueries({ - queryKey: orpc.planet.find.key({ input: { id: 123 } }) -}) -``` - -## Calling Procedure Clients - -Use `.call` to call a procedure client directly. It's an alias for corresponding procedure client. - -```ts -const result = orpc.planet.find.call({ id: 123 }) -``` - -## Error Handling - -Easily manage type-safe errors using our built-in `isDefinedError` helper. - -```ts -import { isDefinedError } from '@orpc/client' - -const mutation = useMutation(orpc.planet.create.mutationOptions({ - onError: (error) => { - if (isDefinedError(error)) { - // Handle the error here - } - } -})) - -mutation.mutate({ name: 'Earth' }) - -if (mutation.error && isDefinedError(mutation.error)) { - // Handle the error here -} -``` - -For more details, see our [type-safe error handling guide](/docs/error-handling#type‐safe-error-handling). - -## `skipToken` for Disabling Queries - -The `skipToken` symbol offers a type-safe alternative to the `disabled` option when you need to conditionally disable a query by omitting its `input`. - -```ts -const query = useQuery( - orpc.planet.list.queryOptions({ - input: search ? { search } : skipToken, // [!code highlight] - }) -) - -const query = useInfiniteQuery( - orpc.planet.list.infiniteOptions({ - input: search // [!code highlight] - ? (offset: number | undefined) => ({ limit: 10, offset, search }) // [!code highlight] - : skipToken, // [!code highlight] - initialPageParam: undefined, - getNextPageParam: lastPage => lastPage.nextPageParam, - }) -) -``` diff --git a/apps/content/docs/integrations/tanstack-query-old/react.md b/apps/content/docs/integrations/tanstack-query-old/react.md deleted file mode 100644 index a15df3cef..000000000 --- a/apps/content/docs/integrations/tanstack-query-old/react.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -title: Tanstack Query Integration For React -description: Seamlessly integrate oRPC with Tanstack Query for React ---- - -# Tanstack Query Integration For React - -This guide shows how to integrate oRPC with Tanstack Query for React. For an introduction, please review the [Basic Guide](/docs/integrations/tanstack-query-old/basic) first. - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/react-query@latest @tanstack/react-query@latest -``` - -```sh [yarn] -yarn add @orpc/react-query@latest @tanstack/react-query@latest -``` - -```sh [pnpm] -pnpm add @orpc/react-query@latest @tanstack/react-query@latest -``` - -```sh [bun] -bun add @orpc/react-query@latest @tanstack/react-query@latest -``` - -```sh [deno] -deno add npm:@orpc/react-query@latest npm:@tanstack/react-query@latest -``` - -::: - -## Setup - -Before you begin, ensure you have already configured a [server-side client](/docs/client/server-side) or a [client-side client](/docs/client/client-side). - -```ts twoslash -import { router } from './shared/planet' -import { RouterClient } from '@orpc/server' - -declare const client: RouterClient -// ---cut--- -import { createORPCReactQueryUtils } from '@orpc/react-query' - -export const orpc = createORPCReactQueryUtils(client) - -orpc.planet.find.queryOptions({ input: { id: 123 } }) -// ^| - -// -``` - -## Avoiding Query/Mutation Key Conflicts - -Prevent key conflicts by passing a unique base key when creating your utils: - -```ts -const userORPC = createORPCReactQueryUtils(userClient, { - path: ['user'] -}) -const postORPC = createORPCReactQueryUtils(postClient, { - path: ['post'] -}) -``` - -## Using React Context - -Integrate oRPC React Query utils into your React app with Context: - -1. **Create the Context:** - - ```ts twoslash - import { router } from './shared/planet' - // ---cut--- - import { createContext, use } from 'react' - import { RouterUtils } from '@orpc/react-query' - import { RouterClient } from '@orpc/server' - - type ORPCReactUtils = RouterUtils> - - export const ORPCContext = createContext(undefined) - - export function useORPC(): ORPCReactUtils { - const orpc = use(ORPCContext) - if (!orpc) { - throw new Error('ORPCContext is not set up properly') - } - return orpc - } - ``` - -2. **Provide the Context in Your App:** - - ```tsx - export function App() { - const [client] = useState>(() => createORPCClient(link)) - const [orpc] = useState(() => createORPCReactQueryUtils(client)) - - return ( - - - - ) - } - ``` - -3. **Use the Utils in Components:** - - ```ts twoslash - import { router } from './shared/planet' - import { RouterClient } from '@orpc/server' - import { RouterUtils } from '@orpc/react-query' - import { useQuery } from '@tanstack/react-query' - - declare function useORPC(): RouterUtils> - // ---cut--- - const orpc = useORPC() - - const query = useQuery(orpc.planet.find.queryOptions({ input: { id: 123 } })) - ``` diff --git a/apps/content/docs/integrations/tanstack-query-old/solid.md b/apps/content/docs/integrations/tanstack-query-old/solid.md deleted file mode 100644 index 4486aefe5..000000000 --- a/apps/content/docs/integrations/tanstack-query-old/solid.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: Tanstack Query Integration For Solid -description: Seamlessly integrate oRPC with Tanstack Query for Solid ---- - -# Tanstack Query Integration For Solid - -This guide shows how to integrate oRPC with Tanstack Query for Solid. For an introduction, please review the [Basic Guide](/docs/integrations/tanstack-query-old/basic) first. - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/solid-query@latest @tanstack/solid-query@latest -``` - -```sh [yarn] -yarn add @orpc/solid-query@latest @tanstack/solid-query@latest -``` - -```sh [pnpm] -pnpm add @orpc/solid-query@latest @tanstack/solid-query@latest -``` - -```sh [bun] -bun add @orpc/solid-query@latest @tanstack/solid-query@latest -``` - -```sh [deno] -deno add npm:@orpc/solid-query@latest npm:@tanstack/solid-query@latest -``` - -::: - -## Setup - -Before you begin, ensure you have already configured a [server-side client](/docs/client/server-side) or a [client-side client](/docs/client/client-side). - -```ts twoslash -import { router } from './shared/planet' -import { RouterClient } from '@orpc/server' - -declare const client: RouterClient -// ---cut--- -import { createORPCSolidQueryUtils } from '@orpc/solid-query' - -export const orpc = createORPCSolidQueryUtils(client) - -orpc.planet.find.queryOptions({ input: { id: 123 } }) -// ^| - -// -``` - -## Avoiding Query/Mutation Key Conflicts - -Prevent key conflicts by passing a unique base key when creating your utils: - -```ts -const userORPC = createORPCSolidQueryUtils(userClient, { - path: ['user'] -}) -const postORPC = createORPCSolidQueryUtils(postClient, { - path: ['post'] -}) -``` - -## Usage - -:::warning -Unlike the React version, when creating a Solid Query Signal, the first argument must be a callback. -::: - -```ts twoslash -import type { router } from './shared/planet' -import type { RouterClient } from '@orpc/server' -import type { RouterUtils } from '@orpc/solid-query' - -declare const orpc: RouterUtils> -declare const condition: boolean -// ---cut--- -import { createQuery } from '@tanstack/solid-query' - -const query = createQuery( - () => orpc.planet.find.queryOptions({ input: { id: 123 } }) -) -``` diff --git a/apps/content/docs/integrations/tanstack-query-old/svelte.md b/apps/content/docs/integrations/tanstack-query-old/svelte.md deleted file mode 100644 index 95c2949e4..000000000 --- a/apps/content/docs/integrations/tanstack-query-old/svelte.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Tanstack Query Integration For Svelte -description: Seamlessly integrate oRPC with Tanstack Query for Svelte ---- - -# Tanstack Query Integration For Svelte - -This guide shows how to integrate oRPC with Tanstack Query for Svelte. For an introduction, please review the [Basic Guide](/docs/integrations/tanstack-query-old/basic) first. - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/svelte-query@latest @tanstack/svelte-query@latest -``` - -```sh [yarn] -yarn add @orpc/svelte-query@latest @tanstack/svelte-query@latest -``` - -```sh [pnpm] -pnpm add @orpc/svelte-query@latest @tanstack/svelte-query@latest -``` - -```sh [bun] -bun add @orpc/svelte-query@latest @tanstack/svelte-query@latest -``` - -```sh [deno] -deno add npm:@orpc/svelte-query@latest npm:@tanstack/svelte-query@latest -``` - -::: - -## Setup - -Before you begin, ensure you have already configured a [server-side client](/docs/client/server-side) or a [client-side client](/docs/client/client-side). - -```ts twoslash -import { router } from './shared/planet' -import { RouterClient } from '@orpc/server' - -declare const client: RouterClient -// ---cut--- -import { createORPCSvelteQueryUtils } from '@orpc/svelte-query' - -export const orpc = createORPCSvelteQueryUtils(client) - -orpc.planet.find.queryOptions({ input: { id: 123 } }) -// ^| - -// -``` - -## Avoiding Query/Mutation Key Conflicts - -Prevent key conflicts by passing a unique base key when creating your utils: - -```ts -const userORPC = createORPCSvelteQueryUtils(userClient, { - path: ['user'] -}) -const postORPC = createORPCSvelteQueryUtils(postClient, { - path: ['post'] -}) -``` diff --git a/apps/content/docs/integrations/tanstack-query-old/vue.md b/apps/content/docs/integrations/tanstack-query-old/vue.md deleted file mode 100644 index d5c8c48cd..000000000 --- a/apps/content/docs/integrations/tanstack-query-old/vue.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Tanstack Query Integration For Vue -description: Seamlessly integrate oRPC with Tanstack Query for Vue ---- - -# Tanstack Query Integration For Vue - -This guide shows how to integrate oRPC with Tanstack Query for Vue. For an introduction, please review the [Basic Guide](/docs/integrations/tanstack-query-old/basic) first. - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/vue-query@latest @tanstack/vue-query@latest -``` - -```sh [yarn] -yarn add @orpc/vue-query@latest @tanstack/vue-query@latest -``` - -```sh [pnpm] -pnpm add @orpc/vue-query@latest @tanstack/vue-query@latest -``` - -```sh [bun] -bun add @orpc/vue-query@latest @tanstack/vue-query@latest -``` - -```sh [deno] -deno add npm:@orpc/vue-query@latest npm:@tanstack/vue-query@latest -``` - -::: - -## Setup - -Before you begin, ensure you have already configured a [server-side client](/docs/client/server-side) or a [client-side client](/docs/client/client-side). - -```ts twoslash -import { router } from './shared/planet' -import { RouterClient } from '@orpc/server' - -declare const client: RouterClient -// ---cut--- -import { createORPCVueQueryUtils } from '@orpc/vue-query' - -export const orpc = createORPCVueQueryUtils(client) - -orpc.planet.find.queryOptions({ input: { id: 123 } }) -// ^| - -// -``` - -## Avoiding Query/Mutation Key Conflicts - -Prevent key conflicts by passing a unique base key when creating your utils: - -```ts -const userORPC = createORPCVueQueryUtils(userClient, { - path: ['user'] -}) -const postORPC = createORPCVueQueryUtils(postClient, { - path: ['post'] -}) -``` diff --git a/apps/content/docs/integrations/tanstack-query.md b/apps/content/docs/integrations/tanstack-query.md index 72317ad99..0fe642a1b 100644 --- a/apps/content/docs/integrations/tanstack-query.md +++ b/apps/content/docs/integrations/tanstack-query.md @@ -1,14 +1,9 @@ ---- -title: Tanstack Query Integration -description: Seamlessly integrate oRPC with Tanstack Query ---- +# TanStack Query Integration -# Tanstack Query Integration - -[Tanstack Query](https://tanstack.com/query/latest) is a robust solution for asynchronous state management. oRPC Tanstack Query integration is very lightweight and straightforward - supporting all libraries that Tanstack Query supports (React, Vue, Angular, Solid, Svelte, etc.). +[TanStack Query](https://tanstack.com/query/latest) integration provides utilities for using oRPC clients with TanStack Query. It includes helper methods for building query and mutation options, as well as query and mutation keys. ::: warning -This documentation assumes you are already familiar with [Tanstack Query](https://tanstack.com/query/latest). If you need a refresher, please review the official Tanstack Query documentation before proceeding. +This guide assumes you are already familiar with [TanStack Query](https://tanstack.com/query/latest). If you need a refresher, review the official TanStack Query documentation before continuing. ::: ## Installation @@ -39,17 +34,14 @@ deno add npm:@orpc/tanstack-query@latest ## Setup -Before you begin, ensure you have already configured a [server-side client](/docs/client/server-side) or a [client-side client](/docs/client/client-side). +Before you begin, set up either a [server-side client](/docs/client/server-side) or a [client-side client](/docs/client/client-side). ```ts twoslash -import { router } from './shared/planet' -import { RouterClient } from '@orpc/server' - -declare const client: RouterClient +import { client } from './shared/planet' // ---cut--- import { createTanstackQueryUtils } from '@orpc/tanstack-query' -export const orpc = createTanstackQueryUtils(client) +const orpc = createTanstackQueryUtils(client) orpc.planet.find.queryOptions({ input: { id: 123 } }) // ^| @@ -67,9 +59,9 @@ orpc.planet.find.queryOptions({ input: { id: 123 } }) // ``` -::: details Avoiding Query/Mutation Key Conflicts? +::: details Avoiding Query and Mutation Key Conflicts? -You can easily avoid key conflicts by passing a unique base key when creating your utils: +To avoid key conflicts, pass a unique base path when creating each set of utils: ```ts const userORPC = createTanstackQueryUtils(userClient, { @@ -85,7 +77,7 @@ const postORPC = createTanstackQueryUtils(postClient, { ## Query Options -Use `.queryOptions` to configure queries. Use it with hooks like `useQuery`, `useSuspenseQuery`, or `prefetchQuery`. +Use `.queryOptions` to build query options. It works with `useQuery`, `useSuspenseQuery`, and `prefetchQuery`, and any other API that accepts query options. ```ts const query = useQuery(orpc.planet.find.queryOptions({ @@ -97,15 +89,15 @@ const query = useQuery(orpc.planet.find.queryOptions({ ## Streamed Query Options -Use `.streamedOptions` to configure queries for [Event Iterator](/docs/event-iterator). Data is an array of events, and each new event is appended to the end of the array as it arrives. +Use `.streamedOptions` to build streamed query options for [Event Iterator](/docs/event-iterator). The resulting data is an array of events, and each new event is appended as it arrives. -Works with hooks like `useQuery`, `useSuspenseQuery`, or `prefetchQuery`. +It works with `useQuery`, `useSuspenseQuery`, and `prefetchQuery`, and any other API that accepts query options. ```ts -const query = useQuery(orpc.streamed.experimental_streamedOptions({ +const query = useQuery(orpc.streamed.streamedOptions({ input: { id: 123 }, // Specify input if needed context: { cache: true }, // Provide client context if needed - queryFnOptions: { // Configure streamedQuery behavior + queryFnOptions: { // Configure streamed query behavior refetchMode: 'reset', maxChunks: 3, }, @@ -114,14 +106,23 @@ const query = useQuery(orpc.streamed.experimental_streamedOptions({ })) ``` +::: info +`refetchMode` determines how data is handled when the query is fetched again: + +- `'reset'` _(default)_: Clears existing data and returns the query to a pending state. +- `'append'`: Adds new streamed chunks to the existing data. +- `'replace'`: Buffers streamed data and replaces the cache after the stream completes. + +::: + ## Live Query Options -Use `.liveOptions` to configure live queries for [Event Iterator](/docs/event-iterator). Data is always the latest event, replacing the previous value whenever a new one arrives. +Use `.liveOptions` to build live query options for [Event Iterator](/docs/event-iterator). The data always reflects the latest event, replacing the previous value whenever a new one arrives. -Works with hooks like `useQuery`, `useSuspenseQuery`, or `prefetchQuery`. +It works with `useQuery`, `useSuspenseQuery`, and `prefetchQuery`, and any other API that accepts query options. ```ts -const query = useQuery(orpc.live.experimental_liveOptions({ +const query = useQuery(orpc.live.liveOptions({ input: { id: 123 }, // Specify input if needed context: { cache: true }, // Provide client context if needed retry: true, // Infinite retry for more reliable streaming @@ -131,10 +132,10 @@ const query = useQuery(orpc.live.experimental_liveOptions({ ## Infinite Query Options -Use `.infiniteOptions` to configure infinite queries. Use it with hooks like `useInfiniteQuery`, `useSuspenseInfiniteQuery`, or `prefetchInfiniteQuery`. +Use `.infiniteOptions` to build infinite query options. It works with `useInfiniteQuery`, `useSuspenseInfiniteQuery`, and `prefetchInfiniteQuery`, and any other API that accepts infinite query options. ::: info -The `input` parameter must be a function that accepts the page parameter and returns the query input. Be sure to define the type for `pageParam` if it can be `null` or `undefined`. +The `input` option must be a function that receives the page parameter and returns the query input. Define the `pageParam` type explicitly if it can be `null` or `undefined`. ::: ```ts @@ -149,7 +150,7 @@ const query = useInfiniteQuery(orpc.planet.list.infiniteOptions({ ## Mutation Options -Use `.mutationOptions` to create options for mutations. Use it with hooks like `useMutation`. +Use `.mutationOptions` to build mutation options. It works with `useMutation` and any other API that accepts mutation options. ```ts const mutation = useMutation(orpc.planet.create.mutationOptions({ @@ -160,15 +161,16 @@ const mutation = useMutation(orpc.planet.create.mutationOptions({ mutation.mutate({ name: 'Earth' }) ``` -## Query/Mutation Key +## Query and Mutation Keys -oRPC provides a set of helper methods to generate keys for queries and mutations: +oRPC provides helper methods for generating query and mutation keys: -- `.key`: Generate a **partial matching** key for actions like revalidating queries, checking mutation status, etc. -- `.queryKey`: Generate a **full matching** key for [Query Options](#query-options). -- `.streamedKey`: Generate a **full matching** key for [Streamed Query Options](#streamed-query-options). -- `.infiniteKey`: Generate a **full matching** key for [Infinite Query Options](#infinite-query-options). -- `.mutationKey`: Generate a **full matching** key for [Mutation Options](#mutation-options). +- `.key`: Generates a **partial-match** key for actions such as invalidating queries or checking mutation status. +- `.queryKey`: Generates a **full-match** key for [Query Options](#query-options). +- `.streamedKey`: Generates a **full-match** key for [Streamed Query Options](#streamed-query-options). +- `.liveKey`: Generates a **full-match** key for [Live Query Options](#live-query-options). +- `.infiniteKey`: Generates a **full-match** key for [Infinite Query Options](#infinite-query-options). +- `.mutationKey`: Generates a **full-match** key for [Mutation Options](#mutation-options). ```ts const queryClient = useQueryClient() @@ -196,7 +198,7 @@ queryClient.setQueryData(orpc.planet.find.queryKey({ input: { id: 123 } }), (old ## Calling Clients -Use `.call` to call a procedure client directly. It's an alias for corresponding procedure client. +The `.call` method provides direct access to the underlying procedure client when needed. ```ts const planet = await orpc.planet.find.call({ id: 123 }) @@ -204,7 +206,7 @@ const planet = await orpc.planet.find.call({ id: 123 }) ## Reactive Options -In reactive libraries like Vue or Solid, **TanStack Query** supports passing computed values as options. The exact usage varies by framework, so refer to the documentation for [Vue](https://tanstack.com/query/latest/docs/framework/vue/reactivity) or [Solid](https://tanstack.com/query/latest/docs/framework/solid/reference/useQuery#reactive-options) for details. +In reactive libraries like Vue or Solid, TanStack Query supports passing computed values as options. The exact API varies by framework, so refer to the TanStack Query documentation for [Vue](https://tanstack.com/query/latest/docs/framework/vue/reactivity) or [Solid](https://tanstack.com/query/latest/docs/framework/solid/reference/useQuery#reactive-options). ::: code-group @@ -228,22 +230,27 @@ const query = useQuery(computed( ## Default Options -You can configure default options for all query/mutation utilities using `experimental_defaults`. These options are [spread merged](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) with user-provided options, allowing you to set defaults while still enabling customization on a per-call basis. +Use `scoped` to configure default options for scoped query and mutation utilities. Each value can be either a partial options object, which is spread-merged with lower priority than per-call options, or a function that receives the per-call options and returns the merged result. ```ts const orpc = createTanstackQueryUtils(client, { - experimental_defaults: { + scoped: { planet: { find: { + queryKey: options => ({ + // Override the auto-generated query key for .queryKey and .queryOptions + queryKey: options.queryKey ?? ['planet', 'find', options.input] + }), queryOptions: { staleTime: 60 * 1000, // 1 minute retry: 3, }, }, list: { - infiniteOptions: { - staleTime: 30 * 1000, - }, + infiniteOptions: options => ({ + ...options, + staleTime: 30 * 1000, // override takes priority + }), }, create: { mutationOptions: { @@ -256,62 +263,147 @@ const orpc = createTanstackQueryUtils(client, { }, }) -// These will automatically use the default options +// These calls automatically use the default options const query = useQuery(orpc.planet.find.queryOptions({ input: { id: 123 } })) const mutation = useMutation(orpc.planet.create.mutationOptions()) -// User-provided options override defaults +// User-provided options take precedence const customQuery = useQuery(orpc.planet.find.queryOptions({ input: { id: 123 }, - staleTime: 0, // overrides the default + staleTime: 0, // overrides the default staleTime })) ``` +::: info +When you configure `queryKey`, it also affects `.queryOptions` because it is used internally to generate query keys. The same applies to live, streamed, infinite, and mutation options when you configure their keys. +::: + +## Interceptors + +Interceptors let you wrap `queryFn` and `mutationFn` calls. Unlike [default options](#default-options), which can be overridden by per-call options, interceptors always run for every query and mutation. + +```ts +import { isInferableError, safe } from '@orpc/client' + +const orpc = createTanstackQueryUtils(client, { + queryInterceptors: [], + liveInterceptors: [], + streamedInterceptors: [], + infiniteInterceptors: [], + mutationInterceptors: [ + async ({ context, path, next }) => { + const [error, data] = await safe(next()) + + if (error) { + if (isInferableError(error)) { + // handle typesafe errors + } + + throw error + } + + return data + } + ], + scoped: { + planet: { + create: { + mutationInterceptors: [ + async ({ next, fnContext }) => { + const result = await next() + fnContext.client.invalidateQueries({ queryKey: orpc.planet.key() }) + return result + }, + ], + }, + }, + }, +}) +``` + +::: info +You can use [`safe` and `isInferableError`](/docs/client/error-handling#using-safe-and-isinferableerror) together for typesafe error handling in interceptors. +::: + +## Plugins + +Plugins package reusable defaults and interceptors for queries and mutations. + +```ts +const orpc = createTanstackQueryUtils(client, { + plugins: [] +}) +``` + ## Client Context ::: warning -oRPC excludes [client context](/docs/client/rpc-link#using-client-context) from query keys. Manually override query keys if needed to prevent unwanted query deduplication. Use built-in `retry` option instead of the [oRPC Client Retry Plugin](/docs/plugins/client-retry). +oRPC excludes [client context](/docs/client/client-side#client-context) from query keys. Override the query key manually when you need to prevent unintended query deduplication. ```ts const query = useQuery(orpc.planet.find.queryOptions({ context: { cache: true }, + // manually include context in the query key queryKey: [['planet', 'find'], { context: { cache: true } }], - retry: true, // Prefer using built-in retry option // additional options... })) ``` ::: -## Error Handling +When a client is invoked through the TanStack Query integration, an **operation context** is automatically added to the [client context](/docs/client/client-side#client-context). You can use this context to configure request behavior, such as selecting the HTTP method for [RPC Link](/docs/rpc/link#request-method). + +```ts twoslash +import { RPCLink } from '@orpc/client/fetch' +// ---cut--- +import { + TANSTACK_QUERY_OPERATION_CONTEXT_SYMBOL, + TanstackQueryOperationContext, +} from '@orpc/tanstack-query' + +interface ClientContext extends TanstackQueryOperationContext { +} + +const GET_OPERATION_TYPE = new Set(['query', 'streamed', 'live', 'infinite']) + +const link = new RPCLink({ + method: ({ context }) => { + const operationType = context[TANSTACK_QUERY_OPERATION_CONTEXT_SYMBOL]?.type + + if (operationType && GET_OPERATION_TYPE.has(operationType)) { + return 'GET' + } + + return 'POST' + }, +}) +``` + +## Typesafe Error Handling -Easily manage type-safe errors using our built-in `isDefinedError` helper. +Use the built-in `isInferableError` helper to handle [typesafe errors](/docs/error-handling#typesafe-errors) in queries and mutations. ```ts -import { isDefinedError } from '@orpc/client' +import { isInferableError } from '@orpc/client' const mutation = useMutation(orpc.planet.create.mutationOptions({ onError: (error) => { - if (isDefinedError(error)) { - // Handle type-safe error here + if (isInferableError(error)) { + // Handle typesafe errors here } } })) mutation.mutate({ name: 'Earth' }) -if (mutation.error && isDefinedError(mutation.error)) { - // Handle the error here +if (mutation.error && isInferableError(mutation.error)) { + // Handle the typesafe errors here } ``` -::: info -For more details, see our [type-safe error handling guide](/docs/error-handling#type‐safe-error-handling). -::: - ## `skipToken` for Disabling Queries -The `skipToken` symbol offers a type-safe alternative to the `disabled` option when you need to conditionally disable a query by omitting its `input`. +The [skipToken symbol](https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries#typesafe-disabling-of-queries-using-skiptoken) provides a typesafe alternative to setting `enabled: false` when you want to disable a query by omitting its `input`. ```ts const query = useQuery( @@ -331,198 +423,38 @@ const query = useInfiniteQuery( ) ``` -## Operation Context - -When clients are invoked through the TanStack Query integration, an **operation context** is automatically added to the [client context](/docs/client/rpc-link#using-client-context). This context can be used to config the request behavior, like setting the HTTP method. - -```ts -import { - TANSTACK_QUERY_OPERATION_CONTEXT_SYMBOL, - TanstackQueryOperationContext, -} from '@orpc/tanstack-query' - -interface ClientContext extends TanstackQueryOperationContext { -} - -const GET_OPERATION_TYPE = new Set(['query', 'streamed', 'live', 'infinite']) - -const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - method: ({ context }, path) => { - const operationType = context[TANSTACK_QUERY_OPERATION_CONTEXT_SYMBOL]?.type - - if (operationType && GET_OPERATION_TYPE.has(operationType)) { - return 'GET' - } - - return 'POST' - }, -}) -``` - -## Hydration +## Custom Serializers -To avoid issues like refetching on mount or waterfall issues, your app may need to use [TanStack Query Hydration](https://tanstack.com/query/latest/docs/framework/react/guides/ssr). For seamless integration with oRPC, extend the default serializer using the [RPC JSON Serializer](/docs/advanced/rpc-json-serializer) to support all oRPC types. - -::: info -You can use any custom serializers, but if you're using oRPC, you should use its built-in serializers. -::: +If needed, you can extend the default TanStack Query serializer to support additional types supported by oRPC. Learn more about [RPC Serializers](/docs/rpc/serializer) and [TanStack Query Server Rendering & Hydration](https://tanstack.com/query/latest/docs/framework/react/guides/ssr). ```ts -import { StandardRPCJsonSerializer } from '@orpc/client/standard' +import { RPCSerializer } from '@orpc/client' -const serializer = new StandardRPCJsonSerializer({ - customJsonSerializers: [ +const serializer = new RPCSerializer({ + handlers: { // put custom serializers here - ] + }, }) const queryClient = new QueryClient({ defaultOptions: { queries: { queryKeyHashFn(queryKey) { - const [json, meta] = serializer.serialize(queryKey) - return JSON.stringify({ json, meta }) + const serialized = serializer.serialize(queryKey, { useFormDataForBlobFields: false }) + return JSON.stringify(serialized) }, staleTime: 60 * 1000, // > 0 to prevent immediate refetching on mount }, dehydrate: { serializeData(data) { - const [json, meta] = serializer.serialize(data) - return { json, meta } + return serializer.serialize(data, { useFormDataForBlobFields: false }) } }, hydrate: { deserializeData(data) { - return serializer.deserialize(data.json, data.meta) + return serializer.deserialize(data) } }, } }) ``` - -::: details Next.js Example? - -This feature is not limited to React or Next.js. You can use it with any library that supports TanStack Query hydration. - -::: code-group - -```ts [lib/serializer.ts] -import { StandardRPCJsonSerializer } from '@orpc/client/standard' - -export const serializer = new StandardRPCJsonSerializer({ - customJsonSerializers: [ - // put custom serializers here - ] -}) -``` - -```ts [lib/query/client.ts] -import { defaultShouldDehydrateQuery, QueryClient } from '@tanstack/react-query' -import { serializer } from '../serializer' - -export function createQueryClient() { - return new QueryClient({ - defaultOptions: { - queries: { - queryKeyHashFn(queryKey) { - const [json, meta] = serializer.serialize(queryKey) - return JSON.stringify({ json, meta }) - }, - staleTime: 60 * 1000, // > 0 to prevent immediate refetching on mount - }, - dehydrate: { - shouldDehydrateQuery: query => defaultShouldDehydrateQuery(query) || query.state.status === 'pending', - serializeData(data) { - const [json, meta] = serializer.serialize(data) - return { json, meta } - }, - }, - hydrate: { - deserializeData(data) { - return serializer.deserialize(data.json, data.meta) - } - }, - } - }) -} -``` - -```tsx [lib/query/hydration.tsx] -import { createQueryClient } from './client' -import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query' -import { cache } from 'react' - -export const getQueryClient = cache(createQueryClient) - -export function HydrateClient(props: { children: React.ReactNode, client: QueryClient }) { - return ( - - {props.children} - - ) -} -``` - -```tsx [app/providers.tsx] -'use client' - -import { useState } from 'react' -import { createQueryClient } from '../lib/query/client' -import { QueryClientProvider } from '@tanstack/react-query' - -export function Providers(props: { children: React.ReactNode }) { - const [queryClient] = useState(() => createQueryClient()) - - return ( - - {props.children} - - ) -} -``` - -```tsx [app/page.tsx] -import { getQueryClient, HydrateClient } from '../lib/query/hydration' -import { ListPlanets } from '../components/list-planets' - -export default function Page() { - const queryClient = getQueryClient() - - queryClient.prefetchQuery( - orpc.planet.list.queryOptions(), - ) - - return ( - - - - ) -} -``` - -```tsx [components/list-planets.tsx] -'use client' - -import { useSuspenseQuery } from '@tanstack/react-query' - -export function ListPlanets() { - const { data, isError } = useSuspenseQuery(orpc.planet.list.queryOptions()) - - if (isError) { - return ( -

Something went wrong

- ) - } - - return ( -
    - {data.map(planet => ( -
  • {planet.name}
  • - ))} -
- ) -} -``` - -::: diff --git a/apps/content/docs/metadata.md b/apps/content/docs/metadata.md index dc34d6b1b..a5577f7d0 100644 --- a/apps/content/docs/metadata.md +++ b/apps/content/docs/metadata.md @@ -1,50 +1,120 @@ ---- -title: Metadata -description: Enhance your procedures with metadata. ---- - # Metadata -oRPC procedures support metadata, simple key-value pairs that provide extra information to customize behavior. +Metadata lets you attach extra information to procedures. Middleware, plugins, and tooling can read it later to control behavior. + +## Quickly Define Meta -## Basic Example +In most cases, use `defineMeta` to create a metadata plugin. It takes a unique name and a merge function that defines how metadata is combined across repeated calls, then returns a tuple of `[metaPlugin, getMeta]`: ```ts twoslash import { os } from '@orpc/server' -declare const db: Map +declare const store: Map // ---cut--- -interface ORPCMetadata { - cache?: boolean -} +import { defineMeta } from '@orpc/server' -const base = os - .$meta({}) // require define initial context [!code highlight] - .use(async ({ procedure, next, path }, input, output) => { - if (!procedure['~orpc'].meta.cache) { - return await next() - } +type CacheMeta = boolean - const cacheKey = path.join('/') + JSON.stringify(input) +const [cacheMeta, getCacheMeta] = defineMeta( // [!code highlight] + 'cache', // [!code highlight] + (incoming: CacheMeta, current) => incoming, // [!code highlight] +) // [!code highlight] - if (db.has(cacheKey)) { - return output(db.get(cacheKey)) - } +const base = os.use(async ({ procedure, next, path }, input, done) => { + if (getCacheMeta(procedure) !== true) { // [!code highlight] + return next() + } - const result = await next() + const key = path.join('/') + JSON.stringify(input) - db.set(cacheKey, result.output) + if (store.has(key)) { + return done({ output: store.get(key)! }) + } - return result - }) + const result = await next() + store.set(key, result.output) + + return result +}) -const example = base - .meta({ cache: true }) // [!code highlight] - .handler(() => { - // Implement your procedure logic here +const cachedProcedure = base + .meta(cacheMeta(true)) // [!code highlight] + .handler(async () => { + return 'Earth' }) ``` -:::info -The `.meta` can be called multiple times; each call [spread merges](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) the new metadata with the existing metadata or the initial metadata. -::: +## Manually Define Meta + +If `defineMeta` is not flexible enough, define a plugin directly with `MetaPlugin`. This gives you full control and lets the plugin infer or restrict procedure types. + +```ts twoslash +import { os } from '@orpc/server' +import z from 'zod' +// ---cut--- +import type { + AnySchema, + ErrorMap, + InferSchemaInput, + InferSchemaOutput, + Meta, + MetaPlugin, +} from '@orpc/server' + +interface ExampleMeta< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap +> { + inputExamples?: InferSchemaInput[] + outputExamples?: InferSchemaOutput[] +} + +interface ExampleMetaPlugin< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap +> extends MetaPlugin { + name: 'example' +} + +function exampleMeta< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, +>( + incoming: ExampleMeta +): ExampleMetaPlugin { + return { + name: 'example', + apply(meta) { + const current = meta.example as ExampleMeta | undefined + + return { + ...meta, + example: { + ...current, + ...incoming, + } + } + }, + } +} + +function getExampleMeta( + procedureOrLazy: { '~orpc': { meta: Meta } } +): ExampleMeta | undefined { + return procedureOrLazy['~orpc'].meta.example as ExampleMeta | undefined +} + +const procedure = os + .input(z.object({ name: z.string() })) + .output(z.object({ id: z.string(), name: z.string() })) + .meta(exampleMeta({ + inputExamples: [{ name: 'Alice' }], // <- typesafe + outputExamples: [{ id: '1', name: 'Alice' }], // <- typesafe + })) + .handler(async ({ input }) => { + return { id: '1', name: 'Alice' } + }) +``` diff --git a/apps/content/docs/middleware.md b/apps/content/docs/middleware.md index 27c6562af..a5fb30718 100644 --- a/apps/content/docs/middleware.md +++ b/apps/content/docs/middleware.md @@ -1,64 +1,51 @@ ---- -title: Middleware -description: Understanding middleware in oRPC ---- +# Middleware -# Middleware in oRPC - -Middleware is a powerful feature in oRPC that enables reusable and extensible procedures. It allows you to: - -- Intercept, hook into, or listen to a handler's execution. -- Inject or guard the execution context. +Middleware is a powerful mechanism in oRPC that allows you to execute code before and after your procedure handlers, enabling features like authentication, logging, caching, and more. It provides a way to modify the context, input, and output of procedures in a flexible and composable manner. ## Overview -Middleware is a function that takes a `next` function as a parameter and either returns the result -of `next` or modifies the result before returning it. - ```ts twoslash -import { os } from '@orpc/server' -// ---cut--- -const authMiddleware = os - .$context<{ something?: string }>() // <-- define dependent-context - .middleware(async ({ context, next }) => { - // Execute logic before the handler - - const result = await next({ - context: { // Pass additional context - user: { id: 1, name: 'John' } - } - }) +import type { AnyMetaPlugin } from '@orpc/server' - // Execute logic after the handler - - return result - }) +declare const someMeta: AnyMetaPlugin +// ---cut--- +import { os } from '@orpc/server' const example = os - .use(authMiddleware) - .handler(async ({ context }) => { - const user = context.user + .$context<{ something?: string }>() // <- define initial context + .meta(someMeta) // <- attach metadata + .errors({ RATE_LIMITED: {} }) // <- attach errors + .middleware(async ({ context, next, errors }) => { // <- middleware logic + try { + // `await` is required to catch async errors + return await next({ + context: { // <- Inject additional context + user: { id: 1, name: 'John' } + } + }) + } + catch (error) { + console.error(error) + throw error + } + finally { + // Cleanup logic after execution + } }) ``` -## Dependent context +## Initial Context -Before `.middleware`, you can `.$context` to specify the dependent context, which must be satisfied when the middleware is used. +Use `.$context` to declare the initial context required when middleware is applied. +Learn more in the [Context Documentation](/docs/context). -## Inline Middleware +## Metadata -Middleware can be defined inline within `.use`, which is useful for simple middleware functions. +Use `.meta` to attach metadata to middleware. This metadata is applied to any procedures that use the middleware. Learn more in the [Metadata documentation](/docs/metadata). -```ts -const example = os - .use(async ({ context, next }) => { - // Execute logic before the handler - return next() - }) - .handler(async ({ context }) => { - // Handler logic - }) -``` +## Typesafe Errors + +Use `.errors` to attach error definitions to middleware. These errors are available in the middleware and any procedures that use it. Learn more in the [Typesafe Error Handling documentation](/docs/error-handling#typesafe-errors). ## Middleware Context @@ -66,40 +53,40 @@ Middleware can be used to inject or guard the [context](/docs/context). ```ts twoslash import { ORPCError, os } from '@orpc/server' + +declare function auth(): { userId: number } | null // ---cut--- const setting = os .use(async ({ context, next }) => { return next({ context: { - auth: await auth() // <-- inject auth payload + auth: await auth() // <- inject auth } }) }) .use(async ({ context, next }) => { - if (!context.auth) { // <-- guard auth + if (!context.auth) { // <- guard auth throw new ORPCError('UNAUTHORIZED') } return next({ context: { - auth: context.auth // <-- override auth + auth: context.auth // <- override auth (now guaranteed to be non-null) } }) }) .handler(async ({ context }) => { - console.log(context.auth) // <-- access auth + console.log(context.auth) // <- auth is guaranteed to be non-null here }) -// ---cut-after--- -declare function auth(): { userId: number } | null ``` -::: info -When you pass additional context to `next`, it will be merged with the existing context. +::: warning +Context passed to `next` must not conflict with the existing context; it is merged at runtime. ::: ## Middleware Input -Middleware can access input, enabling use cases like permission checks. +Middleware can access input in type-safe manner, enabling use cases like permission checks. ```ts const canUpdate = os.middleware(async ({ context, next }, input: number) => { @@ -109,22 +96,21 @@ const canUpdate = os.middleware(async ({ context, next }, input: number) => { const ping = os .input(z.number()) - .use(canUpdate) + .use(canUpdate) // <- input already matches middleware's expected shape .handler(async ({ input }) => { // Handler logic }) -// Mapping input if necessary const pong = os .input(z.object({ id: z.number() })) - .use(canUpdate, input => input.id) + .use(canUpdate.adaptInput(input => input.id)) // <- adapt input to match middleware's expected shape .handler(async ({ input }) => { // Handler logic }) ``` ::: info -You can adapt a middleware to accept a different input shape by using `.mapInput`. +You can adapt a middleware to accept a different input shape by using `.adaptInput`. ```ts const canUpdate = os.middleware(async ({ context, next }, input: number) => { @@ -132,7 +118,7 @@ const canUpdate = os.middleware(async ({ context, next }, input: number) => { }) // Transform middleware to accept a new input shape -const mappedCanUpdate = canUpdate.mapInput((input: { id: number }) => input.id) +const adaptedCanUpdate = canUpdate.adaptInput((input: { id: number }) => input.id) ``` ::: @@ -142,11 +128,11 @@ const mappedCanUpdate = canUpdate.mapInput((input: { id: number }) => input.id) Middleware can also modify the output of a handler, such as implementing caching mechanisms. ```ts -const cacheMid = os.middleware(async ({ context, next, path }, input, output) => { +const cache = os.middleware(async ({ context, next, path }, input, done) => { const cacheKey = path.join('/') + JSON.stringify(input) if (db.has(cacheKey)) { - return output(db.get(cacheKey)) + return done({ output: db.get(cacheKey) }) } const result = await next({}) @@ -157,41 +143,31 @@ const cacheMid = os.middleware(async ({ context, next, path }, input, output) => }) ``` -## Concatenation +## Inline Middleware -Multiple middleware functions can be combined using `.concat`. +Middleware is simply a function that can be defined inline with `.use`, which is useful for simple middleware cases. ```ts -const concatMiddleware = aMiddleware - .concat(os.middleware(async ({ next }) => next())) - .concat(anotherMiddleware) +const example = os + .use(async ({ context, next }) => { + // Execute logic before the handler + return next() + }) + .handler(async ({ context }) => { + // Handler logic + }) ``` -::: info -If you want to concatenate two middlewares with different input types, you can use `.mapInput` to align their input types before concatenation. -::: - -## Built-in Middlewares +## Combining Middleware -oRPC provides some built-in middlewares that can be used to simplify common use cases. +Multiple middleware functions can be combined using `.use`. ```ts -import { onError, onFinish, onStart, onSuccess } from '@orpc/server' - -const ping = os - .use(onStart(() => { - // Execute logic before the handler - })) - .use(onSuccess(() => { - // Execute when the handler succeeds - })) - .use(onError(() => { - // Execute when the handler fails - })) - .use(onFinish(() => { - // Execute logic after the handler - })) - .handler(async ({ context }) => { - // Handler logic - }) +const mergedMiddleware = aMiddleware + .use(async ({ next }) => next()) + .use(anotherMiddleware) ``` + +::: info +To concatenate two middlewares with different input types, use `.adaptInput` to align their inputs first. +::: diff --git a/apps/content/docs/migrations/from-trpc.md b/apps/content/docs/migrations/from-trpc.md index ad8a2a6c0..08f98c172 100644 --- a/apps/content/docs/migrations/from-trpc.md +++ b/apps/content/docs/migrations/from-trpc.md @@ -1,21 +1,16 @@ ---- -title: Migrating from tRPC -description: A comprehensive guide to migrate your tRPC application to oRPC ---- - # Migrating from tRPC -This guide will help you migrate your existing tRPC application to oRPC. Since oRPC draws significant inspiration from tRPC, the migration process should feel familiar and straightforward. +This guide shows how to migrate an existing tRPC app to oRPC. Because oRPC is heavily inspired by tRPC, most concepts map directly, so the migration should feel familiar. ::: info -For a quick way to enhance your existing tRPC app with oRPC features without fully migrating, refer to the [tRPC Integration](/docs/openapi/integrations/trpc). +If you want to add oRPC features to an existing tRPC app without a full migration, see [tRPC Integration](/docs/openapi/integrations/trpc). ::: ## Core Concepts Comparison | Concept | tRPC | oRPC | | --------------------- | ---------------------------- | ------------------- | -| **Router** | `t.router()` | an object | +| **Router** | `t.router()` | plain object | | **Procedure** | `t.procedure` | `os` | | **Context** | `t.context()` | `os.$context()` | | **Create Middleware** | `t.middleware()` | `os.middleware()` | @@ -26,14 +21,14 @@ For a quick way to enhance your existing tRPC app with oRPC features without ful | **Serializer** | `superjson` | built-in | ::: info -Learn more about [oRPC vs tRPC Comparison](/docs/comparison) +See [oRPC vs tRPC Comparison](/docs/comparison) for a broader comparison. ::: ## Step-by-Step Migration ### 1. Installation -First, install oRPC and remove tRPC dependencies: +Remove the tRPC packages and install the oRPC replacements: ::: code-group @@ -66,14 +61,14 @@ deno add npm:@orpc/server@latest npm:@orpc/client@latest npm:@orpc/tanstack-quer ### 2. Initialize -Initialization is an optional step in oRPC. You can use `os` directly without initialization, but for reusability and better code organization, it's recommended to initialize your base procedures. +Initialization is optional in oRPC. You can use `os` directly, but creating shared base procedures makes context and middleware easier to reuse. ::: code-group ```ts [orpc/base.ts] import { ORPCError, os } from '@orpc/server' -export async function createRPCContext(opts: { headers: Headers }) { +export async function createORPCContext(opts: { headers: Headers }) { const session = await auth() return { @@ -82,7 +77,7 @@ export async function createRPCContext(opts: { headers: Headers }) { } } -const o = os.$context>>() +const o = os.$context>>() const timingMiddleware = o.middleware(async ({ next, path }) => { const start = Date.now() @@ -114,7 +109,7 @@ export const protectedProcedure = publicProcedure.use(({ context, next }) => { import { initTRPC, TRPCError } from '@trpc/server' import superjson from 'superjson' -export async function createRPCContext(opts: { headers: Headers }) { +export async function createTRPCContext(opts: { headers: Headers }) { const session = await auth() return { @@ -123,7 +118,7 @@ export async function createRPCContext(opts: { headers: Headers }) { } } -const t = initTRPC.context().create({ +const t = initTRPC.context().create({ transformer: superjson, }) @@ -160,12 +155,12 @@ export const protectedProcedure = t.procedure ::: ::: info -Learn more about oRPC [Context](/docs/context), and [Middleware](/docs/middleware). +Learn more about oRPC [Context](/docs/context) and [Middleware](/docs/middleware). ::: ### 3. Procedures -In oRPC, there are no separate `.query`, `.mutation`, or `.subscription` methods. Instead, use `.handler` for all procedure types. +oRPC does not split procedures into `.query`, `.mutation`, and `.subscription`. Use `.handler` for all procedure types. ::: code-group @@ -235,7 +230,7 @@ Learn more about oRPC [Procedures](/docs/procedure). ### 4. App Router -The main router structure is similar between tRPC and oRPC, except in oRPC you don't need to wrap routers in a `.router` call - plain objects is enough. +The overall router structure stays similar. In oRPC, you do not wrap routers in a `.router` call. A plain object is enough. ::: code-group @@ -263,6 +258,8 @@ Learn more about oRPC [Router](/docs/router). ### 5. Error Handling +Error handling is similar, but `ORPCError` takes the error code as its first argument. + ::: code-group ```ts [orpc] @@ -290,7 +287,7 @@ Learn more about oRPC [Error Handling](/docs/error-handling). ### 6. Server Setup -This example assumes you're using [Next.js](https://nextjs.org/). If you're using a different framework, check the [oRPC HTTP Adapters](/docs/adapters/http) documentation. +This example uses [Next.js](https://nextjs.org/). If you use another framework, see [oRPC HTTP Adapters](/docs/adapters/fetch-api). ::: code-group @@ -299,12 +296,12 @@ import { RPCHandler } from '@orpc/server/fetch' const handler = new RPCHandler(appRouter, { interceptors: [ - async ({ next }) => { + async ({ next, path }) => { try { return await next() } catch (error) { - console.error(error) + console.error(`❌ oRPC failed on ${path.join('.')}: `, error) throw error } } @@ -314,7 +311,7 @@ const handler = new RPCHandler(appRouter, { async function handleRequest(request: Request) { const { response } = await handler.handle(request, { prefix: '/api/orpc', - context: await createORPCContext(request) + context: await createORPCContext({ headers: request.headers }) }) return response ?? new Response('Not found', { status: 404 }) @@ -332,7 +329,7 @@ function handler(req: Request) { endpoint: '/api/trpc', req, router: appRouter, - createContext: () => createTRPCContext(req), + createContext: () => createTRPCContext({ headers: req.headers }), onError: ({ path, error }) => { console.error( `❌ tRPC failed on ${path ?? ''}: ${error.message}` @@ -348,6 +345,8 @@ export { handler as GET, handler as POST } ### 7. Client Setup +Create a transport link, then use it to build a typed client. + ::: code-group ```ts [orpc/client.ts] @@ -356,7 +355,8 @@ import { RPCLink } from '@orpc/client/fetch' import { RouterClient } from '@orpc/server' const link = new RPCLink({ - url: 'http://localhost:3000/api/orpc', + origin: 'http://localhost:3000', + url: '/api/orpc', interceptors: [ onError((error) => { console.error(error) @@ -390,12 +390,12 @@ const { planets } = await client.planet.list.query({ cursor: 0 }) ::: ::: info -Learn more about oRPC [Client-Side Clients](/docs/client/client-side), [Batch Requests Plugin](/docs/plugins/batch-requests), and [Dedupe Requests Plugin](/docs/plugins/dedupe-requests). +Learn more about oRPC [Client-Side Clients](/docs/client/client-side), [Batch Plugin](/docs/plugins/batch), and [Dedupe Plugin](/docs/plugins/dedupe). ::: ### 8. TanStack Query (React) Integration -The oRPC TanStack Query integration is similar to tRPC, but simpler - you can use the `orpc` utilities directly without React providers or special hooks. +The TanStack Query integration feels similar to tRPC, but it is lighter. You can use the generated `orpc` utilities directly without a React provider or custom hooks. ::: code-group diff --git a/apps/content/docs/openapi/advanced/customizing-error-response.md b/apps/content/docs/openapi/advanced/customizing-error-response.md deleted file mode 100644 index 0cd870fb4..000000000 --- a/apps/content/docs/openapi/advanced/customizing-error-response.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: Customizing Error Response Format -description: Learn how to customize the error response format in oRPC OpenAPI to match your application's requirements and improve client compatibility. ---- - -# Customizing Error Response Format - -By default, [OpenAPIHandler](/docs/openapi/openapi-handler), [OpenAPIGenerator](/docs/openapi/openapi-specification), and [OpenAPILink](/docs/openapi/client/openapi-link) share the same error response format. You can customize one, some, or all of them based on your requirements. - -::: info -The examples below use options very close to the default behavior. -::: - -## `OpenAPIHandler` - -Use `customErrorResponseBodyEncoder` in [OpenAPIHandler](/docs/openapi/openapi-handler) to customize how an `ORPCError` is formatted in the response. - -```ts -const handler = new OpenAPIHandler(router, { - customErrorResponseBodyEncoder(error) { - return error.toJSON() - }, -}) -``` - -::: info -Return `null` or `undefined` from `customErrorResponseBodyEncoder` to fallback to the default behavior. -::: - -## `OpenAPIGenerator` - -When using [type-safe errors](/docs/error-handling#type‐safe-error-handling), customize the error response format in [OpenAPIGenerator](/docs/openapi/openapi-specification) with `customErrorResponseBodySchema` to match your application's actual error responses. - -```ts -const generator = new OpenAPIGenerator() - -const spec = await generator.generate(router, { - customErrorResponseBodySchema: (definedErrorDefinitions, status) => { - const result: Record = { - oneOf: [ - { - type: 'object', - properties: { - defined: { const: false }, // for normal errors - code: { type: 'string' }, - status: { type: 'number' }, - message: { type: 'string' }, - data: {}, - }, - required: ['defined', 'code', 'status', 'message'], - }, - ], - } - - for (const [code, defaultMessage, dataRequired, dataSchema] of definedErrorDefinitions) { - result.oneOf.push({ - type: 'object', - properties: { - defined: { const: true }, // for typesafe errors - code: { const: code }, - status: { const: status }, - message: { type: 'string', default: defaultMessage }, - data: dataSchema, - }, - required: dataRequired ? ['defined', 'code', 'status', 'message', 'data'] : ['defined', 'code', 'status', 'message'], - }) - } - - return result - } -}) -``` - -::: info -Return `null` or `undefined` from `customErrorResponseBodySchema` to fallback to the default behavior. -::: - -## `OpenAPILink` - -When your backend isn't oRPC or uses a custom error format, you can instruct [OpenAPILink](/docs/openapi/client/openapi-link) how to parse it to an `ORPCError` using the `customErrorResponseBodyDecoder` option. - -```ts -const link = OpenAPILink(contract, { - customErrorResponseBodyDecoder: (body, response) => { - if (isORPCErrorJson(body)) { - return createORPCErrorFromJson(body) - } - - return null // default behavior supports any error format - } -}) -``` - -::: info -Return `null` or `undefined` from `customErrorResponseBodyDecoder` to fallback to the default behavior. -::: diff --git a/apps/content/docs/openapi/advanced/disabling-output-validation.md b/apps/content/docs/openapi/advanced/disabling-output-validation.md deleted file mode 100644 index bbf34149b..000000000 --- a/apps/content/docs/openapi/advanced/disabling-output-validation.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: Disabling Output Validation -description: Learn how to disable output validation in oRPC procedures for improved performance while maintaining OpenAPI specification generation. ---- - -# Disabling Output Validation - -By default, oRPC validates procedure outputs against their [defined schemas](/docs/procedure#input-output-validation) to ensure data consistency and type safety. If you only define output schemas for [OpenAPI specification generation](/docs/openapi/openapi-specification), you can disable output validation to improve performance. - -## Configuration - -Set `initialOutputValidationIndex` to `NaN` in the [`$config`](/docs/procedure#initial-configuration) method: - -```ts -import { os } from '@orpc/server' - -const base = os - .$config({ - initialOutputValidationIndex: Number.NaN, // [!code highlight] - }) -``` - -All procedures built from `base` will now have output validation disabled. - -## Limitation - -This approach will not work correctly if your schema transforms the data into a different type during validation. - -```ts twoslash -import { os } from '@orpc/server' -import { z } from 'zod' - -const base = os - .$config({ - initialOutputValidationIndex: Number.NaN, - }) -// ---cut--- - -const procedure = base - .output(z.object({ value: z.number().transform(val => String(val)) })) - .handler(() => { - return { value: 123 } - }) - .callable() - -const { value } = await procedure() -``` - -In this example, the client expects `value` to be a `string`, but because output validation is disabled, the transform logic is skipped. The client will receive a `number` instead, causing type mismatches. diff --git a/apps/content/docs/openapi/advanced/expanding-type-support-for-openapi-link.md b/apps/content/docs/openapi/advanced/expanding-type-support-for-openapi-link.md deleted file mode 100644 index 4729a6622..000000000 --- a/apps/content/docs/openapi/advanced/expanding-type-support-for-openapi-link.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: Expanding Type Support for OpenAPI Link -description: Learn how to extend OpenAPILink to support additional data types beyond JSON's native capabilities using the Response Validation Plugin and schema coercion. ---- - -# Expanding Type Support for OpenAPI Link - -This guide will show you how to extend [OpenAPILink](/docs/openapi/client/openapi-link) to support additional data types beyond JSON's native capabilities using the [Response Validation Plugin](/docs/plugins/response-validation). - -## How It Works - -To enable this functionality, you need to extend your [output](/docs/procedure#input-output-validation) and [error](/docs/error-handling#type%E2%80%90safe-error-handling) schemas with proper coercion logic. - -**Why?** OpenAPI response data only represents JSON's native capabilities. We use schema coercion logic in contract's schemas to convert the data to the desired type. - -::: warning -Beyond JSON limitations, outputs containing `Blob` or `File` types (outside the root level) also face [Bracket Notation](/docs/openapi/bracket-notation#limitations) limitations. -::: - -```ts -const contract = oc.output(z.object({ - date: z.coerce.date(), // [!code highlight] - bigint: z.coerce.bigint(), // [!code highlight] -})) - -const procedure = implement(contract).handler(() => ({ - date: new Date(), - bigint: 123n, -})) -``` - -On the client side, you'll receive the output like this: - -```ts -const beforeValidation = { - date: '2025-09-01T07:24:39.000Z', - bigint: '123' -} -``` - -Since your output schema contains coercion logic, the Response Validation Plugin will convert the data to the desired type after validation. - -```ts -const afterValidation = { - date: new Date('2025-09-01T07:24:39.000Z'), - bigint: 123n -} -``` - -::: warning -To support more types than those in [OpenAPI Handler](/docs/openapi/openapi-handler#supported-data-types), you must first extend the [OpenAPI JSON Serializer](/docs/openapi/advanced/openapi-json-serializer) first. -::: - -## Setup - -After understanding how it works and expanding schemas with coercion logic, you only need to set up the [Response Validation Plugin](/docs/plugins/response-validation) and remove the `JsonifiedClient` wrapper. - -```diff - import type { ContractRouterClient } from '@orpc/contract' - import { createORPCClient } from '@orpc/client' - import { OpenAPILink } from '@orpc/openapi-client/fetch' - import { ResponseValidationPlugin } from '@orpc/contract/plugins' - - const link = new OpenAPILink(contract, { - url: 'http://localhost:3000/api', - plugins: [ -+ new ResponseValidationPlugin(contract), - ] - }) - --const client: JsonifiedClient> = createORPCClient(link) -+const client: ContractRouterClient = createORPCClient(link) -``` diff --git a/apps/content/docs/openapi/advanced/openapi-json-serializer.md b/apps/content/docs/openapi/advanced/openapi-json-serializer.md deleted file mode 100644 index d07e1fa62..000000000 --- a/apps/content/docs/openapi/advanced/openapi-json-serializer.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: OpenAPI JSON Serializer -description: Extend or override the standard OpenAPI JSON serializer. ---- - -# OpenAPI JSON Serializer - -This serializer processes JSON payloads for the [OpenAPIHandler](/docs/openapi/openapi-handler) and supports [native data types](/docs/openapi/openapi-handler#supported-data-types). - -## Extending Native Data Types - -Customize serialization by creating your own `StandardOpenAPICustomJsonSerializer` and adding it to the `customJsonSerializers` option. - -1. **Define Your Custom Serializer** - - ```ts twoslash - import type { StandardOpenAPICustomJsonSerializer } from '@orpc/openapi-client/standard' - - export class User { - constructor( - public readonly id: string, - public readonly name: string, - public readonly email: string, - public readonly age: number, - ) {} - - toJSON() { - return { - id: this.id, - name: this.name, - email: this.email, - age: this.age, - } - } - } - - export const userSerializer: StandardOpenAPICustomJsonSerializer = { - condition: data => data instanceof User, - serialize: data => data.toJSON(), - } - ``` - -2. **Use Your Custom Serializer** - - ```ts twoslash - import type { StandardOpenAPICustomJsonSerializer } from '@orpc/openapi-client/standard' - import { OpenAPIHandler } from '@orpc/openapi/fetch' - import { OpenAPIGenerator } from '@orpc/openapi' - - declare const router: Record - declare const userSerializer: StandardOpenAPICustomJsonSerializer - // ---cut--- - const handler = new OpenAPIHandler(router, { - customJsonSerializers: [userSerializer], - }) - - const generator = new OpenAPIGenerator({ - customJsonSerializers: [userSerializer], - }) - ``` - - ::: info - It is recommended to add custom serializers to the `OpenAPIGenerator` for consistent serialization in the OpenAPI document. - ::: diff --git a/apps/content/docs/openapi/advanced/redirect-response.md b/apps/content/docs/openapi/advanced/redirect-response.md deleted file mode 100644 index 570010b9f..000000000 --- a/apps/content/docs/openapi/advanced/redirect-response.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: Redirect Response -description: Standard HTTP redirect response in oRPC OpenAPI. ---- - -# Redirect Response - -Easily return a standard HTTP redirect response in oRPC OpenAPI. - -## Basic Usage - -By combining the `successStatus` and `outputStructure` options, you can return a standard HTTP redirect response. - -```ts -const redirect = os - .route({ - method: 'GET', - path: '/redirect', - successStatus: 307, // [!code highlight] - outputStructure: 'detailed' // [!code highlight] - }) - .handler(async () => { - return { - headers: { - location: 'https://orpc.dev', // [!code highlight] - }, - } - }) -``` - -## Limitations - -When invoking a redirect procedure with [OpenAPILink](/docs/openapi/client/openapi-link), oRPC treats the redirect as a normal response rather than following it. Some environments, such as browsers, may restrict access to the redirect response, **potentially causing errors**. In contrast, server environments like Node.js handle this without issue. diff --git a/apps/content/docs/openapi/bracket-notation.md b/apps/content/docs/openapi/bracket-notation.md index 8d1e3d517..69bb70e3c 100644 --- a/apps/content/docs/openapi/bracket-notation.md +++ b/apps/content/docs/openapi/bracket-notation.md @@ -1,53 +1,66 @@ ---- -title: Bracket Notation -description: Represent structured data in limited formats such as URL queries and form data. ---- - # Bracket Notation -Bracket Notation encodes structured data in formats with limited syntax, like URL queries and form data. It is used by [OpenAPIHandler](/docs/openapi/openapi-handler) and [OpenAPILink](/docs/openapi/client/openapi-link). +Bracket notation encodes structured data in flat key-value formats such as query strings and form data. [OpenAPI Serializer](/docs/openapi/serializer), [OpenAPI Handler](/docs/openapi/handler), and [OpenAPI Link](/docs/openapi/link) use it whenever nested data must be represented outside plain JSON. -## Usage +## Rules -1. **Same name (>=2 elements) are represented as an array.** +1. **Repeated keys become arrays.** ``` - color=red&color=blue → { color: ["red", "blue"] } + color=red&color=blue -> { color: ['red', 'blue'] } ``` -2. **Append `[]` at the end to denote an array.** +2. **Append `[]` to push into an array.** ``` - color[]=red&color[]=blue → { color: ["red", "blue"] } + color[]=red&color[]=blue -> { color: ['red', 'blue'] } ``` -3. **Append `[number]` to specify an array index (missing indexes create sparse arrays).** +3. **Append `[number]` to target an explicit array index.** ``` - color[0]=red&color[2]=blue → { color: ["red", , "blue"] } + color[0]=red&color[2]=blue -> { color: ['red', , 'blue'] } ``` ::: info - Array indexes must be less than 10,000 by default to prevent memory exhaustion attacks from large indices. Configure with `maxBracketNotationArrayIndex` in `OpenAPIHandler`. + Missing indexes create sparse arrays. + + Explicit indexes greater than `999` are treated as object keys by default to avoid huge sparse arrays during deserialization. To change that limit, configure `maxExplicitDeserializingArrayIndex`: + + ```ts + const serializer = new OpenAPISerializer({ + bracketNotation: { + maxExplicitDeserializingArrayIndex: 1999, + } + }) + ``` + ::: -4. **Append `[key]` to denote an object property.** +4. **Append `[key]` to target an object property.** ``` - color[red]=true&color[blue]=false → { color: { red: true, blue: false } } + color[red]=true&color[blue]=false -> { color: { red: 'true', blue: 'false' } } ``` ## Limitations -- **Empty Arrays:** Cannot be represented; arrays must have at least one element. -- **Empty Objects:** Cannot be represented. Objects with empty or numeric keys may be interpreted as arrays, so ensure objects include at least one non-empty, non-numeric key. +Bracket notation is designed to express structured data in constrained environments, so it has a few unavoidable limitations: + +- Cannot represent empty structures like empty objects `{}` or empty arrays `[]`. +- Cannot represent an array at the root level. For example, `0=red&1=blue` becomes `{ 0: 'red', 1: 'blue' }`, not `['red', 'blue']`. +- Cannot represent objects whose keys are all numbers, because they can be mistaken for array indexes. + +::: info +If bracket notation is used in query strings or form data, it also inherits the limitations of those formats. For example, values are always strings or files, and `null` or `undefined` cannot be represented. +::: ## Examples ### URL Query ```bash -curl http://example.com/api/example?name[first]=John&name[last]=Doe +curl 'http://example.com/api/example?name[first]=John&name[last]=Doe' ``` This query is parsed as: @@ -108,3 +121,8 @@ This form data is parsed as: } } ``` + +## Learn More + +The bracket notation is a small, self-contained module, making it easy to understand. +To explore its behavior in detail, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/openapi/src/bracket-notation.ts). diff --git a/apps/content/docs/openapi/client/openapi-link.md b/apps/content/docs/openapi/client/openapi-link.md deleted file mode 100644 index 927316844..000000000 --- a/apps/content/docs/openapi/client/openapi-link.md +++ /dev/null @@ -1,159 +0,0 @@ ---- -title: OpenAPILink -description: Details on using OpenAPILink in oRPC clients. ---- - -# OpenAPILink - -OpenAPILink enables communication with an [OpenAPIHandler](/docs/openapi/openapi-handler) or any API that follows the [OpenAPI Specification](https://swagger.io/specification/) using HTTP/Fetch. - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/openapi-client@latest -``` - -```sh [yarn] -yarn add @orpc/openapi-client@latest -``` - -```sh [pnpm] -pnpm add @orpc/openapi-client@latest -``` - -```sh [bun] -bun add @orpc/openapi-client@latest -``` - -```sh [deno] -deno add npm:@orpc/openapi-client@latest -``` - -::: - -## Setup - -To use `OpenAPILink`, ensure you have a [contract router](/docs/contract-first/define-contract#contract-router) and that your server is set up with [OpenAPIHandler](/docs/openapi/openapi-handler) or any API that follows the [OpenAPI Specification](https://swagger.io/specification/). - -::: info -A normal [router](/docs/router) works as a contract router as long as it does not include a [lazy router](/docs/router#lazy-router). For more advanced use cases, refer to the [Router to Contract](/docs/contract-first/router-to-contract) guide. -::: - -```ts twoslash -import { contract } from './shared/planet' -// ---cut--- -import type { JsonifiedClient } from '@orpc/openapi-client' -import type { ContractRouterClient } from '@orpc/contract' -import { createORPCClient, onError } from '@orpc/client' -import { OpenAPILink } from '@orpc/openapi-client/fetch' - -const link = new OpenAPILink(contract, { - url: 'http://localhost:3000/api', - headers: () => ({ - 'x-api-key': 'my-api-key', - }), - fetch: (request, init) => { - return globalThis.fetch(request, { - ...init, - credentials: 'include', // Include cookies for cross-origin requests - }) - }, - interceptors: [ - onError((error) => { - console.error(error) - }) - ], -}) - -const client: JsonifiedClient> = createORPCClient(link) -``` - -:::warning -Due to JSON limitations, you must wrap your client with `JsonifiedClient` to ensure type safety. Alternatively, follow the [Expanding Type Support for OpenAPI Link](/docs/openapi/advanced/expanding-type-support-for-openapi-link) guide to preserve original types without the wrapper. -::: - -## Limitations - -Unlike [RPCLink](/docs/client/rpc-link), `OpenAPILink` has some constraints: - -- Payloads containing a `Blob` or `File` (outside the root level) must use `multipart/form-data` and serialized using [Bracket Notation](/docs/openapi/bracket-notation). -- For `GET` requests, the payload must be sent as `URLSearchParams` and serialized using [Bracket Notation](/docs/openapi/bracket-notation). - -:::warning -In these cases, both the request and response are subject to the limitations of [Bracket Notation Limitations](/docs/openapi/bracket-notation#limitations). Additionally, oRPC converts data to strings (exclude `null` and `undefined` will not be represented). -::: - -## CORS policy - -`OpenAPILink` requires access to the `Content-Disposition` to distinguish file responses from other responses whe file has a common MIME type like `application/json`, `plain/text`, etc. To enable this, include `Content-Disposition` in your CORS policy's `Access-Control-Expose-Headers`: - -```ts -const handler = new OpenAPIHandler(router, { - plugins: [ - new CORSPlugin({ - exposeHeaders: ['Content-Disposition'], - }), - ], -}) -``` - -## Using Client Context - -Client context lets you pass extra information when calling procedures and dynamically modify OpenAPILink's behavior. - -```ts twoslash -import { contract } from './shared/planet' -// ---cut--- -import type { JsonifiedClient } from '@orpc/openapi-client' -import type { ContractRouterClient } from '@orpc/contract' -import { createORPCClient } from '@orpc/client' -import { OpenAPILink } from '@orpc/openapi-client/fetch' - -interface ClientContext { - something?: string -} - -const link = new OpenAPILink(contract, { - url: 'http://localhost:3000/api', - headers: async ({ context }) => ({ - 'x-api-key': context?.something ?? '' - }) -}) - -const client: JsonifiedClient> = createORPCClient(link) - -const result = await client.planet.list( - { limit: 10 }, - { context: { something: 'value' } } -) -``` - -:::info -If a property in `ClientContext` is required, oRPC enforces its inclusion when calling procedures. -::: - -## Lazy URL - -You can define `url` as a function, ensuring compatibility with environments that may lack certain runtime APIs. - -```ts -const link = new OpenAPILink({ - url: () => { - if (typeof window === 'undefined') { - throw new Error('OpenAPILink is not allowed on the server side.') - } - - return `${window.location.origin}/api` - }, -}) -``` - -## SSE Like Behavior - -Unlike traditional SSE, the [Event Iterator](/docs/event-iterator) does not automatically retry on error. To enable automatic retries, refer to the [Client Retry Plugin](/docs/plugins/client-retry). - -## Lifecycle - -The `OpenAPILink` follows the same lifecycle as the [RPCLink Lifecycle](/docs/client/rpc-link#lifecycle), ensuring consistent behavior across different link types. diff --git a/apps/content/docs/openapi/error-handling.md b/apps/content/docs/openapi/error-handling.md deleted file mode 100644 index 5a6eed639..000000000 --- a/apps/content/docs/openapi/error-handling.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: OpenAPI Error Handling -description: Handle errors in your OpenAPI-compliant oRPC APIs ---- - -# OpenAPI Error Handling - -Before you begin, please review our [Error Handling](/docs/error-handling) guide. This document shows you how to align your error responses with OpenAPI standards. - -## Default Error Mappings - -By default, oRPC maps common error codes to standard HTTP status codes: - -| Error Code | HTTP Status Code | Message | -| ---------------------- | ---------------: | ---------------------- | -| BAD_REQUEST | 400 | Bad Request | -| UNAUTHORIZED | 401 | Unauthorized | -| FORBIDDEN | 403 | Forbidden | -| NOT_FOUND | 404 | Not Found | -| METHOD_NOT_SUPPORTED | 405 | Method Not Supported | -| NOT_ACCEPTABLE | 406 | Not Acceptable | -| TIMEOUT | 408 | Request Timeout | -| CONFLICT | 409 | Conflict | -| PRECONDITION_FAILED | 412 | Precondition Failed | -| PAYLOAD_TOO_LARGE | 413 | Payload Too Large | -| UNSUPPORTED_MEDIA_TYPE | 415 | Unsupported Media Type | -| UNPROCESSABLE_CONTENT | 422 | Unprocessable Content | -| TOO_MANY_REQUESTS | 429 | Too Many Requests | -| CLIENT_CLOSED_REQUEST | 499 | Client Closed Request | -| INTERNAL_SERVER_ERROR | 500 | Internal Server Error | -| NOT_IMPLEMENTED | 501 | Not Implemented | -| BAD_GATEWAY | 502 | Bad Gateway | -| SERVICE_UNAVAILABLE | 503 | Service Unavailable | -| GATEWAY_TIMEOUT | 504 | Gateway Timeout | - -Any error not defined above defaults to HTTP status `500` with the error code used as the message. - -## Customizing Errors - -You can override the default mappings by specifying a custom `status` and `message` when creating an error: - -```ts -const example = os - .errors({ - RANDOM_ERROR: { - status: 503, // <-- override default status - message: 'Default error message', // <-- override default message - }, - }) - .handler(() => { - throw new ORPCError('ANOTHER_RANDOM_ERROR', { - status: 502, // <-- override default status - message: 'Custom error message', // <-- override default message - }) - }) -``` diff --git a/apps/content/docs/openapi/getting-started.md b/apps/content/docs/openapi/getting-started.md deleted file mode 100644 index 63010e7b1..000000000 --- a/apps/content/docs/openapi/getting-started.md +++ /dev/null @@ -1,203 +0,0 @@ ---- -title: Getting Started with OpenAPI -description: Quick guide to OpenAPI in oRPC ---- - -# Getting Started - -OpenAPI is a widely adopted standard for describing RESTful APIs. With oRPC, you can easily publish OpenAPI-compliant APIs with minimal effort. - -oRPC is inherently compatible with OpenAPI, but you may need additional configurations such as path prefixes, custom routing, or including headers, parameters, and queries in inputs and outputs. This guide explains how to make your oRPC setup fully OpenAPI-compatible. It assumes basic knowledge of oRPC or familiarity with the [Getting Started](/docs/getting-started) guide. - -## Prerequisites - -- Node.js 18+ (20+ recommended) | Bun | Deno | Cloudflare Workers -- A package manager: npm | pnpm | yarn | bun | deno -- A TypeScript project (strict mode recommended) - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/server@latest @orpc/client@latest @orpc/openapi@latest -``` - -```sh [yarn] -yarn add @orpc/server@latest @orpc/client@latest @orpc/openapi@latest -``` - -```sh [pnpm] -pnpm add @orpc/server@latest @orpc/client@latest @orpc/openapi@latest -``` - -```sh [bun] -bun add @orpc/server@latest @orpc/client@latest @orpc/openapi@latest -``` - -```sh [deno] -deno add npm:@orpc/server@latest npm:@orpc/client@latest npm:@orpc/openapi@latest -``` - -::: - -## Defining Routes - -This snippet is based on the [Getting Started](/docs/getting-started) guide. Please read it first. - -```ts twoslash -import type { IncomingHttpHeaders } from 'node:http' -import { ORPCError, os } from '@orpc/server' -import * as z from 'zod' - -const PlanetSchema = z.object({ - id: z.number().int().min(1), - name: z.string(), - description: z.string().optional(), -}) - -export const listPlanet = os - .route({ method: 'GET', path: '/planets' }) - .input(z.object({ - limit: z.number().int().min(1).max(100).optional(), - cursor: z.number().int().min(0).default(0), - })) - .output(z.array(PlanetSchema)) - .handler(async ({ input }) => { - // your list code here - return [{ id: 1, name: 'name' }] - }) - -export const findPlanet = os - .route({ method: 'GET', path: '/planets/{id}' }) - .input(z.object({ id: z.coerce.number().int().min(1) })) - .output(PlanetSchema) - .handler(async ({ input }) => { - // your find code here - return { id: 1, name: 'name' } - }) - -export const createPlanet = os - .$context<{ headers: IncomingHttpHeaders }>() - .use(({ context, next }) => { - const user = parseJWT(context.headers.authorization?.split(' ')[1]) - - if (user) { - return next({ context: { user } }) - } - - throw new ORPCError('UNAUTHORIZED') - }) - .route({ method: 'POST', path: '/planets' }) - .input(PlanetSchema.omit({ id: true })) - .output(PlanetSchema) - .handler(async ({ input, context }) => { - // your create code here - return { id: 1, name: 'name' } - }) - -export const router = { - planet: { - list: listPlanet, - find: findPlanet, - create: createPlanet - } -} -// ---cut-after--- - -declare function parseJWT(token: string | undefined): { userId: number } | null -``` - -### Key Enhancements: - -- `.route` defines HTTP methods and paths. -- `.output` enables automatic OpenAPI spec generation. -- `z.coerce` ensures correct parameter parsing. - -For handling headers, queries, etc., see [Input/Output Structure](/docs/openapi/input-output-structure). -For auto-coercion, see [Zod Smart Coercion Plugin](/docs/openapi/plugins/zod-smart-coercion). -For more `.route` options, see [Routing](/docs/openapi/routing). - -## Creating a Server - -```ts twoslash -import { router } from './shared/planet' -// ---cut--- -import { createServer } from 'node:http' -import { OpenAPIHandler } from '@orpc/openapi/node' -import { CORSPlugin } from '@orpc/server/plugins' -import { onError } from '@orpc/server' - -const handler = new OpenAPIHandler(router, { - plugins: [new CORSPlugin()], - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -const server = createServer(async (req, res) => { - const result = await handler.handle(req, res, { - context: { headers: req.headers } - }) - - if (!result.matched) { - res.statusCode = 404 - res.end('No procedure matched') - } -}) - -server.listen( - 3000, - '127.0.0.1', - () => console.log('Listening on 127.0.0.1:3000') -) -``` - -### Important Changes: - -- Use `OpenAPIHandler` instead of `RPCHandler`. -- Learn more in [OpenAPIHandler](/docs/openapi/openapi-handler). - -## Accessing APIs - -```bash -curl -X GET http://127.0.0.1:3000/planets -curl -X GET http://127.0.0.1:3000/planets/1 -curl -X POST http://127.0.0.1:3000/planets \ - -H 'Authorization: Bearer token' \ - -H 'Content-Type: application/json' \ - -d '{"name": "name"}' -``` - -Just a small tweak makes your oRPC API OpenAPI-compliant! - -## Generating OpenAPI Spec - -```ts twoslash -import { OpenAPIGenerator } from '@orpc/openapi' -import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4' -import { router } from './shared/planet' - -const generator = new OpenAPIGenerator({ - schemaConverters: [ - new ZodToJsonSchemaConverter() - ] -}) - -const spec = await generator.generate(router, { - info: { - title: 'Planet API', - version: '1.0.0' - } -}) - -console.log(JSON.stringify(spec, null, 2)) -``` - -Run the script above to generate your OpenAPI spec. - -::: info -oRPC supports a wide range of [Standard Schema](https://github.com/standard-schema/standard-schema) for OpenAPI generation. See the full list [here](/docs/openapi/openapi-specification#generating-specifications) -::: diff --git a/apps/content/docs/openapi/handler.md b/apps/content/docs/openapi/handler.md new file mode 100644 index 000000000..5c5d3ce8c --- /dev/null +++ b/apps/content/docs/openapi/handler.md @@ -0,0 +1,278 @@ +# OpenAPI Handler + +Use `OpenAPIHandler` to expose HTTP endpoints or communicate with [OpenAPI Link](/docs/openapi/link) and other OpenAPI-compliant clients. + +## Overview + +```ts +const handler = new OpenAPIHandler(router, { + interceptors: [ + async ({ next, path }) => { + console.time(path.join('.')) + + try { + return await next() + } + catch (err) { + console.error(`${path.join('.')}:`, err) + throw err + } + finally { + console.timeEnd(path.join('.')) + } + } + ], + plugins: [ + new CORSHandlerPlugin() + ], +}) +``` + +::: info +The actual usage of `OpenAPIHandler` depends on the adapter you use. For example, when using the fetch adapter, the handler is used like this: + +```ts +export async function fetch(request: Request) { + const { response } = await handler.fetch(request, { + prefix: '/api', + context: {} // <- provide initial context if needed + }) + + return response ?? new Response('Not Found', { status: 404 }) +} +``` + +::: + + + +## Interceptors + +Interceptors let you observe or change different stages of an OpenAPI request. Common use cases include logging, error handling, and metrics. + +### Routing Interceptors + +Routing interceptors run on every request before routing. Use them when you need to handle all requests, including requests that do not match a procedure. + +```ts +const handler = new OpenAPIHandler(router, { + routingInterceptors: [ + async ({ next, request, context }) => { + if (condition) { + return { matched: false } + } + + const { matched, response } = await next() + return { matched, response } + }, + ], +}) +``` + +### Interceptors + +These interceptors run only for matched requests, after routing and before error handling (but can't use `ORPCError` for [typesafe errors](/docs/error-handling#orpcerror-compatibility)). Use them when you need access to the matched procedure. + +::: tip +In most cases, `interceptors` are the best choice. They provide more context, are easier to work with, and run before error handling. +::: + +```ts +const handler = new OpenAPIHandler(router, { + interceptors: [ + async ({ next, request, procedure, context }) => { + try { + const response = await next() + return response + } + catch (err) { + if (err instanceof CustomError) { + throw new ORPCError('CUSTOM_ERROR', { message: err.message, cause: err }) + } + + throw err + } + }, + async ({ next, path }) => { + console.time(path.join('.')) + + try { + const response = await next() + return response + } + catch (err) { + console.error(`${path.join('.')}:`, err) + throw err + } + finally { + console.timeEnd(path.join('.')) + } + }, + ], +}) +``` + +### Client Interceptors + +Client interceptors run only for matched requests, after input decoding, before output encoding and can use `ORPCError` for [typesafe errors](/docs/error-handling#orpcerror-compatibility). Use them when you need access to the procedure, input, and output. + +```ts +const handler = new OpenAPIHandler(router, { + clientInterceptors: [ + async ({ next, input, context, procedure }) => { + const output = await next() + return output + }, + ], +}) +``` + +### Adapter Interceptors + +Some `OpenAPIHandler` implementations, such as fetch or node adapters, also support adapter interceptors. These run before [Routing Interceptors](#routing-interceptors) and let you work with the adapter's native request and response objects. + +```ts +const handler = new OpenAPIHandler(router, { + fetchInterceptors: [ + async ({ next, request }) => { + const { matched, response } = await next() + return { matched, response } + }, + ], +}) +``` + +::: info +This example uses the fetch adapter. For other adapters, refer to their JSDoc or adapter-specific documentation. +::: + +## Plugins + +Plugins package reusable interceptors. For example, [CORS Plugin](/docs/plugins/cors) adds a [routing interceptor](#routing-interceptors) to handle preflight requests and adds CORS headers to every response. + +```ts +const handler = new OpenAPIHandler(router, { + plugins: [ + new CORSHandlerPlugin() + ], +}) +``` + +## Custom Serializer + +Provide a custom serializer when you need to extend or override the default serialization behavior. For more details, see [OpenAPI Serializer](/docs/openapi/serializer). + +```ts +const handler = new OpenAPIHandler(router, { + serializer: new OpenAPISerializer({ + handlers: { + // ...custom handlers + }, + }), +}) +``` + +## Filtering Procedures + +Use the `filter` option to exclude procedures from matching: + +```ts +const handler = new OpenAPIHandler(router, { + filter: (contract, path) => getIsInternalMeta(contract) !== true, +}) +``` + +## Custom Error Response + +By default, `OpenAPIHandler` determines response status codes using `COMMON_ERROR_STATUS_MAP` and encodes error bodies in the ORPC error format. Use `errorStatusMap` and `customErrorResponseBodyEncoder` to customize this behavior: + +```ts +import { COMMON_ERROR_STATUS_MAP } from '@orpc/openapi' + +const handler = new OpenAPIHandler(router, { + errorStatusMap: { + ...COMMON_ERROR_STATUS_MAP, + CUSTOM_ERROR: 599, + }, + customErrorResponseBodyEncoder: (error) => { + if (error.code === 'CUSTOM_ERROR') { + return { + customMessage: error.message, + customCode: error.code, + } + } + + // fallback to default by returning null or undefined + return null + }, +}) +``` + +::: details Common Error Status Map + + + +::: + +::: info +If you use `OpenAPILink` with a custom server-side error format, make sure to configure [Custom Error Decoding](/docs/openapi/link#custom-error-decoding). +::: + +## Event Stream Options + +Configure how [event iterators](/docs/event-iterator) are streamed to the client. Available options depend on the adapter. For example, the fetch adapter supports: + +```ts +const handler = new OpenAPIHandler(router, { + toFetchResponse: { + eventStream: { + initialComment: { + /** + * If true, an initial comment is sent immediately upon stream start to flush headers. + * This allows the receiving side to establish the connection without waiting for the first event. + * + * @default true + */ + enabled: true, + /** + * The content of the initial comment sent upon stream start. Must not include newline characters. + * + * @default '' + */ + comment: '', + }, + keepAlive: { + /** + * If true, a ping comment is sent periodically to keep the connection alive. + * + * @default true + */ + enabled: true, + /** + * Interval (in milliseconds) between ping comments sent after the last event. + * + * @default 5000 + */ + interval: 5000, + /** + * The content of the ping comment. Must not include newline characters. + * + * @default '' + */ + comment: '', + }, + /** + * If true, a `close` event is sent even when the iterator completes with `undefined`. + * When the iterator returns a value, a `close` event is always emitted regardless of this setting. + * + * @default true + */ + emptyCloseEventEnabled: true, + }, + }, +}) +``` + +## Lifecycle + +TODO: add lifecycle diagram diff --git a/apps/content/docs/openapi/input-and-output-mapping.md b/apps/content/docs/openapi/input-and-output-mapping.md new file mode 100644 index 000000000..6e02b5b0d --- /dev/null +++ b/apps/content/docs/openapi/input-and-output-mapping.md @@ -0,0 +1,282 @@ +# OpenAPI Input and Output Mapping + +oRPC lets you map OpenAPI requests and responses to procedure inputs and outputs in a few different ways. + +## Input Mapping + +By default, oRPC uses `compact` mode where path parameters are merged with either query parameters or the request body, depending on the HTTP method. + +```ts +const searchPlanets = os + .meta(openapi({ method: 'GET', path: '/planets/{id}' })) + .input(z.object({ + id: z.string(), + q: z.string().optional(), + })) + .handler(async ({ input }) => { + return { id: input.id, q: input.q } + }) +``` + +For `GET /planets/earth?q=life`, the procedure receives: + +```json +{ + "id": "earth", + "q": "life" +} +``` + +::: info +Some requests cannot be merged into a single object. For example, `POST /planets/earth` with a non-object body cannot be merged. In that case, the full input becomes the body. Use [detailed input structure](#detailed-input-structure) if you also need path params. +::: + +### Detailed Input Structure + +In `detailed` mode, the input is an object with separate `params`, `query`, `headers`, and `body` fields. + +```ts +const updatePlanet = os + .meta(openapi({ + method: 'POST', + path: '/planets/{id}', + inputStructure: 'detailed', + })) + .input(z.object({ + params: z.object({ id: z.string() }), + query: z.object({ dryRun: z.coerce.boolean().optional() }).optional(), + headers: z.object({ 'x-trace-id': z.string() }).optional(), + body: z.object({ name: z.string() }), + })) + .handler(async ({ input }) => { + return input + }) +``` + +For `POST /planets/earth?dryRun=true` with header `x-trace-id: abc123` and body `{ "name": "Earth" }`, the procedure receives: + +```json +{ + "params": { "id": "earth" }, + "query": { "dryRun": true }, + "headers": { "x-trace-id": "abc123" }, + "body": { "name": "Earth" } +} +``` + +::: info +You only need to define the fields you want to access. For example, if you only care about path params and the request body, your input schema can include just `params` and `body`. +::: + +### Path Parameter Styles + +By default, path parameters are decoded as plain strings. Use `paramsStyles` to override how each path parameter is encoded and decoded. + +```ts +const getPlanets = os + .meta(openapi({ + method: 'GET', + path: '/planets/{ids}/{filters}', + paramsStyles: { + ids: 'comma-delimited-array', + filters: 'comma-delimited-object', + }, + })) + .input(z.object({ + ids: z.array(z.string()), + filters: z.object({ + type: z.string(), + status: z.string(), + }), + })) + .handler(async () => []) +``` + +Supported path parameter styles: + +| Style | Example path segment | Decoded value | +| ------------------------ | ---------------------------------- | ------------------------------------------------- | +| `primitive` _(default)_ | `/planets/earth` | `{ id: 'earth' }` | +| `comma-delimited-array` | `/planets/earth,mars` | `{ ids: ['earth', 'mars'] }` | +| `comma-delimited-object` | `/planets/type,rocky,status,known` | `{ filters: { type: 'rocky', status: 'known' } }` | + +::: warning +When using delimited styles, do not use delimiter characters like `,` in keys or values. They can make the parameter ambiguous. +::: + +### Query Styles + +By default, query parameters are decoded with [bracket notation](/docs/openapi/bracket-notation). Use `queryStyles` to override how each query parameter is encoded and decoded. + +```ts +const searchPlanets = os + .meta(openapi({ + method: 'GET', + path: '/planets', + queryStyles: { + keyword: 'primitive', + tags: 'comma-delimited-array', + filters: 'comma-delimited-object', + meta: 'json', + }, + })) + .handler(async () => []) +``` + +Supported query styles: + +| Style | Example | Decoded value | +| ------------------------ | ----------------------------------------------- | --------------------------------------------------------- | +| `primitive` | `?tag=a&tag=b` | `{ tag: 'b' }` | +| `array` | `?tag=a&tag=b` | `{ tag: ['a', 'b'] }` | +| `comma-delimited-array` | `?tags=red,blue` | `{ tags: ['red', 'blue'] }` | +| `comma-delimited-object` | `?filter=size,large,brand,nike` | `{ filter: { size: 'large', brand: 'nike' } }` | +| `space-delimited-array` | `?tags=red blue` | `{ tags: ['red', 'blue'] }` | +| `space-delimited-object` | `?filter=size large brand nike` | `{ filter: { size: 'large', brand: 'nike' } }` | +| `pipe-delimited-array` | `?tags=red\|blue` | `{ tags: ['red', 'blue'] }` | +| `pipe-delimited-object` | `?filter=size\|large\|brand\|nike` | `{ filter: { size: 'large', brand: 'nike' } }` | +| `json` | `?meta={"enabled":true}` | `{ meta: { enabled: true } }` | +| _default_ | `?tags[]=red&tags[]=blue&filter[status]=active` | `{ tags: ['red', 'blue'], filter: { status: 'active' } }` | + +::: warning +When using delimited styles, do not use delimiter characters like `,`, ` `, or `|` in keys or values. They can make the parameter ambiguous. +::: + +## Output Mapping + +By default, oRPC uses `compact` mode. The procedure's return value becomes the response body, and the status code comes from `successStatus`, which defaults to `200`. + +```ts +const getPlanet = os + .meta(openapi({ method: 'GET', path: '/planets', successStatus: 200 })) + .handler(async () => { + return { id: 'earth', name: 'Earth' } + }) +``` + +### Detailed Output Structure + +In `detailed` mode, return an object with the following fields: + +- `status`: optional success status code _(defaults to `successStatus`)_ +- `headers`: optional response headers in lower-case keys +- `body`: optional response body + +```ts +const savePlanet = os + .meta(openapi({ + method: 'PUT', + path: '/planets/{id}', + outputStructure: 'detailed', + successStatus: 200, + })) + .input(z.object({ id: z.string() })) + .output(z.union([ + z.object({ + status: z.literal(201).meta({ description: 'Created' }), + body: z.object({ id: z.string(), name: z.string() }), + }), + z.object({ + status: z.literal(200).meta({ description: 'Updated' }), + body: z.object({ id: z.string(), name: z.string() }), + }), + ])) + .handler(async ({ input }) => { + if (!isExistingPlanet(input.id)) { + return { + status: 201, + headers: { 'x-created': 'true' }, + body: { id: 'earth', name: 'Earth' }, + } + } + + return { + body: { id: 'earth', name: 'Earth' }, + } + }) +``` + +## Body Hints + +The body parser normally uses `Content-Type`, `Content-Length`, `Content-Disposition`, and `Standard-Server` headers to decide how to parse the body. If that information is missing or misleading, use `requestBodyHint` to tell [OpenAPI Handler](/docs/openapi/handler) how to parse the request body. Likewise, use `responseBodyHint` to tell [OpenAPI Link](/docs/openapi/link) how to parse the response body. + +```ts +const uploadLargeFile = os + .meta(openapi({ + requestBodyHint: 'octet-stream', + responseBodyHint: 'json', + })) + .input(z.instanceof(ReadableStream)) + .handler(async ({ input }) => { + for await (const chunk of input) { + // process chunk + } + + return { ok: true } + }) +``` + +Supported body hints: + +| Hint | Parsed Result | +| ------------------- | --------------------------------------------------------------------------------- | +| `json` | JSON value | +| `form-data` | `FormData` decoded with [bracket notation](/docs/openapi/bracket-notation) | +| `url-search-params` | `URLSearchParams` decoded with [bracket notation](/docs/openapi/bracket-notation) | +| `event-stream` | [Event Iterator](/docs/event-iterator) | +| `octet-stream` | `ReadableStream` for streamed binary data | +| `file` | `File` for binary data | +| `none` | `undefined` | + +::: info +Learn more about body hints in the [Standard Server documentation](https://github.com/middleapi/standardserver#standard-body) +::: + +## Metadata Merging + +When `openapi` is applied multiple times, `paramsStyles` and `queryStyles` are spreading-merged, while `inputStructure`, `outputStructure`, `responseBodyHint`, and `requestBodyHint` are overridden by the most recent call. For full merge behavior, see the [source code](https://github.com/orpc/orpc/blob/main/packages/openapi/src/meta.ts). + +```ts +const router = os + .meta(openapi({ inputStructure: 'detailed' })) + .router({ + get: os + .meta(openapi({ method: 'GET', path: '/planets', inputStructure: 'compact' })) + .meta(openapi({ queryStyles: { tags: 'comma-delimited-array' } })) + .meta(openapi({ queryStyles: { q: 'primitive' } })) + .input(z.object({ tags: z.array(z.string()), q: z.string().optional() })) + .handler(async () => ([])), + }) +``` + +These are equivalent to: + +```ts +const router = { + get: os + .meta(openapi({ + method: 'GET', + path: '/planets', + inputStructure: 'compact', + queryStyles: { + tags: 'comma-delimited-array', + q: 'primitive', + }, + })) + .input(z.object({ tags: z.array(z.string()), q: z.string().optional() })) + .handler(async () => ([])), +} +``` + +::: info +Metadata resets to its default behavior when set to `undefined` in subsequent calls: + +```ts +const example = os + .meta(openapi({ queryStyles: { tags: 'comma-delimited-array' } })) + .meta(openapi({ queryStyles: undefined })) +``` + +In this example, the final `queryStyles` is `undefined`, so query parameters are parsed with the default bracket notation. + +::: diff --git a/apps/content/docs/openapi/input-output-structure.md b/apps/content/docs/openapi/input-output-structure.md deleted file mode 100644 index 8b5fa2bbd..000000000 --- a/apps/content/docs/openapi/input-output-structure.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -title: Input/Output Structure -description: Control how input and output data is structured in oRPC ---- - -# Input/Output Structure - -oRPC allows you to control the organization of request inputs and response outputs using the `inputStructure` and `outputStructure` options. This is especially useful when you need to handle parameters, query strings, headers, and body data separately. - -## Input Structure - -The `inputStructure` option defines how the incoming request data is structured. - -### Compact Mode (default) - -Combines path parameters with query or body data (depending on the HTTP method) into a single object. - -```ts -const compactMode = os.route({ - path: '/ping/{name}', - method: 'POST', -}) - .input(z.object({ - name: z.string(), - description: z.string().optional(), - })) -``` - -### Detailed Mode - -Provide an object whose fields correspond to each part of the request: - -- `params`: Path parameters (`Record | undefined`) -- `query`: Query string data (`any`) -- `headers`: Headers (`Record`) -- `body`: Body data (`any`) - -```ts -const detailedMode = os.route({ - path: '/ping/{name}', - method: 'POST', - inputStructure: 'detailed', -}) - .input(z.object({ - params: z.object({ name: z.string() }), - query: z.object({ search: z.string() }), - body: z.object({ description: z.string() }).optional(), - headers: z.object({ 'x-custom-header': z.string() }), - })) -``` - -## Output Structure - -The `outputStructure` option determines the format of the response based on the output data. - -### Compact Mode (default) - -Returns the output data directly as the response body. - -```ts -const compactMode = os - .handler(async ({ input }) => { - return { message: 'Hello, world!' } - }) -``` - -### Detailed Mode - -Returns an object with these optional properties: - -- `status`: The response status (must be in 200-399 range) if not set fallback to `successStatus`. -- `headers`: Custom headers to merge with the response headers (`Record`). -- `body`: The response body. - -```ts -const detailedMode = os - .route({ outputStructure: 'detailed' }) - .handler(async ({ input }) => { - return { - headers: { 'x-custom-header': 'value' }, - body: { message: 'Hello, world!' }, - } - }) - -const multipleStatus = os - .route({ outputStructure: 'detailed' }) - .output(z.union([ // for openapi spec generator - z.object({ - status: z.literal(201).describe('record created'), - body: z.string() - }), - z.object({ - status: z.literal(200).describe('record updated'), - body: z.string() - }), - ])) - .handler(async ({ input }) => { - if (something) { - return { - status: 201, - body: 'created', - } - } - - return { - status: 200, - body: 'updated', - } - }) -``` - -## Initial Configuration - -Customize the initial oRPC input/output structure settings using `.$route`: - -```ts -const base = os.$route({ inputStructure: 'detailed' }) -``` diff --git a/apps/content/docs/openapi/integrations/hey-api.md b/apps/content/docs/openapi/integrations/hey-api.md deleted file mode 100644 index f3e1f01a0..000000000 --- a/apps/content/docs/openapi/integrations/hey-api.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: Hey API Integration -description: Generate oRPC contracts from OpenAPI with Hey API or adapt a Hey API generated client into an oRPC client. ---- - -# Hey API Integration - -[Hey API](https://heyapi.dev/) can be integrated with oRPC in two ways, depending on what you start with: - -- Generate an oRPC contract from an existing OpenAPI specification. -- Convert an existing Hey API generated client directly into an oRPC client. - -::: warning -The [Hey API](https://heyapi.dev/) integration is still unstable. As Hey API continues to evolve, this integration may introduce breaking changes in the future. -::: - -## Convert OpenAPI to an oRPC Contract - -If you already have an OpenAPI specification, you can use Hey API to generate an oRPC contract. See [OpenAPI to Contract](/docs/openapi/openapi-to-contract) for the complete setup and next steps. - -Once generated, you can use the contract to: - -- Implement the contract on your own server with [Implement Contract](/docs/contract-first/implement-contract). -- Create a type-safe client with [OpenAPILink](/docs/openapi/client/openapi-link). -- Use the generated contract as a reference alongside [Define Contract](/docs/contract-first/define-contract) to better understand its structure. - -## Convert a Hey API Client Directly to an oRPC Client - -If you already have a generated [Hey API client](https://heyapi.dev/openapi-ts/output) and want to use it as an oRPC client without generating a contract first, use `toORPCClient`. - -```ts -import { experimental_toORPCClient } from '@orpc/hey-api' -import * as sdk from 'src/client/sdk.gen' - -export const client = experimental_toORPCClient(sdk) - -const { body } = await client.listPlanets() -``` - -This `client` behaves like any standard oRPC [server-side client](/docs/client/server-side) or [client-side client](/docs/client/client-side), so you can use it with any oRPC-compatible library. - -### Error Handling - -Internally, oRPC passes the `throwOnError` option to the Hey API client. If the original Hey API client throws an error, oRPC forwards it as is, ensuring consistent error handling. diff --git a/apps/content/docs/openapi/integrations/implement-contract-in-nest.md b/apps/content/docs/openapi/integrations/implement-contract-in-nest.md deleted file mode 100644 index b08e9f605..000000000 --- a/apps/content/docs/openapi/integrations/implement-contract-in-nest.md +++ /dev/null @@ -1,373 +0,0 @@ ---- -title: Implement Contract in NestJS -description: Seamlessly implement oRPC contracts in your NestJS projects. ---- - -# Implement Contract in NestJS - -This guide explains how to easily implement [oRPC contract](/docs/contract-first/define-contract) within your [NestJS](https://nestjs.com/) application using `@orpc/nest`. - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/nest@latest -``` - -```sh [yarn] -yarn add @orpc/nest@latest -``` - -```sh [pnpm] -pnpm add @orpc/nest@latest -``` - -```sh [bun] -bun add @orpc/nest@latest -``` - -```sh [deno] -deno add npm:@orpc/nest@latest -``` - -::: - -## Requirements - -oRPC is an ESM-only library. Therefore, your NestJS application must be configured to support ESM modules. - -1. **Configure `tsconfig.json`**: with `"module": "NodeNext"` or a similar ESM-compatible option. - - ```json - { - "compilerOptions": { - "module": "NodeNext", // <-- this is recommended - "strict": true // <-- this is recommended - // ... other options, - } - } - ``` - -2. **Node.js Environment**: - - **Node.js 22+**: Recommended, as it allows `require()` of ESM modules natively. - - **Older Node.js versions**: Alternatively, use a bundler to compile ESM modules (including `@orpc/nest`) to CommonJS. - - ::: warning - By default, NestJS bundler ([Webpack](https://webpack.js.org/) or [SWC](https://swc.rs/)) might not compile `node_modules`. You may need to adjust your bundler configs to include `@orpc/nest` for compilation. - ::: - -## Define Your Contract - -Before implementation, define your oRPC contract. This process is consistent with the standard oRPC methodology. For detailed guidance, refer to the main [Contract-First guide](/docs/contract-first/define-contract). - -::: details Example Contract - -```ts -import { oc, populateContractRouterPaths } from '@orpc/contract' -import * as z from 'zod' - -export const PlanetSchema = z.object({ - id: z.number().int().min(1), - name: z.string(), - description: z.string().optional(), -}) - -export const listPlanetContract = oc - .route({ - method: 'GET', - path: '/planets' // Path is required for NestJS implementation - }) - .input( - z.object({ - limit: z.number().int().min(1).max(100).optional(), - cursor: z.number().int().min(0).default(0), - }), - ) - .output(z.array(PlanetSchema)) - -export const findPlanetContract = oc - .route({ - method: 'GET', - path: '/planets/{id}' // Path is required - }) - .input(PlanetSchema.pick({ id: true })) - .output(PlanetSchema) - -export const createPlanetContract = oc - .route({ - method: 'POST', - path: '/planets' // Path is required - }) - .input(PlanetSchema.omit({ id: true })) - .output(PlanetSchema) - -/** - * populateContractRouterPaths is completely optional, - * because the procedure's path is required for NestJS implementation. - * This utility automatically populates any missing paths - * Using the router's keys + `/`. - */ -export const contract = populateContractRouterPaths({ - planet: { - list: listPlanetContract, - find: findPlanetContract, - create: createPlanetContract, - }, -}) -``` - -::: - -::: warning -For a contract to be implementable in NestJS using `@orpc/nest`, each contract **must** define a `path` in its `.route`. Omitting it will cause a build‑time error. -You can avoid this by using the `populateContractRouterPaths` utility to automatically fill in any missing paths. -::: - -## Path Parameters - -Aside from [oRPC Path Parameters](/docs/openapi/routing#path-parameters), regular NestJS route patterns still work out of the box. However, they are not standard in OpenAPI, so we recommend using oRPC Path Parameters exclusively. - -::: warning -[oRPC Path Parameter matching with slashes (/)](/docs/openapi/routing#path-parameters) does not work on the NestJS Fastify platform, because Fastify does not allow wildcard (`*`) aliasing in path parameters. -::: - -## Implement Your Contract - -```ts -import { Implement, implement, ORPCError } from '@orpc/nest' - -@Controller() -export class PlanetController { - /** - * Implement a standalone procedure - */ - @Implement(contract.planet.list) - list() { - return implement(contract.planet.list).handler(({ input }) => { - // Implement logic here - - return [] - }) - } - - /** - * Implement entire contract - */ - @Implement(contract.planet) - planet() { - return { - list: implement(contract.planet.list).handler(({ input }) => { - // Implement logic here - return [] - }), - find: implement(contract.planet.find).handler(({ input }) => { - // Implement logic here - return { - id: 1, - name: 'Earth', - description: 'The planet Earth', - } - }), - create: implement(contract.planet.create).handler(({ input }) => { - // Implement logic here - return { - id: 1, - name: 'Earth', - description: 'The planet Earth', - } - }), - } - } - - // other handlers... -} -``` - -::: info -The `@Implement` decorator functions similarly to NestJS built-in HTTP method decorators (e.g., `@Get`, `@Post`). Handlers decorated with `@Implement` are standard NestJS controller handlers and can leverage all NestJS features. -::: - -## Body Parser - -By default, NestJS parses request bodies for `application/json` and `application/x-www-form-urlencoded` content types. However: - -- NestJS `urlencoded` parser does not support [Bracket Notation](/docs/openapi/bracket-notation) like in standard oRPC parsers. -- In some edge cases like uploading a file with `application/json` content type, the NestJS parser does not treat it as a file, instead it parses the body as a JSON string. - -Therefore, we **recommend** disabling the NestJS body parser: - -```ts -import { NestFactory } from '@nestjs/core' -import { AppModule } from './app.module' - -async function bootstrap() { - const app = await NestFactory.create(AppModule, { - bodyParser: false, // [!code highlight] - }) - - await app.listen(process.env.PORT ?? 3000) -} -``` - -::: info -oRPC will use NestJS parsed body when it's available, and only use the oRPC parser if the body is not parsed by NestJS. -::: - -## Hono Adapter - -`@orpc/nest` supports NestJS applications that use Hono-based HTTP adapters. - -For example, install [`@mnigos/platform-hono`](https://www.npmjs.com/package/@mnigos/platform-hono) -and its Hono peer dependencies: - -```sh -pnpm add @mnigos/platform-hono @hono/node-server hono -``` - -Then pass the adapter to `NestFactory.create`: - -```ts -import { HonoAdapter } from '@mnigos/platform-hono' -import { NestFactory } from '@nestjs/core' -import { AppModule } from './app.module' - -async function bootstrap() { - const app = await NestFactory.create(AppModule, new HonoAdapter(), { - bodyParser: false, - }) - - await app.listen(process.env.PORT ?? 3000) -} -``` - -`@Implement` routes continue to use the same contract paths and route -parameters. Internally, oRPC reads the Hono `Request` and returns a Hono-native -`Response` through the adapter response context. - -## Configuration - -Configure the `@orpc/nest` module by importing `ORPCModule` in your NestJS application: - -```ts -import { Module } from '@nestjs/common' -import { REQUEST } from '@nestjs/core' -import { onError, ORPCError, ORPCModule } from '@orpc/nest' -import { Request } from 'express' // if you use express adapter -import { - experimental_RethrowHandlerPlugin as RethrowHandlerPlugin, -} from '@orpc/server/plugins' - -declare module '@orpc/nest' { - /** - * Extend oRPC global context to make it type-safe inside your handlers/middlewares - */ - interface ORPCGlobalContext { - request: Request - } -} - -@Module({ - imports: [ - ORPCModule.forRootAsync({ // or use .forRoot for static config - useFactory: (request: Request) => ({ - interceptors: [ - onError((error) => { - console.error(error) - }), - ], - context: { request }, // oRPC context, accessible from middlewares, etc. - eventIteratorKeepAliveInterval: 5000, // 5 seconds - customJsonSerializers: [], - plugins: [ - new RethrowHandlerPlugin({ - filter: (error) => { - // Rethrow all non-ORPCError errors - // This allows unhandled exceptions to bubble up to NestJS global exception filters - return !(error instanceof ORPCError) - }, - }) - ], // most oRPC plugins are compatible - }), - inject: [REQUEST], - }), - ], -}) -export class AppModule {} -``` - -::: info - -- **`interceptors`** - [Server-side client interceptors](/docs/client/server-side#lifecycle) for intercepting input, output, and errors. -- **`eventIteratorKeepAliveInterval`** - Keep-alive interval for event streams (see [Event Iterator Keep Alive](/docs/rpc-handler#event-iterator-keep-alive)) - -::: - -## Create a Type-Safe Client - -When you implement oRPC contracts in NestJS using `@orpc/nest`, the resulting API endpoints are OpenAPI compatible. This allows you to use an OpenAPI-compatible client link, such as [OpenAPILink](/docs/openapi/client/openapi-link), to interact with your API in a type-safe way. - -```typescript -import type { JsonifiedClient } from '@orpc/openapi-client' -import type { ContractRouterClient } from '@orpc/contract' -import { createORPCClient } from '@orpc/client' -import { OpenAPILink } from '@orpc/openapi-client/fetch' - -const link = new OpenAPILink(contract, { - url: 'http://localhost:3000', - headers: () => ({ - 'x-api-key': 'my-api-key', - }), - // fetch: <-- polyfill fetch if needed -}) - -const client: JsonifiedClient> = createORPCClient(link) -``` - -::: info -Please refer to the [OpenAPILink](/docs/openapi/client/openapi-link) documentation for more information on client setup and options. -::: - -## Advanced - -### Custom Send Response - -By default, oRPC sends the response directly without returning it to the NestJS handler. However, you may want to preserve the return behavior for compatibility with certain NestJS features or third-party libraries. - -```ts -import { Module } from '@nestjs/common' -import { ORPCModule } from '@orpc/nest' -import { Response } from 'express' // if you use express adapter -import { isObject } from '@orpc/shared' // checks if value is a plain object (not a class instance) - -@Module({ - imports: [ - ORPCModule.forRoot({ - sendResponseInterceptors: [ - async ({ response, standardResponse, next }) => { - if ( - standardResponse.status < 200 - || standardResponse.status >= 300 - || !(isObject(standardResponse.body) || Array.isArray(standardResponse.body)) - ) { - // Only object and array are valid to return as response body - // the rest should fallback to default oRPC behavior - return next() - } - - const expressResponse = response as Response - expressResponse.status(standardResponse.status) - for (const [key, value] of Object.entries(standardResponse.headers)) { - if (value !== undefined) { - expressResponse.setHeader(key, value) - } - } - - return standardResponse.body - }, - ], - }), - ], -}) -export class AppModule {} -``` diff --git a/apps/content/docs/openapi/integrations/trpc.md b/apps/content/docs/openapi/integrations/trpc.md deleted file mode 100644 index f76d36085..000000000 --- a/apps/content/docs/openapi/integrations/trpc.md +++ /dev/null @@ -1,133 +0,0 @@ ---- -title: tRPC Integration -description: Use oRPC features in your tRPC applications. ---- - -# tRPC Integration - -This guide explains how to integrate oRPC with tRPC, allowing you to leverage oRPC features in your existing tRPC applications. - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/trpc@latest -``` - -```sh [yarn] -yarn add @orpc/trpc@latest -``` - -```sh [pnpm] -pnpm add @orpc/trpc@latest -``` - -```sh [bun] -bun add @orpc/trpc@latest -``` - -```sh [deno] -deno add npm:@orpc/trpc@latest -``` - -::: - -## OpenAPI Support - -By converting a [tRPC router](https://trpc.io/docs/server/routers) to an [oRPC router](/docs/router), you can utilize most oRPC features, including OpenAPI specification generation and request handling. - -```ts -import { ORPCMeta, toORPCRouter } from '@orpc/trpc' - -export const t = initTRPC.context().meta().create() - -const orpcRouter = toORPCRouter(trpcRouter) -``` - -::: warning -Ensure you set the `.meta` type to `ORPCMeta` when creating your tRPC builder. This is required for OpenAPI features to function properly. - -```ts -const example = t.procedure - .meta({ route: { path: '/hello', summary: 'Hello procedure' } }) // [!code highlight] - .input(z.object({ name: z.string() })) - .query(({ input }) => { - return `Hello, ${input.name}!` - }) -``` - -::: - -### Specification Generation - -```ts -const openAPIGenerator = new OpenAPIGenerator({ - schemaConverters: [ - new ZodToJsonSchemaConverter(), // <-- if you use Zod - new ValibotToJsonSchemaConverter(), // <-- if you use Valibot - new ArkTypeToJsonSchemaConverter(), // <-- if you use ArkType - ], -}) - -const spec = await openAPIGenerator.generate(orpcRouter, { - info: { - title: 'My App', - version: '0.0.0', - }, -}) -``` - -::: info -Learn more about [oRPC OpenAPI Specification Generation](/docs/openapi/openapi-specification). -::: - -### Request Handling - -```ts -const handler = new OpenAPIHandler(orpcRouter, { - plugins: [new CORSPlugin()], - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -export async function fetch(request: Request) { - const { matched, response } = await handler.handle(request, { - prefix: '/api', - context: {} // Add initial context if needed - }) - - return response ?? new Response('Not Found', { status: 404 }) -} -``` - -::: info -Learn more about [oRPC OpenAPI Handler](/docs/openapi/openapi-handler). -::: - -## Error Formatting - -The `toORPCRouter` does not support [tRPC Error Formatting](https://trpc.io/docs/server/error-formatting). You should catch errors and format them manually using interceptors: - -```ts -const handler = new OpenAPIHandler(orpcRouter, { - interceptors: [ - onError((error) => { - if ( - error instanceof ORPCError - && error.cause instanceof TRPCError - && error.cause.cause instanceof ZodError - ) { - throw new ORPCError('INPUT_VALIDATION_FAILED', { - status: 422, - data: error.cause.cause.flatten(), - cause: error.cause.cause, - }) - } - }) - ], -}) -``` diff --git a/apps/content/docs/openapi/link.md b/apps/content/docs/openapi/link.md new file mode 100644 index 000000000..b52a92b1c --- /dev/null +++ b/apps/content/docs/openapi/link.md @@ -0,0 +1,316 @@ +# OpenAPI Link + +Use `OpenAPILink` to call HTTP endpoints served by [OpenAPI Handler](/docs/openapi/handler) and other OpenAPI-compliant servers. + +## Overview + +```ts +const link = new OpenAPILink(contract, { + origin: 'https://api.example.com', + url: '/api', + headers: ({ context }) => ({ + authorization: context?.token ? `Bearer ${context.token}` : undefined, + }), + interceptors: [ + async ({ next, path }) => { + console.time(path.join('.')) + + try { + return await next() + } + finally { + console.timeEnd(path.join('.')) + } + }, + ], + plugins: [ + new RetryAfterLinkPlugin(), + ], + fetch: (request, init) => { // <- only available in fetch adapter + return globalThis.fetch(request, { + ...init, + credentials: 'include', // Include cookies on cross-origin requests + }) + }, +}) +``` + + + +## Typesafe Clients + +After you create an `OpenAPILink`, pass it to `createORPCClient` to build a typesafe client for either a [contract](/docs/contract/router) or a [router](/docs/router): + +```ts +import { createORPCClient } from '@orpc/client' +import { JsonifiedClient, RouterContractClient } from '@orpc/contract' +import { RouterClient } from '@orpc/server' + +// if you are following contract-first approach +const contractClient: JsonifiedClient> = createORPCClient(link) + +// if you are following normal approach +const routerClient: JsonifiedClient> = createORPCClient(link) +``` + +::: info +`JsonifiedClient` is required because of [OpenAPI serializer limitations](/docs/openapi/serializer#limitations). If you want to avoid `JsonifiedClient`, see [Expanding Type Support for OpenAPI Link](/docs/advanced/expanding-type-support-for-openapi-link). +::: + +## Client Context + +Client context lets you pass per-call values, such as auth tokens or cache hints. This context is available in link options, interceptors, plugins, and other extensibility points. + +```ts +type ClientContext = { + token?: string +} + +const link = new OpenAPILink(contract, { + headers: ({ context }) => ({ + authorization: context?.token ? `Bearer ${context.token}` : undefined, + }), +}) +``` + +::: info +Pass `ClientContext` when creating the client, then provide context on each call as needed: + +```ts +// if you are using the contract-first approach +const client: RouterContractClient = createORPCClient(link) + +// if you are using the standard approach +const client: RouterClient = createORPCClient(link) + +const output = await client.someProcedure(input, { + context: { + token: 'abc123', + }, +}) +``` + +::: + +## URL and Header Options + +Use `origin`, `url`, and `headers` to control request destination and headers. + +- `origin`: Server protocol and domain. Omit in the browser to use the current origin. +- `url`: Usually a path prefix like `/api`. May include query params that are added to every request. +- `headers`: Headers sent with every request, such as auth or trace IDs. Keys should be lowercase. + +```ts +const link = new OpenAPILink(contract, { + origin: 'https://api.example.com', + url: '/api?v=2', + headers: { + authorization: `Bearer ${getAuthToken()}`, + }, +}) +``` + +::: info +Each option can also be a function to dynamically customize values per request. For example, routing to a different `origin` based on the procedure path, or injecting headers from client context: + +```ts +const link = new OpenAPILink(contract, { + origin: ({ path, context }) => { + if (path[0] === 'internal') { + return 'https://internal.example.com' + } + + return 'https://api.example.com' + }, + headers: ({ context }) => ({ + authorization: context?.token ? `Bearer ${context.token}` : undefined, + }), +}) +``` + +::: + +## Interceptors + +Interceptors let you observe or customize different stages of an OpenAPI call. Common use cases include logging, retries, auth, batching, and transport customization. + +### Interceptors + +Interceptors run around the entire call, including input encoding, transport, and response decoding. Use them when you need access to the path, input, output, or error. + +```ts +const link = new OpenAPILink(contract, { + interceptors: [ + async ({ next, path, input }) => { + console.time(path.join('.')) + + try { + const output = await next() + return output + } + catch (err) { + console.error(`${path.join('.')}:`, err) + throw err + } + finally { + console.timeEnd(path.join('.')) + } + }, + ], +}) +``` + +### Transport Interceptors + +Interceptors run after input encoding and before response decoding. Use them to inspect or rewrite the request. + +```ts +const link = new OpenAPILink(contract, { + transportInterceptors: [ + async (options) => { + const response = await options.next({ + ...options, + request: { + ...options.request, + headers: { + ...options.request.headers, + 'x-request-id': crypto.randomUUID(), + }, + }, + }) + + return response + }, + ], +}) +``` + +### Adapter Interceptors + +Some `OpenAPILink` implementations also support adapter-specific interceptors. The fetch adapter exposes `fetchInterceptors`, which run right before `fetch` and give you access to the final `url` and `RequestInit`. + +```ts +const link = new OpenAPILink(contract, { + fetchInterceptors: [ + async (options) => { + const response = await options.next({ + ...options, + init: { + ...options.init, + credentials: 'include', + }, + }) + + return response + }, + ], +}) +``` + +::: info +This example uses the fetch adapter. For other adapters, refer to their JSDoc or adapter-specific documentation. +::: + +## Plugins + +Plugins package reusable interceptors. For example, [Retry After Plugin](/docs/plugins/retry-after) adds retry behavior based on the `retry-after` response header. + +```ts +const link = new OpenAPILink(contract, { + plugins: [ + new RetryAfterLinkPlugin(), + ], +}) +``` + +## Custom Serializer + +Provide a custom serializer when you need to extend or override the default serialization behavior. For more details, see [OpenAPI Serializer](/docs/openapi/serializer). + +```ts +const link = new OpenAPILink(contract, { + serializer: new OpenAPISerializer({ + handlers: { + // ...custom handlers + }, + }), +}) +``` + +## Custom Error Decoding + +If your server returns error responses that don't match oRPC's expected format, use `customErrorResponseBodyDecoder` to customize the decoding logic. This works together with [Custom Error Response](/docs/openapi/handler#custom-error-response) on the server. + +```ts +const link = new OpenAPILink(contract, { + customErrorResponseBodyDecoder: (body, response) => { + if (response.status === 422 && typeof body === 'object' && body && 'detail' in body) { + return new ORPCError('BAD_REQUEST', { + message: String(body.detail), + }) + } + + // fallback to default error decoding logic by returning null or undefined + return null + }, +}) +``` + +## Event Stream Options + +Configure how [event iterators](/docs/event-iterator) are streamed to the server. Available options depend on the adapter. For example, the fetch adapter supports: + +```ts +const link = new OpenAPILink(contract, { + toFetchBody: { + eventStream: { + initialComment: { + /** + * If true, an initial comment is sent immediately upon stream start to flush headers. + * This allows the receiving side to establish the connection without waiting for the first event. + * + * @default true + */ + enabled: true, + /** + * The content of the initial comment sent upon stream start. Must not include newline characters. + * + * @default '' + */ + comment: '', + }, + keepAlive: { + /** + * If true, a ping comment is sent periodically to keep the connection alive. + * + * @default true + */ + enabled: true, + /** + * Interval (in milliseconds) between ping comments sent after the last event. + * + * @default 5000 + */ + interval: 5000, + /** + * The content of the ping comment. Must not include newline characters. + * + * @default '' + */ + comment: '', + }, + /** + * If true, a `close` event is sent even when the iterator completes with `undefined`. + * When the iterator returns a value, a `close` event is always emitted regardless of this setting. + * + * @default true + */ + emptyCloseEventEnabled: true, + }, + }, +}) +``` + +## Lifecycle + +TODO: add lifecycle diagram diff --git a/apps/content/docs/openapi/openapi-handler.md b/apps/content/docs/openapi/openapi-handler.md deleted file mode 100644 index 9849cc174..000000000 --- a/apps/content/docs/openapi/openapi-handler.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -title: OpenAPI Handler -description: Comprehensive Guide to the OpenAPIHandler in oRPC ---- - -# OpenAPI Handler - -The `OpenAPIHandler` enables communication with clients over RESTful APIs, adhering to the OpenAPI specification. It is fully compatible with [OpenAPILink](/docs/openapi/client/openapi-link) and the [OpenAPI Specification](/docs/openapi/openapi-specification). - -## Supported Data Types - -`OpenAPIHandler` serializes and deserializes the following JavaScript types: - -- **string** -- **number** (`NaN` → `null`) -- **boolean** -- **null** -- **undefined** (`undefined` in arrays → `null`) -- **Date** (`Invalid Date` → `null`) -- **BigInt** (`BigInt` → `string`) -- **RegExp** (`RegExp` → `string`) -- **URL** (`URL` → `string`) -- **Record (object)** -- **Array** -- **Set** (`Set` → `array`) -- **Map** (`Map` → `array`) -- **Blob** (unsupported in `AsyncIteratorObject`) -- **File** (unsupported in `AsyncIteratorObject`) -- **AsyncIteratorObject** (only at the root level; powers the [Event Iterator](/docs/event-iterator)) -- **ReadableStream\** (supported only at the root level in `OpenAPIHandler`; not supported by client-side `OpenAPILink` until v2) - -::: warning -If a payload contains `Blob` or `File` outside the root level, it must use `multipart/form-data`. In such cases, oRPC applies [Bracket Notation](/docs/openapi/bracket-notation) and converts other types to strings (exclude `null` and `undefined` will not be represented). -::: - -:::tip -You can extend the list of supported types by [creating a custom serializer](/docs/openapi/advanced/openapi-json-serializer#extending-native-data-types). -::: - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/openapi@latest -``` - -```sh [yarn] -yarn add @orpc/openapi@latest -``` - -```sh [pnpm] -pnpm add @orpc/openapi@latest -``` - -```sh [bun] -bun add @orpc/openapi@latest -``` - -```sh [deno] -deno add npm:@orpc/openapi@latest -``` - -::: - -## Setup and Integration - -```ts -import { OpenAPIHandler } from '@orpc/openapi/fetch' // or '@orpc/server/node' -import { CORSPlugin } from '@orpc/server/plugins' -import { onError } from '@orpc/server' - -const handler = new OpenAPIHandler(router, { - plugins: [new CORSPlugin()], - interceptors: [ - onError((error) => { - console.error(error) - }), - ], -}) - -export default async function fetch(request: Request) { - const { matched, response } = await handler.handle(request, { - prefix: '/api', - context: {} // Add initial context if needed - }) - - if (matched) { - return response - } - - return new Response('Not Found', { status: 404 }) -} -``` - -## Filtering Procedures - -You can filter a procedure from matching by using the `filter` option: - -```ts -const handler = new OpenAPIHandler(router, { - filter: ({ contract, path }) => !contract['~orpc'].route.tags?.includes('internal'), -}) -``` - -## Lifecycle - -The `OpenAPIHandler` follows the same lifecycle as the [RPCHandler Lifecycle](/docs/rpc-handler#lifecycle), ensuring consistent behavior across different handler types. diff --git a/apps/content/docs/openapi/openapi-specification.md b/apps/content/docs/openapi/openapi-specification.md deleted file mode 100644 index 58f90c7b3..000000000 --- a/apps/content/docs/openapi/openapi-specification.md +++ /dev/null @@ -1,352 +0,0 @@ ---- -title: OpenAPI Specification -description: Generate OpenAPI specifications for oRPC with ease. ---- - -# OpenAPI Specification - -oRPC uses the [OpenAPI Specification](https://spec.openapis.org/oas/v3.1.0) to define APIs. It is fully compatible with [OpenAPILink](/docs/openapi/client/openapi-link) and [OpenAPIHandler](/docs/openapi/openapi-handler). - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/openapi@latest -``` - -```sh [yarn] -yarn add @orpc/openapi@latest -``` - -```sh [pnpm] -pnpm add @orpc/openapi@latest -``` - -```sh [bun] -bun add @orpc/openapi@latest -``` - -```sh [deno] -deno add npm:@orpc/openapi@latest -``` - -::: - -## Generating Specifications - -oRPC supports OpenAPI 3.1.1 and integrates seamlessly with popular schema libraries like [Zod](https://zod.dev/), [Valibot](https://valibot.dev), and [ArkType](https://arktype.io/). You can generate specifications from either a [Router](/docs/router) or a [Contract](/docs/contract-first/define-contract): - -:::info -Interested in support for additional schema libraries? [Let us know](https://github.com/middleapi/orpc/discussions/categories/ideas)! -::: - -::: details Want to create your own JSON schema converter? -You can use any existing `X to JSON Schema` converter to add support for additional schema libraries. For example, if you want to use [Valibot](https://valibot.dev) with oRPC (if not supported), you can create a custom converter to convert Valibot schemas into JSON Schema. - -```ts -import type { AnySchema } from '@orpc/contract' -import type { ConditionalSchemaConverter, JSONSchema, SchemaConvertOptions } from '@orpc/openapi' -import type { ConversionConfig } from '@valibot/to-json-schema' -import { toJsonSchema } from '@valibot/to-json-schema' - -export class ValibotToJsonSchemaConverter implements ConditionalSchemaConverter { - condition(schema: AnySchema | undefined): boolean { - return schema !== undefined && schema['~standard'].vendor === 'valibot' - } - - convert(schema: AnySchema | undefined, _options: SchemaConvertOptions): [required: boolean, jsonSchema: Exclude] { - // Most JSON schema converters do not convert the `required` property separately, so returning `true` is acceptable here. - return [true, toJsonSchema(schema as any)] - } -} -``` - -:::info -It's recommended to use the built-in converters because the oRPC implementations handle many edge cases and supports every type that oRPC offers. -::: - -```ts -import { OpenAPIGenerator } from '@orpc/openapi' -import { - ZodToJsonSchemaConverter -} from '@orpc/zod' // <-- zod v3 -import { - ZodToJsonSchemaConverter -} from '@orpc/zod/zod4' // <-- zod v4 -import { - experimental_ValibotToJsonSchemaConverter as ValibotToJsonSchemaConverter -} from '@orpc/valibot' -import { - experimental_ArkTypeToJsonSchemaConverter as ArkTypeToJsonSchemaConverter -} from '@orpc/arktype' - -const openAPIGenerator = new OpenAPIGenerator({ - schemaConverters: [ - new ZodToJsonSchemaConverter(), // <-- if you use Zod - new ValibotToJsonSchemaConverter(), // <-- if you use Valibot - new ArkTypeToJsonSchemaConverter(), // <-- if you use ArkType - ], -}) - -const specFromContract = await openAPIGenerator.generate(contract, { - info: { - title: 'My App', - version: '0.0.0', - }, - servers: [ - { url: 'https://api.example.com/v1', }, - ], -}) - -const specFromRouter = await openAPIGenerator.generate(router, { - info: { - title: 'My App', - version: '0.0.0', - }, - servers: [ - { url: 'https://api.example.com/v1', }, - ], -}) -``` - -:::warning -Features prefixed with `experimental_` are unstable and may lack some functionality. -::: - -## Common Schemas - -Define reusable schema components that can be referenced across your OpenAPI specification: - -```ts -const UserSchema = z.object({ - id: z.string(), - name: z.string(), - email: z.email(), -}) - -const PetSchema = z.object({ - id: z.string().transform(id => Number(id)).pipe(z.number()), -}) - -const spec = await generator.generate(router, { - commonSchemas: { - User: { - schema: UserSchema, - }, - InputPet: { - strategy: 'input', - schema: PetSchema, - }, - OutputPet: { - strategy: 'output', - schema: PetSchema, - }, - UndefinedError: { - error: 'UndefinedError' - } - }, -}) -``` - -:::info - -- The `strategy` option determines which schema definition to use when input and output types differ (defaults to `input`). This is needed because we cannot use the same `$ref` for both input and output in this case. - -- `UndefinedError` is used for undefined errors, which is very useful when using [Type-Safe Error Handling](/docs/error-handling#type‐safe-error-handling). - -::: - -## Filtering Procedures - -You can filter a procedure from the OpenAPI specification using the `filter` option: - -```ts -const spec = await generator.generate(router, { - filter: ({ contract, path }) => !contract['~orpc'].route.tags?.includes('internal'), -}) -``` - -## Operation Metadata - -You can enrich your API documentation by specifying operation metadata using the `.route` or `.tag`: - -```ts -const ping = os - .route({ - operationId: 'ping', // override auto-generated operationId - summary: 'the summary', - description: 'the description', - deprecated: false, - tags: ['tag'], - successDescription: 'the success description', - spec: { // override entire auto-generated operation object, can also be a callback for extending - operationId: 'customOperationId', - tags: ['tag'], - summary: 'the summary', - requestBody: { - required: true, - content: { - 'application/json': {}, - } - }, - responses: { - 200: { - description: 'customSuccessDescription', - content: { - 'application/json': {}, - }, - } - }, - } - }) - .handler(() => {}) - -// or append tag for entire router - -const router = os.tag('planets').router({ - // ... -}) -``` - -### Customizing Operation Objects - -You can also extend the operation object by defining `route.spec` as a callback, or by using `oo.spec` in errors or middleware: - -```ts -import { oo } from '@orpc/openapi' - -// Using `route.spec` as a callback -const procedure = os - .route({ - spec: spec => ({ - ...spec, - security: [{ 'api-key': [] }], - }), - }) - .handler(() => 'Hello, World!') - -// With errors -const base = os.errors({ - UNAUTHORIZED: oo.spec({ - data: z.any(), - }, { - security: [{ 'api-key': [] }], - }) -}) - -// With middleware -const requireAuth = oo.spec( - os.middleware(async ({ next, errors }) => { - throw new ORPCError('UNAUTHORIZED') - return next() - }), - { - security: [{ 'api-key': [] }], - } -) -``` - -Any [procedure](/docs/procedure) that includes the use above `errors` or `middleware` will automatically have the defined `security` property applied - -:::info -The `.spec` helper accepts a callback as its second argument, allowing you to override the entire operation object. -::: - -## `@orpc/zod` - -### Zod v4 - -#### File Schema - -Zod v4 includes a native `File` schema. oRPC will detect it automatically - no extra setup needed: - -```ts -import * as z from 'zod' - -const InputSchema = z.object({ - file: z.file(), - image: z.file().mime(['image/png', 'image/jpeg']), -}) -``` - -#### JSON Schema Customization - -`description` and `examples` metadata are supported out of the box: - -```ts -import * as z from 'zod' - -const InputSchema = z.object({ - name: z.string(), -}).meta({ - description: 'User schema', - examples: [{ name: 'John' }], -}) -``` - -For further customization, you can use the `JSON_SCHEMA_REGISTRY`, `JSON_SCHEMA_INPUT_REGISTRY`, and `JSON_SCHEMA_OUTPUT_REGISTRY`: - -```ts -import * as z from 'zod' -import { - JSON_SCHEMA_REGISTRY, -} from '@orpc/zod/zod4' - -export const InputSchema = z.object({ - name: z.string(), -}) - -JSON_SCHEMA_REGISTRY.add(InputSchema, { - description: 'User schema', - examples: [{ name: 'John' }], - // other options... -}) - -JSON_SCHEMA_INPUT_REGISTRY.add(InputSchema, { - // only for .input -}) - -JSON_SCHEMA_OUTPUT_REGISTRY.add(InputSchema, { - // only for .output -}) -``` - -### Zod v3 - -#### File Schema - -In the [File Upload/Download](/docs/file-upload-download) guide, `z.instanceof` is used to describe file/blob schemas. However, this method prevents oRPC from recognizing file/blob schema. Instead, use the enhanced file schema approach: - -```ts -import { z } from 'zod/v3' -import { oz } from '@orpc/zod' - -const InputSchema = z.object({ - file: oz.file(), - image: oz.file().type('image/*'), - blob: oz.blob() -}) -``` - -#### JSON Schema Customization - -If Zod alone does not cover your JSON Schema requirements, you can extend or override the generated schema: - -```ts -import { z } from 'zod/v3' -import { oz } from '@orpc/zod' - -const InputSchema = oz.openapi( - z.object({ - name: z.string(), - }), - { - examples: [ - { name: 'Earth' }, - { name: 'Mars' }, - ], - // additional options... - } -) -``` diff --git a/apps/content/docs/openapi/openapi-to-contract.md b/apps/content/docs/openapi/openapi-to-contract.md deleted file mode 100644 index 705e1d69a..000000000 --- a/apps/content/docs/openapi/openapi-to-contract.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: OpenAPI to Contract -description: Generate an oRPC contract from an existing OpenAPI specification with the Hey API oRPC plugin. ---- - -# OpenAPI to Contract - -If you already have an [OpenAPI Specification](https://swagger.io/specification/), you can generate an oRPC contract with [Hey API](https://heyapi.dev/)'s `orpc` plugin instead of defining the contract manually. - -::: warning -The Hey API `orpc` plugin is currently beta and may introduce breaking changes while the integration stabilizes. -::: - -## Example - -```sh -npm install -D @hey-api/openapi-ts -``` - -```ts [openapi-ts.config.ts] -import { defineConfig } from '@hey-api/openapi-ts' - -export default defineConfig({ - input: 'https://get.heyapi.dev/hey-api/backend', - output: 'src/client', - plugins: [ - { - name: 'orpc', - validator: { - input: 'zod', - }, - }, - ], -}) -``` - -Then run: - -```sh -npx @hey-api/openapi-ts -``` - -This generates an oRPC-compatible contract from your OpenAPI specification. In this example, `zod` is used for generated input validation. - -For more details about configuration options and plugin behavior, see the [Hey API oRPC plugin documentation](https://heyapi.dev/openapi-ts/plugins/orpc). - -## What To Do Next - -Once the contract is generated, what you do next depends on how you want to use it: - -- Implement the contract on your own server with [Implement Contract](/docs/contract-first/implement-contract). -- Create a type-safe client with [OpenAPILink](/docs/openapi/client/openapi-link). -- Use the generated contract as a reference alongside [Define Contract](/docs/contract-first/define-contract) to better understand its structure. diff --git a/apps/content/docs/openapi/plugins/openapi-reference.md b/apps/content/docs/openapi/plugins/openapi-reference.md deleted file mode 100644 index e4f54a305..000000000 --- a/apps/content/docs/openapi/plugins/openapi-reference.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: OpenAPI Reference Plugin (Swagger/Scalar) -description: A plugin that serves API reference documentation and the OpenAPI specification for your API. ---- - -# OpenAPI Reference Plugin (Swagger/Scalar) - -This plugin provides API reference documentation powered by [Scalar](https://github.com/scalar/scalar) or [Swagger UI](https://swagger.io/tools/swagger-ui/), along with the OpenAPI specification in JSON format. - -::: info -This plugin relies on the [OpenAPI Generator](/docs/openapi/openapi-specification). Please review its documentation before using this plugin. -::: - -## Setup - -```ts -import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4' -import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' - -const handler = new OpenAPIHandler(router, { - plugins: [ - new OpenAPIReferencePlugin({ - docsProvider: 'swagger', // default: 'scalar' - schemaConverters: [ - new ZodToJsonSchemaConverter(), - ], - specGenerateOptions: { - info: { - title: 'ORPC Playground', - version: '1.0.0', - }, - servers: [ // or let the plugin auto-infer from the request - { url: 'https://api.example.com/v1', }, - ], - }, - }), - ] -}) -``` - -::: info -By default, the API reference client is served at the root path (`/`), and the OpenAPI specification is available at `/spec.json`. You can customize these paths by providing the `docsPath` and `specPath` options. -::: diff --git a/apps/content/docs/openapi/plugins/smart-coercion.md b/apps/content/docs/openapi/plugins/smart-coercion.md deleted file mode 100644 index 0bfacbe88..000000000 --- a/apps/content/docs/openapi/plugins/smart-coercion.md +++ /dev/null @@ -1,153 +0,0 @@ ---- -title: Smart Coercion Plugin -description: Automatically converts input values to match schema types without manually defining coercion logic. ---- - -# Smart Coercion Plugin - -Automatically converts input values to match schema types without manually defining coercion logic. - -::: warning -This plugin improves developer experience but impacts performance. For high-performance applications or complex schemas, manually defining coercion in your schema validation is more efficient. -::: - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/json-schema@latest -``` - -```sh [yarn] -yarn add @orpc/json-schema@latest -``` - -```sh [pnpm] -pnpm add @orpc/json-schema@latest -``` - -```sh [bun] -bun add @orpc/json-schema@latest -``` - -```sh [deno] -deno add npm:@orpc/json-schema@latest -``` - -::: - -## Setup - -Configure the plugin with [JSON Schema Converters](/docs/openapi/openapi-specification#generating-specifications) for your validation libraries. - -```ts -import { OpenAPIHandler } from '@orpc/openapi/fetch' -import { SmartCoercionPlugin } from '@orpc/json-schema' - -const handler = new OpenAPIHandler(router, { - plugins: [ - new SmartCoercionPlugin({ - schemaConverters: [ - new ZodToJsonSchemaConverter(), - // Add other schema converters as needed - ], - }) - ] -}) -``` - -## How It Works - -The plugin converts values **safely** using these rules: - -1. **Schema-guided:** Only converts when the schema says what type to use -2. **Safe only:** Only converts values that make sense (like `'123'` to `123`) -3. **Keep original:** If conversion is unsafe, keeps the original value -4. **Smart unions:** Picks the best conversion for union types -5. **Deep conversion:** Works inside nested objects and arrays - -::: info -JavaScript native types such as BigInt, Date, RegExp, URL, Set, and Map are not natively supported by JSON Schema. To enable correct coercion, oRPC relies on the `x-native-type` metadata in your schema: - -- `x-native-type: 'bigint'` for BigInt -- `x-native-type: 'date'` for Date -- `x-native-type: 'regexp'` for RegExp -- `x-native-type: 'url'` for URL -- `x-native-type: 'set'` for Set -- `x-native-type: 'map'` for Map - -The built-in [JSON Schema Converters](/docs/openapi/openapi-specification#generating-specifications) handle these cases (except for some experimental converters). Since this approach is not part of the official JSON Schema specification, if you use a custom converter, you may need to add the appropriate `x-native-type` metadata to your schemas to ensure proper coercion. -::: - -## Conversion Rules - -### String → Boolean - -Support specific string values (case-insensitive): - -- `'true'`, `'on'` → `true` -- `'false'`, `'off'` → `false` - -::: info -HTML `` elements submit `'on'` or `'off'` as values, so this conversion is especially useful for handling checkbox input in forms. -::: - -### String → Number - -Support valid numeric strings: - -- `'123'` → `123` -- `'3.14'` → `3.14` - -### String/Number → BigInt - -Support valid numeric strings or numbers: - -- `'12345678901234567890'` → `12345678901234567890n` -- `12345678901234567890` → `12345678901234567890n` - -### String → Date - -Support ISO date/datetime strings: - -- `'2023-10-01'` → `new Date('2023-10-01')` -- `'2020-01-01T06:15'` → `new Date('2020-01-01T06:15')` -- `'2020-01-01T06:15Z'` → `new Date('2020-01-01T06:15Z')` -- `'2020-01-01T06:15:00Z'` → `new Date('2020-01-01T06:15:00Z')` -- `'2020-01-01T06:15:00.123Z'` → `new Date('2020-01-01T06:15:00.123Z')` - -### String → RegExp - -Support valid regular expression strings: - -- `'/^\\d+$/i'` → `new RegExp('^\\d+$', 'i')` -- `'/abc/'` → `new RegExp('abc')` - -### String → URL - -Support valid URL strings: - -- `'https://example.com'` → `new URL('https://example.com')` - -### Array → Set - -Support arrays of **unique values**: - -- `['apple', 'banana']` → `new Set(['apple', 'banana'])` - -### Array → Object - -Converts arrays to objects with numeric keys: - -- `['apple', 'banana']` → `{ 0: 'apple', 1: 'banana' }` - -::: info -This is particularly useful for [Bracket Notation](/docs/openapi/bracket-notation) when you need objects with numeric keys. -::: - -### Array → Map - -Support arrays of key-value pairs with **unique keys**: - -- `[['key1', 'value1'], ['key2', 'value2']]` → `new Map([['key1', 'value1'], ['key2', 'value2']])` diff --git a/apps/content/docs/openapi/plugins/zod-smart-coercion.md b/apps/content/docs/openapi/plugins/zod-smart-coercion.md deleted file mode 100644 index 575c3c20b..000000000 --- a/apps/content/docs/openapi/plugins/zod-smart-coercion.md +++ /dev/null @@ -1,159 +0,0 @@ ---- -title: Zod Smart Coercion -description: A refined alternative to `z.coerce` that automatically converts inputs to the expected type without modifying the input schema. ---- - -# Zod Smart Coercion - -A Plugin refined alternative to `z.coerce` that automatically converts inputs to the expected type without modifying the input schema. - -::: warning -In Zod v4, this plugin only supports **discriminated unions**. Regular (non-discriminated) unions are **not** coerced automatically. -::: - -## Installation - -::: code-group - -```sh [npm] -npm install @orpc/zod@latest -``` - -```sh [yarn] -yarn add @orpc/zod@latest -``` - -```sh [pnpm] -pnpm add @orpc/zod@latest -``` - -```sh [bun] -bun add @orpc/zod@latest -``` - -```sh [deno] -deno add npm:@orpc/zod@latest -``` - -::: - -## Setup - -```ts -import { OpenAPIHandler } from '@orpc/openapi/fetch' -import { ZodSmartCoercionPlugin } from '@orpc/zod' // <-- zod v3 -import { - experimental_ZodSmartCoercionPlugin as ZodSmartCoercionPlugin -} from '@orpc/zod/zod4' // <-- zod v4 - -const handler = new OpenAPIHandler(router, { - plugins: [new ZodSmartCoercionPlugin()] -}) -``` - -:::warning -Do not use this plugin with [RPCHandler](/docs/rpc-handler) as it may negatively impact performance. -::: - -## Safe and Predictable Conversion - -Zod Smart Coercion converts data only when: - -1. The schema expects a specific type and the input can be converted. -2. The input does not already match the schema. - -For example: - -- If the input is `'true'` but the schema does not expect a boolean, no conversion occurs. -- If the schema accepts both boolean and string, `'true'` will not be coerced to a boolean. - -### Conversion Rules - -#### Boolean - -Converts string representations of boolean values: - -```ts -const raw = 'true' // Input -const coerced = true // Output -``` - -Supported values: - -- `'true'`, `'on'`, `'t'` → `true` -- `'false'`, `'off'`, `'f'` → `false` - -#### Number - -Converts numeric strings: - -```ts -const raw = '42' // Input -const coerced = 42 // Output -``` - -#### BigInt - -Converts strings representing valid BigInt values: - -```ts -const raw = '12345678901234567890' // Input -const coerced = 12345678901234567890n // Output -``` - -#### Date - -Converts valid date strings into Date objects: - -```ts -const raw = '2024-11-27T00:00:00.000Z' // Input -const coerced = new Date('2024-11-27T00:00:00.000Z') // Output -``` - -Supported formats: - -- Full ISO date-time (e.g., `2024-11-27T00:00:00.000Z`) -- Date only (e.g., `2024-11-27`) - -#### RegExp - -Converts strings representing regular expressions: - -```ts -const raw = '/^abc$/i' // Input -const coerced = /^abc$/i // Output -``` - -#### URL - -Converts valid URL strings into URL objects: - -```ts -const raw = 'https://example.com' // Input -const coerced = new URL('https://example.com') // Output -``` - -#### Set - -Converts arrays into Set objects, removing duplicates: - -```ts -const raw = ['apple', 'banana', 'apple'] // Input -const coerced = new Set(['apple', 'banana']) // Output -``` - -#### Map - -Converts arrays of key-value pairs into Map objects: - -```ts -const raw = [ - ['key1', 'value1'], - ['key2', 'value2'] -] // Input - -const coerced = new Map([ - ['key1', 'value1'], - ['key2', 'value2'] -]) // Output -``` diff --git a/apps/content/docs/openapi/routing.md b/apps/content/docs/openapi/routing.md index 68166816a..e121d974c 100644 --- a/apps/content/docs/openapi/routing.md +++ b/apps/content/docs/openapi/routing.md @@ -1,79 +1,190 @@ ---- -title: OpenAPI Routing -description: Configure procedure routing with oRPC. ---- +# OpenAPI Routing -# Routing - -Define how procedures map to HTTP methods, paths, and response statuses. - -:::warning -This feature applies only when using [OpenAPIHandler](/docs/openapi/openapi-handler). -::: +Use `openapi` metadata to control how a procedure is exposed over HTTP. ## Basic Routing -By default, oRPC uses the `POST` method, constructs paths from router keys with `/`, and returns a 200 status on success. Override these defaults with `.route`: +If you do not set OpenAPI routing metadata, a procedure is exposed as a `POST` endpoint whose path is derived from the router structure. For example: -```ts -os.route({ method: 'GET', path: '/example', successStatus: 200 }) -os.route({ method: 'POST', path: '/example', successStatus: 201 }) +```ts twoslash +import { os } from '@orpc/server' +// ---cut--- +import { openapi } from '@orpc/openapi' + +const router = { + planet: { + list: os + .meta(openapi({ method: 'GET', path: '/planets' })) + .handler(async () => [{ id: 'earth', name: 'Earth' }]), + create: os + .handler(async () => ({})), + } +} ``` -:::info -The `.route` can be called multiple times; each call [spread merges](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) the new route with the existing route. -::: +In this example, `list` is exposed as `GET /planets` because it overrides the default method and path. `create` keeps the default behavior, so it is exposed as `POST /planet/create`. ## Path Parameters -By default, path parameters merge with query/body into a single input object. You can modify this behavior as described in the [Input/Output structure docs](/docs/openapi/input-output-structure). +To define a path parameter, use `{name}` in the `path` and add the same field as a required key in the input schema: ```ts -os.route({ path: '/example/{id}' }) +import { z } from 'zod' + +const getPlanet = os + .meta(openapi({ method: 'GET', path: '/planets/{id}' })) .input(z.object({ id: z.string() })) +``` -os.route({ path: '/example/{+path}' }) // Matches slashes (/) +For catch-all path segments that may include `/`, use `{+name}`: + +```ts +const getFile = os + .meta(openapi({ method: 'GET', path: '/files/{+path}' })) .input(z.object({ path: z.string() })) ``` -## Route Prefixes +::: info +To customize path parameter encoding and decoding, see [Path Parameter Styles](/docs/openapi/input-and-output-mapping#path-parameter-styles). +::: -Use `.prefix` to prepend a common path to all procedures in a router that have an explicitly defined `path`: +## Prefixes + +Define `prefix` to prepend a path to a procedure, or an entire router: ```ts -const router = os.prefix('/planets').router({ - list: listPlanet, - find: findPlanet, - create: createPlanet, +const planetBuilder = os.meta(openapi({ prefix: '/planets' })) + +const listPlanets = planetBuilder + .meta(openapi({ method: 'GET', path: '/' })) + .handler(async () => [{ id: 'earth', name: 'Earth' }]) + +const createPlanet = planetBuilder + .handler(async () => ({})) + +const router = os.meta(openapi({ prefix: '/api/v2' })).router({ + planet: { + list: listPlanets, + create: createPlanet, + }, }) ``` -::: warning -The prefix only applies to procedures that specify a `path`. -::: +In this example, `listPlanets` is exposed as `GET /api/v2/planets/`. `createPlanet` is exposed as `POST /api/v2/planets/planet/create`. + +### Path Parameters in Prefixes + +Prefixes can also include path parameters, but they must be defined as required fields in the input schema. + +```ts +const base = os + .meta(openapi({ prefix: '/{workspaceId}' })) + .input(z.looseObject({ workspaceId: z.string() })) + .use(({ next }, { workspaceId }) => { + console.log('Workspace ID:', workspaceId) + return next() + }) + +const procedure = base + .meta(openapi({ method: 'GET', path: '/planets/{id}' })) + .input(z.looseObject({ id: z.string() })) + .handler(async ({ input }) => { + console.log('Workspace ID:', input.workspaceId) + console.log('Planet ID:', input.id) + }) +``` ## Lazy Router -When combining a [Lazy Router](/docs/router#lazy-router) with [OpenAPIHandler](/docs/openapi/openapi-handler), a prefix is required for lazy loading. Without it, the router behaves like a regular router. +When using a [lazy router](/docs/router#lazy-router), define a `prefix` so lazy loading is triggered only for relevant requests: -:::info -If you follow the [contract-first approach](/docs/contract-first/define-contract), you can ignore this requirement - oRPC knows the full contract and loads the router lazily properly. -::: +```ts +const router = { + project: os + .meta(openapi({ prefix: '/projects' })) + .lazy(() => import('./project')), +} +``` + +## Metadata Merging + +When `openapi` is applied multiple times, `prefix` values are concatenated. `method`, `path`, and `successStatus` are overridden by the most recent call. For full merge behavior, see the [source code](https://github.com/orpc/orpc/blob/main/packages/openapi/src/meta.ts). + +```ts +const router = os + .meta(openapi({ prefix: '/api/v2' })) + .router({ + get: os + .meta(openapi({ prefix: '/planets' })) + .meta(openapi({ method: 'GET', path: '/planets/{id}' })) + .meta(openapi({ path: '/{id}' })) + .input(z.object({ id: z.string() })) + .handler(async () => ({})), + }) +``` + +These calls are equivalent to: ```ts const router = { - planet: os.prefix('/planets').lazy(() => import('./planet')) + get: os + .meta(openapi({ + prefix: '/api/v2/planets', + method: 'GET', + path: '/{id}', + })) + .handler(async () => ({})), } ``` -:::warning -Do not use the `lazy` helper from `@orpc/server` here, as it cannot apply route prefixes. +::: info +Metadata resets to its default behavior when set to `undefined` in subsequent calls: + +```ts +const example = os + .meta(openapi({ prefix: '/api/v2' })) + .meta(openapi({ prefix: undefined })) +``` + +In this example, the final `prefix` is `undefined`, so no prefix is applied to `example`. + ::: -## Initial Configuration +## Shorthands -Customize the initial oRPC routing settings using `.$route`: +For common cases, use the shorthand helpers: ```ts -const base = os.$route({ method: 'GET' }) +const listPlanets = os + .meta(openapi.prefix('/planets')) + .meta(openapi.method('GET')) + .meta(openapi.path('/')) +``` + +## `.route` extension + +Import `@orpc/openapi/extensions/route` from a module that always runs during initialization, such as the file where you define your base builder or create your server. This adds a `.route` method to the builder, allowing you to define OpenAPI metadata directly without wrapping it in `.meta(openapi(...))`. + +::: code-group + +```ts [usage] +const ping = base + .route({ + method: 'GET', + path: '/ping', + }) + .input(z.object({ name: z.string(), })) + .handler(async ({ input }) => { + return `Hello ${input.name}!` + }) ``` + +```ts [setup] +import '@orpc/openapi/extensions/route' + +import { os } from '@orpc/server' + +export const base = os +``` + +::: diff --git a/apps/content/docs/openapi/scalar.md b/apps/content/docs/openapi/scalar.md index 233627f4a..77cb84ddb 100644 --- a/apps/content/docs/openapi/scalar.md +++ b/apps/content/docs/openapi/scalar.md @@ -1,29 +1,25 @@ ---- -title: Scalar (Swagger) -description: Create a beautiful API client for your oRPC effortlessly. ---- - # Scalar (Swagger) -Leverage the [OpenAPI Specification](/docs/openapi/openapi-specification) to generate a stunning API client for your oRPC using [Scalar](https://github.com/scalar/scalar). +Use [Scalar](https://github.com/scalar/scalar) to serve an interactive API reference for your oRPC API from an [OpenAPI specification](/docs/openapi/specification). ::: info -This guide covers the basics. For a simpler setup, consider using the [OpenAPI Reference Plugin](/docs/openapi/plugins/openapi-reference), which serves both the API reference UI and the OpenAPI specification. +This guide shows a manual setup. If you want a simpler option, use the [OpenAPI Reference Plugin](/docs/plugins/openapi-reference), which serves both the API reference UI and the OpenAPI specification for you. ::: ## Basic Example +This example serves the OpenAPI document at `/spec.json` and renders Scalar at `/`. + ```ts import { createServer } from 'node:http' import { OpenAPIGenerator } from '@orpc/openapi' import { OpenAPIHandler } from '@orpc/openapi/node' import { CORSPlugin } from '@orpc/server/plugins' -import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' +import { ZodToJsonSchemaConverter } from '@orpc/zod' const openAPIHandler = new OpenAPIHandler(router, { plugins: [ - new CORSPlugin(), - new ZodSmartCoercionPlugin(), + new CORSHandlerPlugin(), ], }) @@ -49,7 +45,7 @@ const server = createServer(async (req, res) => { version: '1.0.0', }, servers: [ - { url: '/api' }, /** Should use absolute URLs in production */ + { url: '/api' }, /** Use an absolute URL in production. */ ], security: [{ bearerAuth: [] }], components: { @@ -105,4 +101,4 @@ server.listen(3000, () => { }) ``` -Access the playground at `http://localhost:3000` to view your API client. +Open `http://localhost:3000` to view the API reference UI. diff --git a/apps/content/docs/openapi/serializer.md b/apps/content/docs/openapi/serializer.md new file mode 100644 index 000000000..8e0fa337b --- /dev/null +++ b/apps/content/docs/openapi/serializer.md @@ -0,0 +1,166 @@ +# OpenAPI Serializer + +OpenAPI Serializers handle one-way serialization to JSON-friendly formats. They let you partially support complex data types beyond plain JSON, such as `Date`, `BigInt`, `Set`, and even custom classes. + +## Supported Data Types + +`OpenAPISerializer` supports the following types by default: + +| Type | Handler key | Serialized | Notes | +| ---------------------------------------- | ----------- | ------------------ | ----------------------------- | +| **string** | | | | +| **number** | | | | +| **NaN** | `nan` | `null` | | +| **boolean** | | | | +| **null** | | | | +| **undefined** | `undefined` | `null` | Ignore `undefined` properties | +| **Date** | `date` | ISO String, `null` | | +| **BigInt** | `bigint` | string | | +| **RegExp** | `regexp` | string | | +| **URL** | `url` | string | | +| **Record (object)** | | | `toJSON` methods are ignored | +| **Array** | | | | +| **Set** | `set` | array | | +| **Map** | `map` | array | | +| **Blob** | | | Unsupported in Event Iterator | +| **File** | | | Unsupported in Event Iterator | +| **Event Iterator (AsyncIteratorObject)** | | | Only at the root level | +| **ReadableStream\** | | | Only at the root level | + + + +## Limitations + +OpenAPI Serializers are designed for one-way serialization to JSON-friendly formats. For example, a `Date` is serialized to an ISO string and remains a string after deserialization unless you add custom logic or plugins. + +In complex cases like mixed files with other data or nested structures in query strings, OpenAPI Serializer might use bracket notation to represent nested data, which has its own limitations. See [Bracket Notation Limitations](/docs/openapi/bracket-notation#limitations) for details. + +::: tip +If you use [OpenAPI Link](/docs/openapi/link) to connect your client and server, follow [Expanding Type Support for OpenAPI Link](/docs/advanced/expanding-type-support-for-openapi-link) to restore native types on the client. +::: + +## Custom Serializers + +Add custom handlers with unique keys to support additional types, or reuse a built-in key to override the default behavior. + +```ts twoslash +class Person { + constructor( + public name: string, + public age: number, + ) {} +} +// ---cut--- +import { OpenAPISerializer } from '@orpc/openapi' + +const serializer = new OpenAPISerializer({ + handlers: { + person: { // <- add support for Person + condition: v => v instanceof Person, + serialize: (v: Person) => ({ name: v.name, age: v.age }), + }, + date: { // <- replace the default Date handler + condition: v => v instanceof Date, + serialize: (v: Date) => v.getTime(), + }, + }, +}) +``` + +::: info Use a custom serializer with OpenAPIHandler and OpenAPILink + +```ts +const handler = new OpenAPIHandler(router, { + serializer, +}) + +const link = new OpenAPILink(contract, { + serializer, +}) +``` + +::: + +## Serialization Format + +In most cases, serialized data is JSON-serializable. + +```json +{ + "name": "John", + "age": 30, + "createdAt": "2024-01-01T00:00:00.000Z" +} +``` + +### With Files + +If the data includes nested `Blob` or `File`, the serializer returns a `FormData` object using [Bracket Notation](/docs/openapi/bracket-notation). Non-file values are converted to strings, and `null` or `undefined` fields are omitted. + +```ts +const form = new FormData() + +form.append('name', 'Earth') +form.append('thumbnail', new Blob([''], { type: 'image/png' })) +form.append('images[0]', new Blob([''], { type: 'image/png' })) +form.append('createdAt', '2022-01-01T00:00:00.000Z') +``` + +::: info +`images[0]` means the first item in `images` array. +::: + +### Direct File + +If the entire data is a single `Blob` or `File`, it can be sent as-is without wrapping in `FormData`. + +```http +HTTP/1.1 200 OK +Content-Type: image/png +Content-Disposition: attachment; filename="earth.png" +Content-Length: 12345 +Standard-Server: file + + +``` + +::: info +If the receiver mistakenly handles this payload as a regular (non-file) body, set the `standard-server` header to help the receiver detect the actual data type and handle it correctly. Learn more about this header in the [Standard Server Documentation](https://github.com/middleapi/standardserver#resolving-body). +::: + +### Event Iterator (AsyncIteratorObject) + +When the output is an event iterator (`AsyncIteratorObject`), it is sent as a [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) stream. Each event contains one serialized chunk of data. + +```http +HTTP/1.1 200 OK +Content-Type: text/event-stream + +event: message +data: {"name":"John","createdAt":"2024-01-01T00:00:00.000Z"} + +event: message +data: {"name":"Jane","createdAt":"2024-01-02T00:00:00.000Z"} +``` + +### ReadableStream\ + +A `ReadableStream` is passed through as-is and streamed as binary data. + +```http +HTTP/1.1 200 OK +Content-Type: application/octet-stream +Standard-Server: octet-stream + + + +``` + +::: info +If the receiver mistakenly handles this payload as a regular (non-stream) body, set the `standard-server` header to help the receiver detect the actual data type and handle it correctly. Learn more about this header in the [Standard Server Documentation](https://github.com/middleapi/standardserver#resolving-body). +::: + +## Learn More + +The serializer is a small, self-contained module, making it easy to understand. +To explore its behavior in detail, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/openapi/src/openapi-serializer.ts). diff --git a/apps/content/docs/openapi/specification.md b/apps/content/docs/openapi/specification.md new file mode 100644 index 000000000..bbda946d6 --- /dev/null +++ b/apps/content/docs/openapi/specification.md @@ -0,0 +1,320 @@ +# OpenAPI Specification + +Learn how to configure metadata and generate OpenAPI documents from your oRPC [contracts](/docs/contract/router) and [routers](/docs/router). + +## Metadata + +Use `openapi` metadata to control how a procedure appears in the generated OpenAPI document: + +```ts +import { oc } from '@orpc/contract' +import { openapi } from '@orpc/openapi' +import { z } from 'zod' + +const getPlanet = oc + .meta(openapi({ + method: 'GET', + path: '/planets/{id}', + operationId: 'getPlanet', + summary: 'Get a planet', + description: 'Returns a single planet.', + tags: ['planets'], + successStatus: 200, + successDescription: 'Planet payload', + })) + .input(z.object({ + id: z.string(), + })) + .output(z.object({ + id: z.string(), + name: z.string(), + })) +``` + +::: info +For routing metadata, you can learn more in [OpenAPI Routing](/docs/openapi/routing). For input and output mapping metadata, see [OpenAPI Input and Output Mapping](/docs/openapi/input-and-output-mapping). +::: + +### Customizing the Operation Object + +Use `spec` to customize the generated operation object. If `spec` is an object, it replaces the generated operation object entirely. If `spec` is a callback, it receives the final operation object and returns an extended version. + +```ts +const getPlanet = oc + .meta(openapi({ + method: 'GET', + path: '/planets/{id}', + spec: current => ({ + ...current, + security: [{ bearerAuth: [] }], + }), + })) + .input(z.object({ id: z.string() })) +``` + +### Metadata Merging + +When `openapi` is applied multiple times, `tags`, `spec`, `prefix`, `paramsStyle`, and `queryStyles` are deep-merged, while `operationId`, `summary`, `description`, `successDescription`, `method`, `path`, `successStatus`, `inputStructure`, `outputStructure`, `responseBodyHint`, and `requestBodyHint` are overridden by the most recent call. For full merge behavior, see the [source code](https://github.com/orpc/orpc/blob/main/packages/openapi/src/meta.ts). + +```ts +const router = os + .meta(openapi({ + tags: ['planets'], + spec: current => ({ + ...current, + security: [{ bearerAuth: [] }], + }), + })) + .router({ + list: os + .meta(openapi({ method: 'GET', summary: 'List planets', tags: ['list'] })) + .meta(openapi({ + spec: { + operationId: 'getPlanet', + summary: 'List planets', + responses: { + 200: { + description: 'List of planets', + }, + } + } + })) + .input(z.object({ q: z.string().optional() })) + .handler(async () => ([])), + }) +``` + +These are equivalent to: + +```ts +const router = { + list: os + .meta(openapi({ + method: 'GET', + tags: ['planets', 'list'], + summary: 'List planets', + spec: { + operationId: 'getPlanet', + summary: 'List planets', + responses: { + 200: { + description: 'List of planets', + }, + }, + security: [{ bearerAuth: [] }], + }, + })) + .input(z.object({ q: z.string().optional() })) + .handler(async () => ([])), +} +``` + +::: info +Metadata resets to its default behavior when set to `undefined` in subsequent calls: + +```ts +const example = os + .meta(openapi({ tags: ['planets'] })) + .meta(openapi({ tags: undefined })) +``` + +In this example, the final `tags` is `undefined`, so no tags are applied to `example`. + +::: + +## OpenAPI Generator + +`OpenAPIGenerator` accepts either a [contract](/docs/contract/router) or a [router](/docs/router) and generates an OpenAPI 3.1 document. + +```ts +import { OpenAPIGenerator } from '@orpc/openapi' + +const generator = new OpenAPIGenerator({ + converters: [new ZodToJsonSchemaConverter()], +}) + +const spec = await generator.generate(router, { + base: { + info: { + title: 'Planet API', + version: '1.0.0', + }, + servers: [ + { url: 'https://example.com/api' }, + ], + }, +}) +``` + +### Custom Serializer + +If your [OpenAPI Handler](/docs/openapi/handler#custom-serializer) uses a custom serializer, configure `OpenAPIGenerator` with the same serializer so the generated document matches the actual formats. For details, see [OpenAPI Serializer](/docs/openapi/serializer). + +```ts +const handler = new OpenAPIGenerator({ + serializer: new OpenAPISerializer({ + handlers: { + // ...custom handlers + }, + }), +}) +``` + +### Filtering Procedures + +Use `filter` to exclude procedures from the generated document: + +```ts +const spec = await generator.generate(router, { + filter: (_procedure, path) => !path.includes('internal'), +}) +``` + +### Hoisting `$defs` + +By default, root-level `$defs` generated by your converters are moved into `components.schemas`. Use `shouldHoistDef` to keep selected definitions inline: + +```ts +const spec = await generator.generate(router, { + shouldHoistDef: defName => !defName.startsWith('_'), +}) +``` + +#### Custom Error Response Schemas + +If your [OpenAPI Handler](/docs/openapi/handler#custom-error-response) uses custom error response formats, configure `OpenAPIGenerator` with the same logic so the generated document matches the actual error response formats. + +```ts +import { COMMON_ERROR_STATUS_MAP } from '@orpc/openapi' + +const spec = await generator.generate(router, { + errorStatusMap: { + ...COMMON_ERROR_STATUS_MAP, + PLANET_GONE: 410, + }, + customErrorResponseBodySchema: (definedErrors, status) => { + if (status === 410) { + return { + type: 'object', + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['code', 'message'], + } + } + + // fallback to default by returning null or undefined + return null + }, +}) +``` + +### Json Schema Converters + +`OpenAPIGenerator` relies on JSON Schema converters to translate your input, output, and error schemas into JSON Schemas. oRPC provides built-in for [Zod](https://zod.dev/), [Valibot](https://valibot.dev/), and [Arktype](https://arktype.io/): + +```ts +import { ZodToJsonSchemaConverter } from '@orpc/zod' +import { ValibotToJsonSchemaConverter } from '@orpc/valibot' +import { ArkTypeToJsonSchemaConverter } from '@orpc/arktype' + +const generator = new OpenAPIGenerator({ + converters: [ + new ZodToJsonSchemaConverter(), + new ValibotToJsonSchemaConverter(), + new ArkTypeToJsonSchemaConverter(), + ], +}) +``` + +::: info +`OpenAPIGenerator` falls back to [Standard Json Schema](https://standardschema.dev/json-schema) conversion when the required converter is missing. +::: + +::: details Building Your Own Converter? + +Building your own converter is straightforward. You can add support for another [Standard Schema](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec) library by implementing the `JsonSchemaConverter` interface: + +```ts +import type { AnySchema } from '@orpc/contract' +import type { + JsonSchema, + JsonSchemaConverter, + JsonSchemaConverterDirection +} from '@orpc/json-schema' +import { toJsonSchema } from '@valibot/to-json-schema' + +class MyCustomConverter implements JsonSchemaConverter { + condition(schema: AnySchema | undefined, _direction: JsonSchemaConverterDirection): boolean { + return schema?.['~standard'].vendor === 'valibot' + } + + convert( + schema: AnySchema | undefined, + direction: JsonSchemaConverterDirection + ): [jsonSchema: JsonSchema, optional: boolean] { + // In most cases, treating the schema as required is acceptable. + return [toJsonSchema(schema as any), false] as any + } +} +``` + +::: + +#### Customizing `ZodToJsonSchemaConverter` + +`ZodToJsonSchemaConverter` wraps [Zod's built-in toJSONSchema](https://zod.dev/json-schema?id=ztojsonschema#ztojsonschema) and adds support for additional types. See the [source code](https://github.com/middleapi/orpc/blob/main/packages/zod/src/converter.ts) for implementation details. + +A common pattern is defining reusable schemas with `id` metadata. The converter places them in `$defs`, which `OpenAPIGenerator` then [hoists](#hoisting-defs) into `components.schemas`. For more on `id` and `$ref` in Zod, see [Zod JSON Schema Registries](https://zod.dev/json-schema?id=registries#registries). + +```ts +import { z } from 'zod' + +const PlanetSchema = z.object({ + id: z.string(), + name: z.string(), +}).meta({ id: 'Planet' }) +``` + +#### Customizing `ValibotToJsonSchemaConverter` + +`ValibotToJsonSchemaConverter` wraps [Valibot's built-in toJsonSchema](https://github.com/open-circle/valibot/blob/main/packages/to-json-schema/README.md). See the [source code](https://github.com/middleapi/orpc/blob/main/packages/valibot/src/converter.ts) for implementation details. + +A common pattern is defining reusable or recursive schemas via definitions. The converter preserves them in `$defs`, which `OpenAPIGenerator` can then [hoist](#hoisting-defs) into `components.schemas`. For more on how definitions work in Valibot, see [Valibot JSON Schema Definitions](https://github.com/open-circle/valibot/blob/main/packages/to-json-schema/README.md#definitions). + +```ts +import * as v from 'valibot' + +const PlanetSchema = v.object({ + id: v.string(), + name: v.string(), +}) + +const generator = new OpenAPIGenerator({ + converters: [ + new ValibotToJsonSchemaConverter({ + definitions: { PlanetSchema }, + }), + ], +}) +``` + +#### Customizing `ArkTypeToJsonSchemaConverter` + +`ArkTypeToJsonSchemaConverter` wraps [ArkType's built-in toJsonSchema](https://arktype.io/docs/type-api#tojsonschema). See the [source code](https://github.com/middleapi/orpc/blob/main/packages/arktype/src/converter.ts) and ArkType's [JSON Schema configuration docs](https://arktype.io/docs/configuration#tojsonschema) for implementation details. + +A common pattern is defining reusable or recursive types using scopes. The converter preserves them in `$defs`, which `OpenAPIGenerator` can then [hoist](#hoisting-defs) into `components.schemas`. + +```ts +import { scope } from 'arktype' + +const types = scope({ + Planet: { + name: 'string', + neighbors: 'Planet[]', + }, +}) + +const PlanetSchema = types.export().Planet +``` diff --git a/apps/content/docs/playgrounds.md b/apps/content/docs/playgrounds.md index ed2dc971e..17861a3a0 100644 --- a/apps/content/docs/playgrounds.md +++ b/apps/content/docs/playgrounds.md @@ -1,8 +1,3 @@ ---- -title: Playgrounds -description: Interactive development environments for exploring and testing oRPC functionality. ---- - # Playgrounds Explore oRPC implementations through our interactive playgrounds, @@ -10,51 +5,23 @@ featuring pre-configured examples accessible instantly via StackBlitz or local s ## Available Playgrounds -| Environment | StackBlitz | GitHub Source | -| -------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| Next.js Playground | [Open in StackBlitz](https://stackblitz.com/github/middleapi/orpc/tree/main/playgrounds/next) | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/next) | -| TanStack Start Playground | [Open in StackBlitz](https://stackblitz.com/github/middleapi/orpc/tree/main/playgrounds/tanstack-start) | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/tanstack-start) | -| Nuxt.js Playground | [Open in StackBlitz](https://stackblitz.com/github/middleapi/orpc/tree/main/playgrounds/nuxt) | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/nuxt) | -| Solid Start Playground | [Open in StackBlitz](https://stackblitz.com/github/middleapi/orpc/tree/main/playgrounds/solid-start) | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/solid-start) | -| Svelte Kit Playground | [Open in StackBlitz](https://stackblitz.com/github/middleapi/orpc/tree/main/playgrounds/svelte-kit) | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/svelte-kit) | -| Astro Playground | [Open in StackBlitz](https://stackblitz.com/github/middleapi/orpc/tree/main/playgrounds/astro) | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/astro) | -| Contract-First Playground | [Open in StackBlitz](https://stackblitz.com/github/middleapi/orpc/tree/main/playgrounds/contract-first) | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/contract-first) | -| NestJS Playground | [Open in StackBlitz](https://stackblitz.com/github/middleapi/orpc/tree/main/playgrounds/nest) | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/nest) | -| Cloudflare Worker | [Open in StackBlitz](https://stackblitz.com/github/middleapi/orpc/tree/main/playgrounds/cloudflare-worker) | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/cloudflare-worker) | -| Bun WebSocket + OpenTelemetry | | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/bun-websocket-otel) | -| Electron Playground | | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/electron) | -| Browser Extension Playground | | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/browser-extension) | -| Multiservice Monorepo Playground | | [View Source](https://github.com/middleapi/orpc-multiservice-monorepo-playground) | -| Vue + Bun + Monorepo (Community) | | [View Source](https://github.com/hunterwilhelm/orpc-community-playgrounds/tree/main/vue-bun) | +| Environment | StackBlitz | GitHub Source | +| ------------------ | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| Next.js Playground | [Open in StackBlitz](https://stackblitz.com/github/middleapi/orpc/tree/main/playgrounds/next) | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/next) | :::warning -StackBlitz has own limitations, so some features may not work as expected. +StackBlitz has its own limitations, so some features may not work as expected. ::: ## Local Development -If you prefer working locally, you can clone any playground using the following commands: +Prefer working locally? Clone the playground with: ```bash npx degit middleapi/orpc/playgrounds/next orpc-next-playground -npx degit middleapi/orpc/playgrounds/tanstack-start orpc-tanstack-start-playground -npx degit middleapi/orpc/playgrounds/nuxt orpc-nuxt-playground -npx degit middleapi/orpc/playgrounds/solid-start orpc-solid-start-playground -npx degit middleapi/orpc/playgrounds/svelte-kit orpc-svelte-kit-playground -npx degit middleapi/orpc/playgrounds/astro orpc-astro-playground -npx degit middleapi/orpc/playgrounds/contract-first orpc-contract-first-playground -npx degit middleapi/orpc/playgrounds/nest orpc-nest-playground -npx degit middleapi/orpc/playgrounds/cloudflare-worker orpc-cloudflare-worker-playground -npx degit middleapi/orpc/playgrounds/bun-websocket-otel orpc-bun-websocket-otel-playground -npx degit middleapi/orpc/playgrounds/electron orpc-electron-playground -npx degit middleapi/orpc/playgrounds/browser-extension orpc-browser-extension-playground -npx degit middleapi/orpc-multiservice-monorepo-playground orpc-multiservice-monorepo-playground - -# Community (clone at your own risk) -npx degit hunterwilhelm/orpc-community-playgrounds/vue-bun orpc-vue-bun-monorepo-playground ``` -For each project, set up the development environment: +Then install dependencies and start the dev server: ```bash # Install dependencies @@ -64,4 +31,15 @@ npm install npm run dev ``` -That's it! You can now access the playground at `http://localhost:3000`. +- Visit `http://localhost:3000` to view the app. +- Visit `http://localhost:3000/api` to explore the OpenAPI client. + +### OpenTelemetry + +Collect OpenTelemetry traces with [Jaeger](https://www.jaegertracing.io/) by running this in a separate terminal: + +```bash +npm run jaeger:run +``` + +Then play with your app and open `http://localhost:16686` to see the traces in the Jaeger dashboard. diff --git a/apps/content/docs/plugins/batch-requests.md b/apps/content/docs/plugins/batch-requests.md deleted file mode 100644 index 9c62ad295..000000000 --- a/apps/content/docs/plugins/batch-requests.md +++ /dev/null @@ -1,203 +0,0 @@ ---- -title: Batch Requests Plugin -description: A plugin for oRPC to batch requests and responses. ---- - -# Batch Requests Plugin - -The **Batch Requests Plugin** allows you to combine multiple requests and responses into a single batch, reducing the overhead of sending each one separately. - -::: info -HTTP/2, HTTP/3, and later versions already support multiplexing, allowing multiple requests and responses over a single connection. Since these protocols are now widely adopted, the batch plugin may be less beneficial in most scenarios. -::: - -## Setup - -This plugin requires configuration on both the server and client sides. - -### Server - -```ts twoslash -import { RPCHandler } from '@orpc/server/fetch' -import { router } from './shared/planet' -// ---cut--- -import { BatchHandlerPlugin } from '@orpc/server/plugins' - -const handler = new RPCHandler(router, { - plugins: [new BatchHandlerPlugin()], -}) -``` - -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler) or custom implementations. Note that this plugin uses its own protocol for batching requests and responses, which is different from the handler's native protocol. -::: - -### Client - -To use the `BatchLinkPlugin`, define at least one group. Requests within the same group will be considered for batching together, and each group requires a `context` as described in [client context](/docs/client/rpc-link#using-client-context). - -```ts twoslash -import { RPCLink } from '@orpc/client/fetch' -// ---cut--- -import { BatchLinkPlugin } from '@orpc/client/plugins' - -const link = new RPCLink({ - url: 'https://api.example.com/rpc', - plugins: [ - new BatchLinkPlugin({ - groups: [ - { - condition: options => true, - context: {} // Context used for the rest of the request lifecycle - } - ] - }), - ], -}) -``` - -::: info -The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom implementations. -::: - -## Batch Mode - -By default, the plugin uses `streaming` mode, which sends responses asynchronously as they arrive. This ensures that no single request blocks others, allowing for faster and more efficient batching. - -If your environment does not support streaming responses, such as some serverless platforms or older browsers you can switch to `buffered` mode. In this mode, all responses are collected before being sent together. - -```ts -const link = new RPCLink({ - url: 'https://api.example.com/rpc', - plugins: [ - new BatchLinkPlugin({ - mode: typeof window === 'undefined' ? 'buffered' : 'streaming', // [!code highlight] - groups: [ - { - condition: options => true, - context: {} - } - ] - }), - ], -}) -``` - -## Limitations - -The plugin does not support [AsyncIteratorObject](/docs/rpc-handler#supported-data-types) or [File/Blob](/docs/rpc-handler#supported-data-types) in responses (requests will auto fall back to the default behavior). To exclude unsupported procedures, use the `exclude` option: - -```ts twoslash -import { RPCLink } from '@orpc/client/fetch' -import { BatchLinkPlugin } from '@orpc/client/plugins' -// ---cut--- -const link = new RPCLink({ - url: 'https://api.example.com/rpc', - plugins: [ - new BatchLinkPlugin({ - groups: [ - { - condition: options => true, - context: {} - } - ], - exclude: ({ path }) => { - return ['planets/getImage', 'planets/subscribe'].includes(path.join('/')) - } - }), - ], -}) -``` - -## Request Headers - -By default, oRPC uses the headers appear in all requests in the batch. To customize headers, use the `headers` option: - -```ts twoslash -import { RPCLink } from '@orpc/client/fetch' -import { BatchLinkPlugin } from '@orpc/client/plugins' -// ---cut--- -const link = new RPCLink({ - url: 'https://api.example.com/rpc', - plugins: [ - new BatchLinkPlugin({ - groups: [ - { - condition: options => true, - context: {} - } - ], - headers: () => ({ - authorization: 'Bearer 1234567890', - }) - }), - ], -}) -``` - -## Response Headers - -By default, the response headers are empty. To customize headers, use the `headers` option: - -```ts twoslash -import { RPCHandler } from '@orpc/server/fetch' -import { router } from './shared/planet' -// ---cut--- -import { BatchHandlerPlugin } from '@orpc/server/plugins' - -const handler = new RPCHandler(router, { - plugins: [new BatchHandlerPlugin({ - headers: responses => ({ - 'some-header': 'some-value', - }) - })], -}) -``` - -## Groups - -Requests within the same group will be considered for batching together, and each group requires a `context` as described in [client context](/docs/client/rpc-link#using-client-context). - -In the example below, I used a group and `context` to batch requests based on the `cache` control: - -```ts twoslash -import { RPCLink } from '@orpc/client/fetch' -import { BatchLinkPlugin } from '@orpc/client/plugins' - -interface ClientContext { - cache?: RequestCache -} - -const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - method: ({ context }) => { - if (context?.cache) { - return 'GET' - } - - return 'POST' - }, - plugins: [ - new BatchLinkPlugin({ - groups: [ - { - condition: ({ context }) => context?.cache === 'force-cache', - context: { // This context will be passed to the fetch method - cache: 'force-cache', - }, - }, - { // Fallback for all other requests - need put it at the end of list - condition: () => true, - context: {}, - }, - ], - }), - ], - fetch: (request, init, { context }) => globalThis.fetch(request, { - ...init, - cache: context?.cache, - }), -}) -``` - -Now, calls with `cache=force-cache` will be sent with `cache=force-cache`, whether they're batched or executed individually. diff --git a/apps/content/docs/plugins/batch.md b/apps/content/docs/plugins/batch.md new file mode 100644 index 000000000..5b453f5e2 --- /dev/null +++ b/apps/content/docs/plugins/batch.md @@ -0,0 +1,146 @@ +# Batch Plugin + +Use the **Batch Plugin** to combine multiple requests into a single batch and receive their responses together. This reduces the overhead of sending each request separately. + +::: warning +HTTP/2, HTTP/3, and later versions already support multiplexing, which allows multiple requests and responses to share a single connection. Because these protocols are now widely adopted, this plugin is often less useful than it once was. +::: + +## Setup + +Set up batching on both the server and the client. The server plugin handles incoming batch requests, and the client plugin groups outgoing requests into batches. + +::: code-group + +```ts [server.ts] +import { BatchHandlerPlugin } from '@orpc/server/plugins' + +const handler = new RPCHandler(router, { + plugins: [ + new BatchHandlerPlugin(), + ], +}) +``` + +```ts [client.ts] +import { BatchLinkPlugin } from '@orpc/client/plugins' + +const link = new RPCLink({ + url: '/rpc', + plugins: [ + new BatchLinkPlugin({ + groups: [ + { + condition: () => true, + context: {}, + }, + ], + }), + ], +}) +``` + +::: + +::: warning +`BatchHandlerPlugin` detects batch requests by checking for the `orpc-batch` header. If you enable CORS, add this header to your allowlist so cross-origin batch requests are not blocked. + +```ts +const cors = new CORSHandlerPlugin({ + allowHeaders: ['orpc-batch'], +}) +``` + +::: + +## Response Modes + +By default, the plugin uses `streaming` mode. Responses are sent as soon as they are ready, so one slow request does not block the rest of the batch. + +If your environment does not support streaming responses, such as some serverless platforms or older browsers, switch to `buffered` mode instead. In this mode, all responses are collected and sent together. + +```ts +const link = new RPCLink({ + url: '/rpc', + plugins: [ + new BatchLinkPlugin({ + mode: 'buffered', + groups: [ + { + condition: () => true, + context: {}, + }, + ], + }), + ], +}) +``` + +## Groups + +Only requests in the same group are batched together. Each group also defines a context, as described in [client context](/docs/rpc/link#client-context). + +The following example batches requests by cache policy: + +```ts +interface ClientContext { + cache?: RequestCache +} + +const link = new RPCLink({ + method: ({ context }) => { + if (context?.cache) { + return 'GET' + } + + return 'POST' + }, + plugins: [ + new BatchLinkPlugin({ + groups: [ + { + condition: ({ context }) => context?.cache === 'force-cache', + context: { // used for the rest of the request lifecycle + cache: 'force-cache', + }, + }, + { // Fallback for all other requests. Keep this last. + condition: () => true, + context: {}, + }, + ], + }), + ], + fetch: (url, init, { context }) => globalThis.fetch(url, { + ...init, + cache: context?.cache, + }), +}) +``` + +Now, calls made with `cache = 'force-cache'` use that cache setting whether they are batched or sent individually. + +## Filtering Requests + +Use `filter` to skip batching for specific requests before group matching runs. Requests for which `filter` returns `false` continue through the link chain individually. + +```ts +const link = new RPCLink({ + url: '/rpc', + plugins: [ + new BatchLinkPlugin({ + filter: ({ path }) => !path.includes('upload'), + groups: [ + { + condition: () => true, + context: {}, + }, + ], + }), + ], +}) +``` + +## Learn More + +See the [BatchHandlerPlugin source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/plugins/batch-handler-plugin.ts) and the [BatchLinkPlugin source code](https://github.com/middleapi/orpc/blob/main/packages/client/src/plugins/batch-link-plugin.ts) for implementation details. diff --git a/apps/content/docs/plugins/body-compression.md b/apps/content/docs/plugins/body-compression.md new file mode 100644 index 000000000..487fd5fd0 --- /dev/null +++ b/apps/content/docs/plugins/body-compression.md @@ -0,0 +1,30 @@ +# Body Compression Plugin + +**Body Compression Plugin** compresses response bodies to reduce bandwidth usage and improve performance for clients that support compression. + +## Import + +Depending on your adapter, import the corresponding plugin: + +```ts +import { BodyCompressionHandlerPlugin } from '@orpc/server/node' +import { BodyCompressionHandlerPlugin } from '@orpc/server/fetch' +``` + +## Setup + +Add the plugin to your handler: + +```ts +const handler = new RPCHandler(router, { + plugins: [ + new BodyCompressionHandlerPlugin(), + ], +}) +``` + + + +## Learn More + +For implementation details, see the [fetch adapter source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/adapters/fetch/body-compression-plugin.ts) and the [node adapter source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/adapters/node/body-compression-plugin.ts). diff --git a/apps/content/docs/plugins/body-limit.md b/apps/content/docs/plugins/body-limit.md index d64207d1e..2cd146b20 100644 --- a/apps/content/docs/plugins/body-limit.md +++ b/apps/content/docs/plugins/body-limit.md @@ -1,35 +1,32 @@ ---- -title: Body Limit Plugin -description: A plugin for oRPC to limit the request body size. ---- - # Body Limit Plugin -The **Body Limit Plugin** restricts the size of the request body. +**Body Limit Plugin** helps restrict the size of the request body. ## Import Depending on your adapter, import the corresponding plugin: ```ts -import { BodyLimitPlugin } from '@orpc/server/fetch' -import { BodyLimitPlugin } from '@orpc/server/node' +import { BodyLimitHandlerPlugin } from '@orpc/server/fetch' +import { BodyLimitHandlerPlugin } from '@orpc/server/node' ``` ## Setup -Configure the plugin with your desired maximum body size: +Set `maxBodySize` to the maximum number of bytes allowed: ```ts const handler = new RPCHandler(router, { plugins: [ - new BodyLimitPlugin({ + new BodyLimitHandlerPlugin({ maxBodySize: 1024 * 1024, // 1MB }), ], }) ``` -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: + + +## Learn More + +For implementation details, see the [fetch adapter source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/adapters/fetch/body-limit-plugin.ts) and the [node adapter source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/adapters/node/body-limit-plugin.ts). diff --git a/apps/content/docs/plugins/client-retry.md b/apps/content/docs/plugins/client-retry.md deleted file mode 100644 index d846251b9..000000000 --- a/apps/content/docs/plugins/client-retry.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -title: Client Retry Plugin -description: A plugin for oRPC that enables retrying client calls when errors occur. ---- - -# Client Retry Plugin - -The `Client Retry Plugin` enables retrying client calls when errors occur. - -## Setup - -Before you begin, please review the [Client Context](/docs/client/rpc-link#using-client-context) documentation. - -```ts twoslash -import { router } from './shared/planet' -import { RouterClient } from '@orpc/server' -import { createORPCClient } from '@orpc/client' -// ---cut--- -import { RPCLink } from '@orpc/client/fetch' -import { ClientRetryPlugin, ClientRetryPluginContext } from '@orpc/client/plugins' - -interface ORPCClientContext extends ClientRetryPluginContext {} - -const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - plugins: [ - new ClientRetryPlugin({ - default: { // Optional override for default options - retry: ({ path }) => { - if (path.join('.') === 'planet.list') { - return 2 - } - - return 0 - } - }, - }), - ], -}) - -const client: RouterClient = createORPCClient(link) -``` - -::: info -The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom implementations. -::: - -## Usage - -```ts twoslash -import { router } from './shared/planet' -import { ClientRetryPluginContext } from '@orpc/client/plugins' -import { RouterClient } from '@orpc/server' - -declare const client: RouterClient -// ---cut--- -const planets = await client.planet.list({ limit: 10 }, { - context: { - retry: 3, // Maximum retry attempts - retryDelay: 2000, // Delay between retries in ms - shouldRetry: options => true, // Determines whether to retry based on the error - onRetry: (options) => { - // Hook executed on each retry - - return (isSuccess) => { - // Execute after the retry is complete - } - }, - } -}) -``` - -::: info -By default, retries are disabled unless a `retry` count is explicitly set. - -- **retry:** Maximum retry attempts before throwing an error (default: `0`). -- **retryDelay:** Delay between retries (default: `(o) => o.lastEventRetry ?? 2000`). -- **shouldRetry:** Function that determines whether to retry (default: `true`). - ::: - -## Event Iterator (SSE) - -To replicate the behavior of [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) for [Event Iterator](/docs/event-iterator), use the following configuration: - -```ts -const streaming = await client.streaming('the input', { - context: { - retry: Number.POSITIVE_INFINITY, - } -}) - -for await (const message of streaming) { - console.log(message) -} -``` diff --git a/apps/content/docs/plugins/compression.md b/apps/content/docs/plugins/compression.md deleted file mode 100644 index 7ae9a25d2..000000000 --- a/apps/content/docs/plugins/compression.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: Compression Plugin -description: A plugin for oRPC that compresses response bodies. ---- - -# Compression Plugin - -The **Compression Plugin** compresses response bodies to reduce bandwidth usage and improve performance. - -## Import - -Depending on your adapter, import the corresponding plugin: - -```ts -import { CompressionPlugin } from '@orpc/server/node' -import { CompressionPlugin } from '@orpc/server/fetch' -``` - -## Setup - -Add the plugin to your handler configuration: - -```ts -const handler = new RPCHandler(router, { - plugins: [ - new CompressionPlugin(), - ], -}) -``` - -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: diff --git a/apps/content/docs/plugins/cors.md b/apps/content/docs/plugins/cors.md index 1aac2ed9d..e083ca8bb 100644 --- a/apps/content/docs/plugins/cors.md +++ b/apps/content/docs/plugins/cors.md @@ -1,20 +1,18 @@ ---- -title: CORS Plugin -description: CORS Plugin for oRPC ---- +# CORS Handler Plugin -# CORS Plugin - -`CORSPlugin` is a plugin for oRPC that allows you to configure CORS for your API. +Use `CORSHandlerPlugin` to configure [CORS Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) for your API. ## Basic -```ts -import { CORSPlugin } from '@orpc/server/plugins' +```ts twoslash +import { RPCHandler } from '@orpc/server/fetch' +import { router } from './shared/planet' +// ---cut--- +import { CORSHandlerPlugin } from '@orpc/server/plugins' const handler = new RPCHandler(router, { plugins: [ - new CORSPlugin({ + new CORSHandlerPlugin({ origin: (origin, options) => origin, allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH'], // ... @@ -23,6 +21,10 @@ const handler = new RPCHandler(router, { }) ``` -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: + + + + +## Learn More + +For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/plugins/cors.ts). diff --git a/apps/content/docs/plugins/csrf-guard.md b/apps/content/docs/plugins/csrf-guard.md new file mode 100644 index 000000000..918082804 --- /dev/null +++ b/apps/content/docs/plugins/csrf-guard.md @@ -0,0 +1,39 @@ +# CSRF Guard Plugin + +Use `CSRFGuardHandlerPlugin` to protect against [Cross-Site Request Forgery (CSRF) attacks](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF) by rejecting requests with unsafe fetch modes. + +## How It Works + +The plugin inspects the [Sec-Fetch-Mode header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode) and blocks requests with a mode of `navigate`, `no-cors`, or `websocket`, which may be triggered by cross-site links, forms, or other passive browser features. + +## Setup + +```ts +import { OpenAPIHandler } from '@orpc/openapi/fetch' +import { CSRFGuardHandlerPlugin } from '@orpc/server/plugins' + +const handler = new OpenAPIHandler(router, { + plugins: [ + new CSRFGuardHandlerPlugin(), + ], +}) +``` + +::: info +HTTP-based `RPCHandler` implementations enable this plugin by default. Disable it with `csrfGuardHandlerPlugin.enabled`. + +```ts +const handler = new RPCHandler(router, { + csrfGuardHandlerPlugin: { + enabled: false, + }, +}) +``` + +::: + + + +## Learn More + +For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/plugins/csrf-guard.ts). diff --git a/apps/content/docs/plugins/dedupe-requests.md b/apps/content/docs/plugins/dedupe-requests.md deleted file mode 100644 index edf5479f0..000000000 --- a/apps/content/docs/plugins/dedupe-requests.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: Dedupe Requests Plugin -description: Prevents duplicate requests by deduplicating similar ones to reduce server load. ---- - -# Dedupe Requests Plugin - -The **Dedupe Requests Plugin** prevents redundant requests by deduplicating similar ones, helping to reduce the number of requests sent to the server. - -## Usage - -```ts -import { DedupeRequestsPlugin } from '@orpc/client/plugins' - -const link = new RPCLink({ - plugins: [ - new DedupeRequestsPlugin({ - filter: ({ request }) => request.method === 'GET', // Filters requests to dedupe - groups: [ - { - condition: () => true, - context: {}, // Context used for the rest of the request lifecycle - }, - ], - }), - ], -}) -``` - -::: info -The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom implementations. -::: - -::: tip -By default, only `GET` requests are deduplicated. - -If your application does not rely on running multiple mutation requests in parallel (in the same [call stack](https://developer.mozilla.org/en-US/docs/Glossary/Call_stack)), you can expand the filter to deduplicate **all** request types. -This also helps prevent issues caused by users clicking actions too quickly and unintentionally sending duplicate mutation requests. -::: - -## Groups - -To enable deduplication, a request must match at least one defined group. Requests that fall into the same group are considered for deduplication together. Each group also requires a `context`, which will be used during the remainder of the request lifecycle. Learn more about [client context](/docs/client/rpc-link#using-client-context). - -Here's an example that deduplicates requests based on the `cache` control: - -```ts -interface ClientContext { - cache?: RequestCache -} - -const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - method: ({ context }) => { - if (context?.cache) { - return 'GET' - } - - return 'POST' - }, - plugins: [ - new DedupeRequestsPlugin({ - filter: ({ request }) => request.method === 'GET', // Filters requests to dedupe - groups: [ - { - condition: ({ context }) => context?.cache === 'force-cache', - context: { - cache: 'force-cache', - }, - }, - { - // Fallback group – placed last to catch remaining requests - condition: () => true, - context: {}, - }, - ], - }), - ], - fetch: (request, init, { context }) => globalThis.fetch(request, { - ...init, - cache: context?.cache, - }), -}) -``` - -Now, calls with `cache=force-cache` will be sent with `cache=force-cache`, whether they're deduplicated or executed individually. diff --git a/apps/content/docs/plugins/dedupe.md b/apps/content/docs/plugins/dedupe.md new file mode 100644 index 000000000..e7a6ea658 --- /dev/null +++ b/apps/content/docs/plugins/dedupe.md @@ -0,0 +1,101 @@ +# Dedupe Plugin + +**Dedupe Plugin** prevents redundant requests by deduplicating similar requests, reducing the number of requests sent to the server. + +## Overview + +```ts +import { DedupeLinkPlugin } from '@orpc/client/plugins' + +const link = new RPCLink({ + plugins: [ + new DedupeLinkPlugin({ + groups: [ + { + condition: () => true, + context: {}, // Context used for the rest of the request lifecycle + }, + ], + }), + ], +}) +``` + + + +## Filter + +By default, the plugin deduplicates only `GET` requests. You can customize this behavior by providing a `filter` function. + +```ts +const link = new RPCLink({ + plugins: [ + new DedupeLinkPlugin({ + filter: ({ request }) => request.method === 'GET', + groups: [ + { + condition: () => true, + context: {}, + }, + ], + }), + ], +}) +``` + +::: warning +If you are using [RPC Link](/docs/rpc/link), you might need to [customize the request method](/docs/rpc/link#request-method) because it defaults to `POST`. +::: + +::: tip +If your application does not need to run multiple mutation requests in parallel within the same [call stack](https://developer.mozilla.org/en-US/docs/Glossary/Call_stack), you can expand the filter to deduplicate **all** request types. +This can also help prevent duplicate mutation requests when users click actions too quickly. +::: + +## Groups + +Only requests in the same group are deduplicated together. Each group also defines a `context`, as described in [client context](/docs/client/client-side#client-context). + +The following example deduplicates requests by cache policy: + +```ts +interface ClientContext { + cache?: RequestCache +} + +const link = new RPCLink({ + method: ({ context }) => { + if (context?.cache) { + return 'GET' + } + + return 'POST' + }, + plugins: [ + new DedupeLinkPlugin({ + groups: [ + { + condition: ({ context }) => context?.cache === 'force-cache', + context: { // used for the rest of the request lifecycle + cache: 'force-cache', + }, + }, + { // Fallback for all other requests. Keep this last. + condition: () => true, + context: {}, + }, + ], + }), + ], + fetch: (url, init, { context }) => globalThis.fetch(url, { + ...init, + cache: context?.cache, + }), +}) +``` + +Now, calls made with `cache = 'force-cache'` use that cache setting whether they are deduplicated or sent individually. + +## Learn More + +For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/client/src/plugins/dedupe.ts). diff --git a/apps/content/docs/plugins/hibernation.md b/apps/content/docs/plugins/hibernation.md deleted file mode 100644 index a0ca9a78e..000000000 --- a/apps/content/docs/plugins/hibernation.md +++ /dev/null @@ -1,215 +0,0 @@ ---- -title: Hibernation Plugin -description: A plugin to fully leverage Hibernation APIs for your ORPC server. ---- - -# Hibernation Plugin - -The Hibernation Plugin helps you fully leverage Hibernation APIs, making it especially useful for adapters like [Cloudflare Websocket Hibernation](https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server/). - -## Setup - -```ts -import { HibernationPlugin } from '@orpc/server/hibernation' - -const handler = new RPCHandler(router, { - plugins: [ - new HibernationPlugin(), - ], -}) -``` - -## Event Iterator - -The plugin provide `HibernationEventIterator` and `encodeHibernationRPCEvent` to help you return an [Event Iterator](/docs/event-iterator) that utilizes the Hibernation APIs. - -1. Return an `HibernationEventIterator` from your handler - - ```ts - import { HibernationEventIterator } from '@orpc/server/hibernation' - - export const onMessage = os.handler(async ({ context }) => { - return new HibernationEventIterator<{ message: string }>((id) => { - // Save the ID. You'll need it to send events later. - context.ws.serializeAttachment({ id }) - }) - }) - ``` - -2. Send events to clients with `encodeHibernationRPCEvent` - - ```ts - import { encodeHibernationRPCEvent } from '@orpc/server/hibernation' - - export const sendMessage = os.handler(async ({ input, context }) => { - const websockets = context.getWebSockets() - - for (const ws of websockets) { - const { id } = ws.deserializeAttachment() - - // yield an event to all clients - ws.send(encodeHibernationRPCEvent(id, { message: input.message }, { - customJsonSerializers: [ - // put custom serializers here - ] - })) - // return an event and stop event iterator - ws.send(encodeHibernationRPCEvent(id, { message: input.message }, { event: 'done' })) - // throw an error and stop event iterator - ws.send(encodeHibernationRPCEvent(id, new ORPCError('INTERNAL_SERVER_ERROR'), { event: 'error' })) - } - }) - ``` - -::: details Cloudflare Durable Object Chat Room Example? - -This example demonstrates how to set up a chat room using [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/) and [Websocket Hibernation](https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server/). Everyone connected to the same Durable Object can send messages to each other. - -::: code-group - -```ts [Durable Object] -import { RPCHandler } from '@orpc/server/websocket' -import { - encodeHibernationRPCEvent, - HibernationEventIterator, - HibernationPlugin, -} from '@orpc/server/hibernation' -import { onError, os } from '@orpc/server' -import { DurableObject } from 'cloudflare:workers' -import * as z from 'zod' - -const base = os.$context<{ - handler: RPCHandler - ws: WebSocket - getWebsockets: () => WebSocket[] -}>() - -export const router = { - send: base.input(z.object({ message: z.string() })).handler(async ({ input, context }) => { - const websockets = context.getWebsockets() - - for (const ws of websockets) { - const data = ws.deserializeAttachment() - if (typeof data !== 'object' || data === null) { - continue - } - - const { id } = data - - ws.send(encodeHibernationRPCEvent(id, input.message)) - } - }), - onMessage: base.handler(async ({ context }) => { - return new HibernationEventIterator((id) => { - context.ws.serializeAttachment({ id }) - }) - }), -} - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], - plugins: [ - new HibernationPlugin(), - ], -}) - -export class ChatRoom extends DurableObject { - async fetch(): Promise { - const { '0': client, '1': server } = new WebSocketPair() - - this.ctx.acceptWebSocket(server) - - return new Response(null, { - status: 101, - webSocket: client, - }) - } - - async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { - await handler.message(ws, message, { - context: { - handler, - ws, - getWebsockets: () => this.ctx.getWebSockets(), - }, - }) - } - - async webSocketClose(ws: WebSocket): Promise { - handler.close(ws) - } -} -``` - -```ts [Client] -import { RPCLink } from '@orpc/client/websocket' -import { createORPCClient } from '@orpc/client' -import type { router } from '../../worker/dos/chat-room' -import type { RouterClient } from '@orpc/server' - -const websocket = new WebSocket(`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/chat-room`) - -websocket.addEventListener('error', (event) => { - console.error(event) -}) - -const link = new RPCLink({ - websocket, -}) - -export const chatRoomClient: RouterClient = createORPCClient(link) -``` - -```tsx [Component] -import { useEffect, useState } from 'react' -import { chatRoomClient } from '../lib/chat-room' - -export function ChatRoom() { - const [messages, setMessages] = useState([]) - - useEffect(() => { - const controller = new AbortController() - - void (async () => { - for await (const message of await chatRoomClient.onMessage(undefined, { signal: controller.signal })) { - setMessages(messages => [...messages, message]) - } - })() - - return () => { - controller.abort() - } - }, []) - - const sendMessage = async (e: React.FormEvent) => { - e.preventDefault() - - const form = new FormData(e.target as HTMLFormElement) - const message = form.get('message') as string - - await chatRoomClient.send({ message }) - } - - return ( -
-

Chat Room

-

Open multiple tabs to chat together

-
    - {messages.map((message, index) => ( -
  • {message}
  • - ))} -
-
- - -
-
- ) -} -``` - -::: diff --git a/apps/content/docs/plugins/openapi-reference.md b/apps/content/docs/plugins/openapi-reference.md new file mode 100644 index 000000000..764cf661e --- /dev/null +++ b/apps/content/docs/plugins/openapi-reference.md @@ -0,0 +1,67 @@ +# OpenAPI Reference Plugin (Swagger/Scalar) + +This plugin serves API reference documentation powered by [Scalar](https://github.com/scalar/scalar) or [Swagger UI](https://swagger.io/tools/swagger-ui/), and exposes the OpenAPI specification as JSON. + +::: info +This plugin depends on the [OpenAPI Generator](/docs/openapi/specification). Review that guide before setting up the reference plugin. +::: + +## Setup + +To use this plugin, first create an [OpenAPI Generator](/docs/openapi/specification). The plugin uses it to generate the OpenAPI specification. + +```ts +import { OpenAPIGenerator } from '@orpc/openapi' +import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' + +const generator = new OpenAPIGenerator({ + converters: [ + new ZodToJsonSchemaConverter(), + ], +}) + +const handler = new OpenAPIHandler(router, { + plugins: [ + new OpenAPIReferencePlugin({ + spec: () => generator.generateSpec(router, { + info: { + title: 'ORPC Playground', + version: '1.0.0', + }, + servers: [ + { url: 'https://api.example.com/v1', }, + ], + }), + }), + ] +}) +``` + +::: info +By default, the API reference UI is served from `/`, and the OpenAPI specification is served from `/spec.json`. Use `docsPath` and `specPath` to change these routes. +::: + +## Provider + +[Scalar](https://github.com/scalar/scalar) is the default provider. To use [Swagger UI](https://swagger.io/tools/swagger-ui/) instead, set `provider` to `'swagger'`. Use `providerConfig` to pass provider-specific options. + +```ts +const handler = new OpenAPIHandler(router, { + plugins: [ + new OpenAPIReferencePlugin({ + provider: 'swagger', + providerConfig: { + // Swagger UI specific configuration + }, + }), + ] +}) +``` + +::: info +You can also load custom assets for the docs UI by setting `providerScriptUrl` and `providerCssUrl`. +::: + +## Learn More + +For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/openapi/src/plugins/openapi-reference.ts). diff --git a/apps/content/docs/plugins/request-headers.md b/apps/content/docs/plugins/request-headers.md index 138f69be8..bc1790f5b 100644 --- a/apps/content/docs/plugins/request-headers.md +++ b/apps/content/docs/plugins/request-headers.md @@ -1,28 +1,18 @@ ---- -title: Request Headers Plugin -description: Request Headers Plugin for oRPC ---- - # Request Headers Plugin -The Request Headers Plugin allows you to access request headers in oRPC. It injects a `reqHeaders` instance into the `context`, enabling you to read incoming request headers easily. - -::: info -**What's the difference vs passing request headers directly into the context?** -There's no functional difference, but this plugin provides a consistent interface for accessing headers across different handlers. -::: +Use `RequestHeadersHandlerPlugin` to expose incoming request headers as `context.reqHeaders`. -## Context Setup +## Context Access ```ts twoslash import { os } from '@orpc/server' // ---cut--- import { getCookie } from '@orpc/server/helpers' -import { RequestHeadersPluginContext } from '@orpc/server/plugins' +import type { RequestHeadersHandlerPluginContext } from '@orpc/server/plugins' -interface ORPCContext extends RequestHeadersPluginContext {} +interface ServerContext extends RequestHeadersHandlerPluginContext {} -const base = os.$context() +const base = os.$context() const example = base .use(({ context, next }) => { @@ -35,9 +25,8 @@ const example = base }) ``` -::: info -**Why can `reqHeaders` be `undefined`?** -This allows procedures to run safely even when `RequestHeadersPlugin` is not used, such as in direct calls. +::: info Why can `reqHeaders` be undefined? +This allows procedures to run safely even without `RequestHeadersHandlerPlugin`, such as in direct calls. ::: ::: tip @@ -47,15 +36,17 @@ Combine with [Cookie Helpers](/docs/helpers/cookie) for streamlined cookie manag ## Handler Setup ```ts -import { RequestHeadersPlugin } from '@orpc/server/plugins' +import { RequestHeadersHandlerPlugin } from '@orpc/server/plugins' const handler = new RPCHandler(router, { plugins: [ - new RequestHeadersPlugin() + new RequestHeadersHandlerPlugin(), ], }) ``` -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: + + +## Learn More + +For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/plugins/request-headers.ts). diff --git a/apps/content/docs/plugins/request-validation.md b/apps/content/docs/plugins/request-validation.md index 5a7f696d3..3dc7d59c0 100644 --- a/apps/content/docs/plugins/request-validation.md +++ b/apps/content/docs/plugins/request-validation.md @@ -1,46 +1,78 @@ ---- -title: Request Validation Plugin -description: A plugin that blocks invalid requests before they reach your server. Especially useful for applications that rely heavily on server-side validation. ---- - # Request Validation Plugin -The **Request Validation Plugin** ensures that only valid requests are sent to your server. This is especially valuable for applications that depend on server-side validation. - -::: info -This plugin is best suited for [Contract-First Development](/docs/contract-first/define-contract). [Minified Contract](/docs/contract-first/router-to-contract#minify-export-the-contract-router-for-the-client) is **not supported** because it removes the schema from the contract. -::: +**Request Validation Plugin** validates requests against your contract before they are sent to the server. This is useful when your application relies on server-side validation. ## Setup -```ts twoslash -import { contract } from './shared/planet' -import { createORPCClient } from '@orpc/client' -import type { ContractRouterClient } from '@orpc/contract' -// ---cut--- -import { RPCLink } from '@orpc/client/fetch' -import { RequestValidationPlugin } from '@orpc/contract/plugins' +```ts +import { RequestValidationLinkPlugin } from '@orpc/contract/plugins' const link = new RPCLink({ - url: 'http://localhost:3000/rpc', plugins: [ - new RequestValidationPlugin(contract), + new RequestValidationLinkPlugin(contract), ], }) - -const client: ContractRouterClient = createORPCClient(link) ``` ::: info -The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom implementations. +If you do not have a [contract](/docs/contract/router), you can use a [unlazied router](/docs/contract/router#router-to-contract) instead. ::: + + +## Forward Validated Input + +By default, the plugin does not reuse validated input for the rest of the request. Some schemas transform input in ways that can cause server-side validation to fail. If your schemas do not do that, set `forwardValidatedInput` to `true`. + +```ts +const link = new RPCLink({ + plugins: [ + new RequestValidationLinkPlugin(contract, { + forwardValidatedInput: true, + }), + ], +}) +``` + +## Custom Validation Errors + +If you have already [customized validation errors on the server](/docs/advanced/validation-errors), you can use interceptors to catch and map the validation errors thrown by this plugin so they match your server-side errors. + +```ts +import { ORPCError } from '@orpc/client' +import { ValidationError } from '@orpc/contract' + +const link = new RPCLink({ + plugins: [ + new RequestValidationLinkPlugin(contract), + ], + interceptors: [ + async ({ next }) => { + try { + return await next() + } + catch (error) { + if ( + error instanceof ORPCError + && error.code === 'BAD_REQUEST' + && error.cause instanceof ValidationError + ) { + throw new CustomInputValidationError(error.cause.issues) + } + + throw error + } + } + ] +}) +``` + ## Form Validation -You can simplify your frontend by removing heavy form validation libraries and relying on oRPC's validation errors instead, since input validation runs directly in the browser and is highly performant. +You can pair this plugin with [Form Data Helpers](/docs/helpers/form-data) to avoid heavier form validation libraries and keep your contract as the single source of truth on both the client and server. ```tsx -import { getIssueMessage, parseFormData } from '@orpc/openapi-client/helpers' +import { getIssueMessage, parseFormData } from '@orpc/openapi/helpers' export function ContactForm() { const [error, setError] = useState() @@ -69,6 +101,6 @@ export function ContactForm() { } ``` -::: info -This example uses [Form Data Helpers](/docs/helpers/form-data). -::: +## Learn More + +For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/contract/src/plugins/request-validation.ts). diff --git a/apps/content/docs/plugins/response-headers.md b/apps/content/docs/plugins/response-headers.md index e6d4c9d83..6e895e27a 100644 --- a/apps/content/docs/plugins/response-headers.md +++ b/apps/content/docs/plugins/response-headers.md @@ -1,27 +1,22 @@ ---- -title: Response Headers Plugin -description: Response Headers Plugin for oRPC ---- - # Response Headers Plugin -The Response Headers Plugin allows you to set response headers in oRPC. It injects a `resHeaders` instance into the `context`, enabling you to modify response headers easily. +Use `ResponseHeadersHandlerPlugin` to accumulate response headers in `context.resHeaders` and merge them into the final response. -## Context Setup +## Context Access ```ts twoslash import { os } from '@orpc/server' -// ---cut--- import { setCookie } from '@orpc/server/helpers' -import { ResponseHeadersPluginContext } from '@orpc/server/plugins' +// ---cut--- +import type { ResponseHeadersHandlerPluginContext } from '@orpc/server/plugins' -interface ORPCContext extends ResponseHeadersPluginContext {} +interface ServerContext extends ResponseHeadersHandlerPluginContext {} -const base = os.$context() +const base = os.$context() -const example = base +const procedure = base .use(({ context, next }) => { - context.resHeaders?.set('x-custom-header', 'value') + context.resHeaders?.set('x-request-id', 'req_123') return next() }) .handler(({ context }) => { @@ -32,9 +27,8 @@ const example = base }) ``` -::: info -**Why can `resHeaders` be `undefined`?** -This allows procedures to run safely even when `ResponseHeadersPlugin` is not used, such as in direct calls. +::: info Why can `resHeaders` be undefined? +This allows procedures to run safely even without `ResponseHeadersHandlerPlugin`, such as in direct calls. ::: ::: tip @@ -44,15 +38,17 @@ Combine with [Cookie Helpers](/docs/helpers/cookie) for streamlined cookie manag ## Handler Setup ```ts -import { ResponseHeadersPlugin } from '@orpc/server/plugins' +import { ResponseHeadersHandlerPlugin } from '@orpc/server/plugins' const handler = new RPCHandler(router, { plugins: [ - new ResponseHeadersPlugin() + new ResponseHeadersHandlerPlugin(), ], }) ``` -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: + + +## Learn More + +For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/plugins/response-headers.ts). diff --git a/apps/content/docs/plugins/response-validation.md b/apps/content/docs/plugins/response-validation.md index 61d33b560..aa0712b46 100644 --- a/apps/content/docs/plugins/response-validation.md +++ b/apps/content/docs/plugins/response-validation.md @@ -1,52 +1,78 @@ ---- -title: Response Validation Plugin -description: A plugin that validates server responses against the contract schema to ensure that the data returned from your server matches the expected types defined in your contract. ---- - # Response Validation Plugin -The **Response Validation Plugin** validates server responses against your contract schema, ensuring that data returned from your server matches the expected types defined in your contract. - -::: info -This plugin is best suited for [Contract-First Development](/docs/contract-first/define-contract). [Minified Contract](/docs/contract-first/router-to-contract#minify-export-the-contract-router-for-the-client) is **not supported** because it removes the schema from the contract. -::: +**Response Validation Plugin** validates server responses against your contract before your application uses them. This helps ensure the data returned by the server matches the types defined in your contract. ## Setup -```ts twoslash -import { contract } from './shared/planet' -import { createORPCClient } from '@orpc/client' -import type { ContractRouterClient } from '@orpc/contract' -// ---cut--- -import { RPCLink } from '@orpc/client/fetch' -import { ResponseValidationPlugin } from '@orpc/contract/plugins' +```ts +import { ResponseValidationLinkPlugin } from '@orpc/contract/plugins' const link = new RPCLink({ - url: 'http://localhost:3000/rpc', plugins: [ - new ResponseValidationPlugin(contract), + new ResponseValidationLinkPlugin(contract), ], }) - -const client: ContractRouterClient = createORPCClient(link) ``` ::: info -The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom implementations. +If you do not have a [contract](/docs/contract/router), you can use a [unlazied router](/docs/contract/router#router-to-contract) instead. ::: + + ## Limitations -Schemas that transform data into different types than the expected schema types are not supported. +Schemas that transform values into a different type are not supported. -**Why?** Consider this example schema that accepts a `number` and transforms it into a `string` after validation: +**Why?** Consider this schema, which accepts a `number` and transforms it into a `string`: ```ts const unsupported = z.number().transform(value => value.toString()) ``` -When the server validates output, it transforms the `number` into a `string`. The client receives a `string`, but the `string` no longer matches the original schema, causing validation to fail. +When the server validates the output, it transforms the `number` into a `string`. The client then receives that `string`, but the schema still expects a `number` as input, so validation fails. + +## Typesafe Errors Compatibility + +This plugin reconciles ORPC errors from other interceptors and plugins, allowing you to use `ORPCError` for [typesafe errors](/docs/error-handling#orpcerror-compatibility). + +## Custom Validation Errors + +If you have already [customized validation errors on the server](/docs/advanced/validation-errors), you can use interceptors to catch and map the validation errors thrown by this plugin so they match your server-side errors. + +```ts +import { ORPCError } from '@orpc/client' +import { ValidationError } from '@orpc/contract' + +const link = new RPCLink({ + plugins: [ + new ResponseValidationLinkPlugin(contract), + ], + interceptors: [ + async ({ next }) => { + try { + return await next() + } + catch (error) { + if ( + error instanceof ORPCError + && error.code === 'INTERNAL_SERVER_ERROR' + && error.cause instanceof ValidationError + ) { + throw new CustomOutputValidationError(error.cause.issues) + } + + throw error + } + } + ] +}) +``` ## Advanced Usage -Beyond response validation, this plugin also serves special purposes such as [Expanding Type Support for OpenAPI Link](/docs/openapi/advanced/expanding-type-support-for-openapi-link). +You can also use this plugin in guides such as [Expanding Type Support for OpenAPI Link](/docs/advanced/expanding-type-support-for-openapi-link). + +## Learn More + +For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/contract/src/plugins/response-validation.ts). diff --git a/apps/content/docs/plugins/rethrow-handler.md b/apps/content/docs/plugins/rethrow-handler.md deleted file mode 100644 index 6e0d796f3..000000000 --- a/apps/content/docs/plugins/rethrow-handler.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: Rethrow Handler Plugin -description: A plugin to catch and rethrow specific errors during request handling instead of handling them in the oRPC error flow. ---- - -# Rethrow Handler Plugin - -The `RethrowHandlerPlugin` allows you to catch and rethrow specific errors that occur during request handling. This is particularly useful when your framework has its own error handling mechanism (e.g., global exception filters in NestJS, error middleware in Express) and you want certain errors to be processed by that mechanism instead of being handled by the oRPC error handling flow. - -## Usage - -```ts twoslash -import { ORPCError } from '@orpc/server' -import { RPCHandler } from '@orpc/server/fetch' -import { router } from './shared/planet' - -// ---cut--- -import { - experimental_RethrowHandlerPlugin as RethrowHandlerPlugin, -} from '@orpc/server/plugins' - -const handler = new RPCHandler(router, { - plugins: [ - new RethrowHandlerPlugin({ - // Decide which errors should be rethrown. - filter: (error) => { - // Example: Rethrow all non-ORPCError errors - // This allows unhandled exceptions to bubble up to your framework - return !(error instanceof ORPCError) - }, - }), - ], -}) -``` - -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or another custom handler. -::: diff --git a/apps/content/docs/plugins/retry-after.md b/apps/content/docs/plugins/retry-after.md index 951a1188d..6fe33bf58 100644 --- a/apps/content/docs/plugins/retry-after.md +++ b/apps/content/docs/plugins/retry-after.md @@ -1,40 +1,37 @@ ---- -title: Retry After Plugin -description: A plugin for oRPC that automatically retries requests based on server Retry-After headers. ---- - # Retry After Plugin -The **Retry After Plugin** automatically retries requests based on server `Retry-After` headers. This is particularly useful for handling rate limiting and temporary server unavailability. +**Retry After Plugin** automatically retries requests according to the `Retry-After` response header. This is especially useful for handling rate limits and temporary server unavailability. ## Usage ```ts -import { RetryAfterPlugin } from '@orpc/client/plugins' +import { RetryAfterLinkPlugin } from '@orpc/client/plugins' const link = new RPCLink({ - url: 'http://localhost:3000/rpc', plugins: [ - new RetryAfterPlugin({ - condition: (response, options) => { - // Override condition to determine if a request should be retried - return response.status === 429 || response.status === 503 - }, - maxAttempts: 5, // Maximum retry attempts - timeout: 5 * 60 * 1000, // Maximum time to spend retrying (ms) - }), + new RetryAfterLinkPlugin(), ], }) ``` -::: info Options + + +## Options -- **`condition`**: A function to determine whether a request should be retried. Defaults to retrying on `429` (Too Many Requests) and `503` (Service Unavailable) status codes. -- **`maxAttempts`**: Maximum number of retry attempts allowed. Defaults to `3`. -- **`timeout`**: Maximum duration in milliseconds to spend on retries. If specified, retries will stop once this time limit is exceeded. Defaults to `5 * 60 * 1000` (5 minutes). +By default, the plugin retries only requests that receive a `429` (Too Many Requests) or `503` (Service Unavailable) status code. It times out after 5 minutes and allows up to 3 retry attempts. You can customize this behavior with the following options: + +```ts +const link = new RPCLink({ + plugins: [ + new RetryAfterLinkPlugin({ + condition: response => [429, 503].includes(response.status), + timeout: 5 * 60 * 1000, // 5 minutes + maxAttempts: 3, + }), + ], +}) +``` -::: +## Learn More -::: info -The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom implementations. -::: +For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/client/src/plugins/retry-after.ts). diff --git a/apps/content/docs/plugins/retry.md b/apps/content/docs/plugins/retry.md new file mode 100644 index 000000000..e46cbf0e6 --- /dev/null +++ b/apps/content/docs/plugins/retry.md @@ -0,0 +1,95 @@ +# Retry Plugin + +**Retry Plugin** automatically retries failed requests based on customizable retry strategies, improving the resilience of your application. + +::: warning +Before using this plugin, make sure you understand [client context](/docs/client/client-side#client-context), as retry behavior is managed through context. +::: + +## Setup + +```ts +import { RetryLinkPlugin, RetryLinkPluginContext } from '@orpc/client/plugins' + +interface ClientContext extends RetryLinkPluginContext {} + +const link = new RPCLink({ + plugins: [ + new RetryLinkPlugin(), + ], +}) +``` + + + +## Usage + +By default, retries are disabled. To enable retries, set the `retry` count in the request context: + +```ts twoslash +import { router } from './shared/planet' +import { RetryLinkPluginContext } from '@orpc/client/plugins' +import { RouterClient } from '@orpc/server' + +declare const client: RouterClient +// ---cut--- +const planets = await client.planet.list({ limit: 10 }, { + context: { + retry: 3, // Maximum retry attempts + retryDelay: 2000, // Delay between retries in ms + shouldRetry: options => true, // Determines whether to retry based on the error + onRetry: (options) => { + // Hook executed on each retry + + return (isSuccess) => { + // Execute after the retry is complete + } + }, + } +}) +``` + +::: info +The following context options control retry behavior: + +- **retry:** Maximum number of retry attempts before throwing an error _(default: `0`)_. +- **retryDelay:** Delay between retry attempts _(default: `(o) => o.lastEventRetry ?? 2000`)_. +- **shouldRetry:** Function that determines whether a retry should be attempted _(default: `true`)_. + +You can override the default retry behavior globally by passing `default` options when initializing the plugin: + +```ts +const link = new RPCLink({ + plugins: [ + new RetryLinkPlugin({ + default: { + retry: 0, + retryDelay: o => o.lastEventRetry ?? 2000, + shouldRetry: o => true, + } + }), + ], +}) +``` + +::: + +## Event Source Simulation + +To replicate the behavior of [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) for [Event Iterator](/docs/event-iterator), use the following configuration: + +```ts +const streaming = await client.streaming('the input', { + context: { + retry: Number.POSITIVE_INFINITY, + } +}) + +for await (const message of streaming) { + console.log(message) +} +``` + +## Learn More + +For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/client/src/plugins/retry.ts). diff --git a/apps/content/docs/plugins/simple-csrf-protection.md b/apps/content/docs/plugins/simple-csrf-protection.md deleted file mode 100644 index b9f57f39e..000000000 --- a/apps/content/docs/plugins/simple-csrf-protection.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: Simple CSRF Protection Plugin -description: Add basic Cross-Site Request Forgery (CSRF) protection to your oRPC application. It helps ensure that requests to your procedures originate from JavaScript code, not from other sources like standard HTML forms or direct browser navigation. ---- - -# Simple CSRF Protection Plugin - -This plugin adds basic [Cross-Site Request Forgery (CSRF)](https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CSRF_prevention) protection to your oRPC application. It helps ensure that requests to your procedures originate from JavaScript code, not from other sources like standard HTML forms or direct browser navigation. - -## When to Use - -This plugin is beneficial if your application stores sensitive data (like session or auth tokens) in Cookie storage using `SameSite=Lax` (the default) or `SameSite=None`. - -## Setup - -This plugin requires configuration on both the server and client sides. - -### Server - -```ts twoslash -import { RPCHandler } from '@orpc/server/fetch' -import { router } from './shared/planet' -// ---cut--- -import { SimpleCsrfProtectionHandlerPlugin } from '@orpc/server/plugins' - -const handler = new RPCHandler(router, { - strictGetMethodPluginEnabled: false, // Replace Strict Get Method Plugin - plugins: [ - new SimpleCsrfProtectionHandlerPlugin() - ], -}) -``` - -::: info -The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc-handler), [OpenAPIHandler](/docs/openapi/openapi-handler), or custom implementations. -::: - -### Client - -```ts twoslash -import { RPCLink } from '@orpc/client/fetch' -// ---cut--- -import { SimpleCsrfProtectionLinkPlugin } from '@orpc/client/plugins' - -const link = new RPCLink({ - url: 'https://api.example.com/rpc', - plugins: [ - new SimpleCsrfProtectionLinkPlugin(), - ], -}) -``` - -::: info -The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom implementations. -::: diff --git a/apps/content/docs/plugins/smart-coercion.md b/apps/content/docs/plugins/smart-coercion.md new file mode 100644 index 000000000..cac85d9a4 --- /dev/null +++ b/apps/content/docs/plugins/smart-coercion.md @@ -0,0 +1,176 @@ +# Smart Coercion Plugin + +Automatically converts values to match your schema types without requiring manual coercion logic. + +::: warning +This plugin improves developer experience, but it adds runtime overhead. For performance sensitive applications or complex schemas, manual coercion in your validation layer is usually more efficient. +::: + +## Installation + +::: code-group + +```sh [npm] +npm install @orpc/json-schema@latest +``` + +```sh [yarn] +yarn add @orpc/json-schema@latest +``` + +```sh [pnpm] +pnpm add @orpc/json-schema@latest +``` + +```sh [bun] +bun add @orpc/json-schema@latest +``` + +```sh [deno] +deno add npm:@orpc/json-schema@latest +``` + +::: + +## Setup + +Use `SmartCoercionHandlerPlugin` in your handler to coerce incoming request data to the expected `.input` schema: + +```ts +import { SmartCoercionHandlerPlugin } from '@orpc/json-schema' + +const handler = new OpenAPIHandler(router, { + plugins: [ + new SmartCoercionHandlerPlugin({ + converters: [ + new ZodToJsonSchemaConverter(), + // Add other schema converters as needed + ], + }) + ] +}) +``` + +Use `SmartCoercionLinkPlugin` in your link to coerce server responses to the expected `.output` or `.errors` schemas: + +```ts +import { SmartCoercionLinkPlugin } from '@orpc/json-schema' + +const link = new OpenAPILink(contract, { + plugins: [ + new SmartCoercionLinkPlugin(contract, { + converters: [ + new ZodToJsonSchemaConverter(), + // Add other schema converters as needed + ], + }), + ] +}) +``` + +::: info +This plugin relies on [JSON Schema Converters](/docs/openapi/specification#json-schema-converters) to determine how values should be coerced. Configure the appropriate converter for each validation library you use. If a required converter is unavailable, it automatically falls back to [Standard Json Schema](https://standardschema.dev/json-schema) conversion. +::: + +## How It Works + +The plugin coerces values safely by following these rules: + +1. **Schema-driven:** Converts only when the schema defines the target type +2. **Safe only:** Converts only values with an unambiguous representation, such as `'123'` to `123` +3. **Preserve original values:** Leaves the original value unchanged when conversion would be unsafe +4. **Union-aware:** Picks the best match for union types +5. **Deep conversion:** Applies recursively inside nested objects and arrays + +::: info +JSON Schema does not natively represent `BigInt`, `Date`, `RegExp`, `URL`, `Set`, or `Map`. For these types, oRPC relies on `x-native-type` metadata in your schema: + +- `x-native-type: 'bigint'` for BigInt +- `x-native-type: 'date'` for Date +- `x-native-type: 'regexp'` for RegExp +- `x-native-type: 'url'` for URL +- `x-native-type: 'set'` for Set +- `x-native-type: 'map'` for Map + +The built-in [Standard Json Schema](https://standardschema.dev/json-schema) converter handles these cases. Because this metadata is outside the official JSON Schema specification, custom converters may need to add the appropriate `x-native-type` values explicitly. +::: + +## Conversion Rules + +### String → Boolean + +Supports these specific string values, case-insensitively: + +- `'true'`, `'on'` → `true` +- `'false'`, `'off'` → `false` + +::: info +HTML `` elements commonly submit `'on'` or `'off'`, so this conversion is especially useful for form handling. +::: + +### String → Number + +Supports valid numeric strings: + +- `'123'` → `123` +- `'3.14'` → `3.14` + +### String/Number → BigInt + +Supports valid numeric strings or numbers: + +- `'12345678901234567890'` → `12345678901234567890n` +- `12345678901234567890` → `12345678901234567890n` + +### String → Date + +Supports ISO date and datetime strings: + +- `'2023-10-01'` → `new Date('2023-10-01')` +- `'2020-01-01T06:15'` → `new Date('2020-01-01T06:15')` +- `'2020-01-01T06:15Z'` → `new Date('2020-01-01T06:15Z')` +- `'2020-01-01T06:15:00Z'` → `new Date('2020-01-01T06:15:00Z')` +- `'2020-01-01T06:15:00.123Z'` → `new Date('2020-01-01T06:15:00.123Z')` + +### String → RegExp + +Supports valid regular expression strings: + +- `'/^\\d+$/i'` → `new RegExp('^\\d+$', 'i')` +- `'/abc/'` → `new RegExp('abc')` + +### String → URL + +Supports valid URL strings: + +- `'https://example.com'` → `new URL('https://example.com')` + +### Array → Set + +Supports arrays of **unique values**: + +- `['apple', 'banana']` → `new Set(['apple', 'banana'])` + +### Array → Object + +Converts arrays into objects with numeric keys: + +- `['apple', 'banana']` → `{ 0: 'apple', 1: 'banana' }` + +::: info +This is particularly useful for [Bracket Notation](/docs/openapi/bracket-notation#limitations) when you need objects with numeric keys. +::: + +### Array → Map + +Supports arrays of key-value pairs with **unique keys**: + +- `[['key1', 'value1'], ['key2', 'value2']]` → `new Map([['key1', 'value1'], ['key2', 'value2']])` + +## Advanced Usage + +You can also use this plugin in guides such as [Expanding Type Support for OpenAPI Link](/docs/advanced/expanding-type-support-for-openapi-link). + +## Learn More + +For implementation details, see the [SmartCoercionHandlerPlugin source code](https://github.com/middleapi/orpc/blob/main/packages/json-schema/src/v2/smart-coercion-handler-plugin.ts) or the [SmartCoercionLinkPlugin source code](https://github.com/middleapi/orpc/blob/main/packages/json-schema/src/v2/smart-coercion-link-plugin.ts). diff --git a/apps/content/docs/plugins/strict-get-method.md b/apps/content/docs/plugins/strict-get-method.md deleted file mode 100644 index d34623ca5..000000000 --- a/apps/content/docs/plugins/strict-get-method.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: Strict GET Method Plugin -description: Enhance security by ensuring only procedures explicitly marked to accept `GET` requests can be called using the HTTP `GET` method for RPC Protocol. This helps prevent certain types of Cross-Site Request Forgery (CSRF) attacks. ---- - -# Strict GET Method Plugin - -This plugin enhances security by ensuring only procedures explicitly marked to accept `GET` requests can be called using the HTTP `GET` method for [RPC Protocol](/docs/advanced/rpc-protocol). This helps prevent certain types of [Cross-Site Request Forgery (CSRF)](https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CSRF_prevention) attacks. - -## When to Use - -This plugin is beneficial if your application stores sensitive data (like session or auth tokens) in Cookie storage using `SameSite=Lax` (the default) or `SameSite=None`. - -::: info -[RPCHandler](/docs/rpc-handler#default-plugins) enabled this plugin by default for [HTTP Adapter](/docs/adapters/http). You may switch to [Simple CSRF Protection](/docs/plugins/simple-csrf-protection) if preferred, or disable this plugin entirely if it does not provide any benefit for your use case. -::: - -## How it works - -The plugin enforces a simple rule: only procedures explicitly configured with `method: 'GET'` can be invoked via a `GET` request. All other procedures will reject `GET` requests. - -```ts -import { os } from '@orpc/server' - -const ping = os - .route({ method: 'GET' }) // [!code highlight] - .handler(() => 'pong') -``` - -## Setup - -```ts twoslash -import { RPCHandler } from '@orpc/server/fetch' -import { router } from './shared/planet' -// ---cut--- -import { StrictGetMethodPlugin } from '@orpc/server/plugins' - -const handler = new RPCHandler(router, { - plugins: [ - new StrictGetMethodPlugin() - ], -}) -``` diff --git a/apps/content/docs/procedure.md b/apps/content/docs/procedure.md index cc42abbd6..ebbf5d868 100644 --- a/apps/content/docs/procedure.md +++ b/apps/content/docs/procedure.md @@ -1,54 +1,95 @@ ---- -title: Procedure -description: Understanding procedures in oRPC ---- +# Procedure -# Procedure in oRPC - -In oRPC, a procedure is like a standard function but comes with built-in support for: - -- Input/output validation -- Middleware -- Dependency injection -- Other extensibility features +Procedures are the core building blocks of oRPC. They define the logic for handling specific operations, including input validation, output validation, and middleware application. Each procedure is created using a builder pattern that allows for flexible composition and reuse. ## Overview -Here's an example of defining a procedure in oRPC: +```ts twoslash +import { z } from 'zod' +import type { AnyMetaPlugin } from '@orpc/server' + +declare const someMeta: AnyMetaPlugin + +const requireAuth = os + .middleware(({ context, next }) => { + return next({ + context: { + user: { id: 1 } + } + }) + }) -```ts +const canEdit = os + .$context<{ user: { id: number } }>() + .middleware(async ({ next }, id: number) => { + return next() + }) +// ---cut--- import { os } from '@orpc/server' const example = os - .use(aMiddleware) // Apply middleware - .input(z.object({ name: z.string() })) // Define input validation - .use(aMiddlewareWithInput, input => input.name) // Use middleware with typed input - .output(z.object({ id: z.number() })) // Define output validation - .handler(async ({ input, context }) => { // Define execution logic - return { id: 1 } + .$context<{ something?: string }>() // <- define initial context + .meta(someMeta) // <- attach metadata + .errors({ NOT_FOUND: {} }) // <- define errors + .use(requireAuth) // <- apply middleware + .input(z.object({ id: z.number(), name: z.string() })) // <- input validation + .use(canEdit.adaptInput(input => input.id)) // <- middleware with typed input + .output(z.object({ id: z.number(), name: z.string() })) // <- output validation + .handler(async ({ input, context, errors }) => { // <- handler logic + return { id: 1, name: 'example' } }) - .callable() // Make the procedure callable like a regular function - .actionable() // Server Action compatibility ``` :::info The `.handler` method is the only required step. All other chains are optional. ::: +## Initial Context + +Use `.$context` to declare the initial context required for a procedure to execute. +Learn more in the [Context Documentation](/docs/context). + +## Metadata + +Use `.meta` to attach metadata to a procedure. You can access this metadata later in middleware or plugins. Learn more in the [Metadata Documentation](/docs/metadata). + +## Typesafe Errors + +Use `.errors` to define error definitions for a procedure. These errors can be thrown in the handler or middleware and will be properly typed on the client. Learn more in the [Typesafe Error Handling documentation](/docs/error-handling#typesafe-errors). + ## Input/Output Validation -oRPC supports [Zod](https://github.com/colinhacks/zod), [Valibot](https://github.com/fabian-hiller/valibot), [Arktype](https://github.com/arktypeio/arktype), and any other [Standard Schema](https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec) library for input and output validation. +oRPC supports [Zod](https://zod.dev/), [Valibot](https://valibot.dev/), [Arktype](https://arktype.io/), and any other [Standard Schema](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec) library for validation. ::: tip -By explicitly specifying the `.output` or your `handler's return type`, you enable TypeScript to infer the output without parsing the handler's code. This approach can dramatically enhance both type-checking and IDE-suggestion speed. +By specifying `.output` or the handler's return type, TypeScript can infer the output without analyzing the handler body. This can significantly improve type-checking and IDE suggestion performance for complex handlers. +::: + +### Multiple Schemas + +`.input` and `.output` can be called multiple times. Each call adds another schema instead of replacing an earlier one. + +```ts +const example = os + .input(z.looseObject({ name: z.string() })) + .input(z.looseObject({ id: z.number() })) + .output(z.looseObject({ name: z.string() })) + .output(z.looseObject({ id: z.number() })) + .handler(async ({ input }) => { + return { id: 1, name: 'example' } + }) +``` + +::: warning +When you stack schemas, the input or output must satisfy all of them, so the schemas need to be compatible. For example, with Zod, prefer `z.looseObject` over `z.object` to allow unknown properties. ::: ### `type` Utility -For simple use-case without external libraries, use oRPC's built-in `type` utility. It takes a mapping function as its first argument: +For simple use cases without external libraries, use oRPC's built-in `type` utility. It takes a mapping function as its first argument: -```ts twoslash -import { os, type } from '@orpc/server' +```ts +import { type } from '@orpc/server' const example = os .input(type<{ value: number }>()) @@ -69,20 +110,29 @@ const example = os .handler(async ({ context }) => { /* logic */ }) ``` -::: info -[Middleware](/docs/middleware) can be applied if the [current context](/docs/context#combining-initial-and-execution-context) meets the [middleware dependent context](/docs/middleware#dependent-context) requirements and does not conflict with the [current context](/docs/context#combining-initial-and-execution-context). +::: warning +[Middleware](/docs/middleware) can only be applied when the [current context](/docs/context#combining-initial-and-middleware-context) satisfies the [middleware's initial context](/docs/middleware#initial-context) and does not conflict with the context the middleware adds. ::: -## Initial Configuration - -Customize the initial input schema using `.$input`: +::: info +You can use [`.adaptInput`](/docs/middleware#middleware-input) when applying middleware to adapt the input to a different shape that the middleware expects. ```ts -const base = os.$input(z.void()) -const base = os.$input>() +const canEdit = os.middleware(async ({ next }, id: string) => { + if (!canUserEdit(id)) { + throw new ORPCError('UNAUTHORIZED') + } + + return next() +}) + +const example = os + .input(z.object({ id: z.string(), name: z.string() })) + .use(canEdit.adaptInput(input => input.id)) // Adapt input to match middleware's expected shape + .handler(async ({ context }) => { /* logic */ }) ``` -Unlike `.input`, the `.$input` method lets you redefine the input schema after its initial configuration. This is useful when you need to enforce a `void` input when no `.input` is specified. +::: ## Reusability @@ -90,11 +140,13 @@ Each modification to a builder creates a completely new instance, avoiding refer ```ts const pub = os.use(logMiddleware) // Base setup for procedures that publish -const authed = pub.use(authMiddleware) // Extends 'pub' with authentication +const authed = pub.use(requireAuth) // Extends 'pub' with authentication -const pubExample = pub.handler(async ({ context }) => { /* logic */ }) +const pubExample = pub + .handler(async ({ context }) => { /* logic */ }) -const authedExample = pubExample.use(authMiddleware) +const authedExample = authed + .handler(async ({ context }) => { /* logic */ }) ``` This pattern helps prevent duplication while maintaining flexibility. diff --git a/apps/content/docs/router.md b/apps/content/docs/router.md index 5c0a0fa88..9a65e78fb 100644 --- a/apps/content/docs/router.md +++ b/apps/content/docs/router.md @@ -1,35 +1,36 @@ ---- -title: Router -description: Understanding routers in oRPC ---- +# Router -# Router in oRPC +A router is a plain, nestable object made up of procedures. Routers can also modify those procedures, which makes it easy to organize and extend your API. -Routers in oRPC are simple, nestable objects composed of procedures. They can also modify their own procedures, offering flexibility and modularity when designing your API. +::: info +A standalone [procedure](/docs/procedure) is also a router, so you can use all router features on individual procedures too. +::: ## Overview -Routers are defined as plain JavaScript objects where each key corresponds to a procedure. For example: +Define a router as a plain JavaScript object where each key maps to a procedure: -```ts +```ts twoslash import { os } from '@orpc/server' const ping = os.handler(async () => 'ping') const pong = os.handler(async () => 'pong') -const router = { +export const router = { ping, pong, nested: { ping, pong } } ``` + + ## Extending Router -Routers can be modified to include additional features. For example, to require authentication on all procedures: +You can extend a router with shared behavior. For example, by applying authentication middleware or attaching metadata to every procedure: ```ts -const router = os.use(requiredAuth).router({ +const router = os.use(requiredAuth).meta(requireAuthMeta).router({ ping, pong, nested: { @@ -39,13 +40,13 @@ const router = os.use(requiredAuth).router({ }) ``` -::: warning -If you apply middleware using `.use` at both the router and procedure levels, it may execute multiple times. This duplication can lead to performance issues. For guidance on avoiding redundant middleware execution, please see our [best practices for middleware deduplication](/docs/best-practices/dedupe-middleware). +::: danger +If you apply middleware with `.use` at both the router and procedure levels, it may run more than once. That duplication can hurt performance. To avoid redundant middleware execution, see our [best practices for middleware deduplication](/docs/best-practices/dedupe-middleware). ::: ## Lazy Router -In oRPC, routers can be lazy-loaded, making them ideal for code splitting and enhancing cold start performance. Lazy loading allows you to defer the initialization of routes until they are actually needed, which reduces the initial load time and improves resource management. +Routers can also be lazy-loaded. This is useful for code splitting and can improve cold start performance by deferring route initialization until it is needed. ::: code-group @@ -84,29 +85,16 @@ export default { ::: -::: tip -Alternatively, you can use the standalone `lazy` helper from `@orpc/server`. This helper is faster for type inference, and doesn't require matching the [Initial Context](/docs/context#initial-context). - -```ts [router.ts] -import { lazy } from '@orpc/server' - -const router = { - ping, - pong, - planet: lazy(() => import('./planet')) -} -``` - -::: - ## Utilities ::: info -Every [procedure](/docs/procedure) is also a router, so you can apply these utilities to procedures as well. +A standalone [procedure](/docs/procedure) is also a router, so these utilities work with procedures too. ::: ### Infer Router Inputs +Infers the input type for each procedure in the router. + ```ts twoslash import type { router } from './shared/planet' // ---cut--- @@ -117,10 +105,10 @@ export type Inputs = InferRouterInputs type FindPlanetInput = Inputs['planet']['find'] ``` -Infers the expected input types for each procedure in the router. - ### Infer Router Outputs +Infers the output type for each procedure in the router. + ```ts twoslash import type { router } from './shared/planet' // ---cut--- @@ -131,10 +119,10 @@ export type Outputs = InferRouterOutputs type FindPlanetOutput = Outputs['planet']['find'] ``` -Infers the expected output types for each procedure in the router. - ### Infer Router Initial Contexts +Infers the [initial context](/docs/context#initial-context) for each procedure in the router. + ```ts twoslash import type { router } from './shared/planet' // ---cut--- @@ -145,18 +133,42 @@ export type InitialContexts = InferRouterInitialContexts type FindPlanetInitialContext = InitialContexts['planet']['find'] ``` -Infers the [initial context](/docs/context#initial-context) types defined for each procedure. +### Infer Router Final Contexts -### Infer Router Current Contexts +Infers the final context for each procedure in the router by combining the [initial and injected context](/docs/context#combining-initial-and-injected-context). This is the closest match to the context the procedure's handler receives. ```ts twoslash import type { router } from './shared/planet' // ---cut--- -import type { InferRouterCurrentContexts } from '@orpc/server' +import type { InferRouterFinalContexts } from '@orpc/server' -export type CurrentContexts = InferRouterCurrentContexts +export type FinalContexts = InferRouterFinalContexts -type FindPlanetCurrentContext = CurrentContexts['planet']['find'] +type FindPlanetFinalContext = FinalContexts['planet']['find'] ``` -Infers the [current context](/docs/context#combining-initial-and-execution-context) types, which combine the initial context with the execution context and pass it to the handler. +### Infer Router Errors + +Infers the throwable errors each procedure in a router can produce. + +```ts twoslash +import type { router } from './shared/planet' +// ---cut--- +import type { InferRouterErrors } from '@orpc/server' + +export type Errors = InferRouterErrors + +type FindPlanetError = Errors['planet']['find'] +``` + +### Infer Router Error + +Infers all possible throwable errors the entire router can produce. This is useful when you want a single type for router-wide error handling. + +```ts twoslash +import type { router } from './shared/planet' +// ---cut--- +import type { InferRouterError } from '@orpc/server' + +export type RouterError = InferRouterError +``` diff --git a/apps/content/docs/rpc-handler.md b/apps/content/docs/rpc-handler.md deleted file mode 100644 index 1f2782fe4..000000000 --- a/apps/content/docs/rpc-handler.md +++ /dev/null @@ -1,139 +0,0 @@ ---- -title: RPC Handler -description: Comprehensive Guide to the RPCHandler in oRPC ---- - -# RPC Handler - -The `RPCHandler` enables communication with clients over oRPC's proprietary [RPC protocol](/docs/advanced/rpc-protocol), built on top of HTTP. While it efficiently transfers native types, the protocol is neither human-readable nor OpenAPI-compatible. For OpenAPI support, use the [OpenAPIHandler](/docs/openapi/openapi-handler). - -:::warning -`RPCHandler` is designed exclusively for [RPCLink](/docs/client/rpc-link) and **does not** support OpenAPI. Avoid sending requests to it manually. -::: - -:::warning -This documentation is focused on the [HTTP Adapter](/docs/adapters/http). -Other adapters may remove or change options to keep things simple. -::: - -## Supported Data Types - -`RPCHandler` natively serializes and deserializes the following JavaScript types: - -- **string** -- **number** (including `NaN`) -- **boolean** -- **null** -- **undefined** -- **Date** (including `Invalid Date`) -- **BigInt** -- **RegExp** -- **URL** -- **Record (object)** -- **Array** -- **Set** -- **Map** -- **Blob** (unsupported in `AsyncIteratorObject`) -- **File** (unsupported in `AsyncIteratorObject`) -- **AsyncIteratorObject** (only at the root level; powers the [Event Iterator](/docs/event-iterator)) - -:::tip -You can extend the list of supported types by [creating a custom serializer](/docs/advanced/rpc-json-serializer#extending-native-data-types). -::: - -## Setup and Integration - -```ts -import { RPCHandler } from '@orpc/server/fetch' // or '@orpc/server/node' -import { CORSPlugin } from '@orpc/server/plugins' -import { onError } from '@orpc/server' - -const handler = new RPCHandler(router, { - plugins: [ - new CORSPlugin() - ], - interceptors: [ - onError((error) => { - console.error(error) - }) - ], -}) - -export default async function fetch(request: Request) { - const { matched, response } = await handler.handle(request, { - prefix: '/rpc', - context: {} // Provide initial context if required - }) - - if (matched) { - return response - } - - return new Response('Not Found', { status: 404 }) -} -``` - -## Filtering Procedures - -You can filter a procedure from matching by using the `filter` option: - -```ts -const handler = new RPCHandler(router, { - filter: ({ contract, path }) => !contract['~orpc'].route.tags?.includes('internal'), -}) -``` - -## Default Plugins - -`RPCHandler` automatically enables **essential plugins** for security reasons. - -| Plugin | Applies To | Toggle Option | -| -------------------------------------------------------- | ----------------------------------- | ------------------------------ | -| [StrictGetMethodPlugin](/docs/plugins/strict-get-method) | [HTTP Adapter](/docs/adapters/http) | `strictGetMethodPluginEnabled` | - -::: info -You can safely disable default plugins if they don't provide any meaningful benefit for your use case. -::: - -## Lifecycle - -```mermaid -sequenceDiagram - actor A1 as Client - participant P3 as Request/Response encoder - participant P4 as Router + Input/Output encoder - participant P5 as Server-Side Procedure Client - - Note over A1: adaptorInterceptors - A1 ->> P3: request - P3 ->> P3: Convert - Note over P3: rootInterceptors - P3 ->> P4: standard request - Note over P4: interceptors - P4 ->> P4: Find procedure - P4 ->> A1: If not matched - P4 ->> P4: Load body + decode request - P4 ->> P3: if invalid request - P3 ->> A1: response - P4 ->> P5: Input, Signal, LastEventId,... - Note over P5: clientInterceptors - P5 ->> P5: Handle - P5 ->> P4: if success - P4 ->> P4: Encode output - P5 ->> P4: if failed - Note over P4: end interceptors - P4 ->> P4: Encode error - P4 ->> P3: standard response - P3 ->> A1: response -``` - -::: tip -Interceptors can be used to intercept and modify the lifecycle at various stages. -::: - -:::info - -- The Server-side Procedure Client is a [Server-Side Client](/docs/client/server-side), and `clientInterceptors` are the same as [Server-Side Client Interceptors](/docs/client/server-side#lifecycle). -- Some `RPCHandler` implementations may omit the `Request/Response encoder` when it's not required. - -::: diff --git a/apps/content/docs/rpc/handler.md b/apps/content/docs/rpc/handler.md new file mode 100644 index 000000000..f0e70f3ae --- /dev/null +++ b/apps/content/docs/rpc/handler.md @@ -0,0 +1,276 @@ +# RPC Handler + +Use `RPCHandler` to communicate with [RPC Link](/docs/rpc/link) and other clients that implement the [RPC protocol](/docs/rpc/protocol). + +## Overview + +```ts +const handler = new RPCHandler(router, { + interceptors: [ + async ({ next, path }) => { + console.time(path.join('.')) + + try { + return await next() + } + catch (err) { + console.error(`${path.join('.')}:`, err) + throw err + } + finally { + console.timeEnd(path.join('.')) + } + } + ], + plugins: [ + new CORSHandlerPlugin() + ], +}) +``` + +::: info +The actual usage of `RPCHandler` depends on the adapter you use. For example, when using the fetch adapter, the handler is used like this: + +```ts +export async function fetch(request: Request) { + const { response } = await handler.fetch(request, { + prefix: '/rpc', + context: {} // <- provide initial context if needed + }) + + return response ?? new Response('Not Found', { status: 404 }) +} +``` + +::: + + + +## Interceptors + +Interceptors let you observe or change different stages of an RPC request. Common use cases include logging, error handling, and metrics. + +### Routing Interceptors + +Routing interceptors run on every request before routing. Use them when you need to handle all requests, including requests that do not match a procedure. + +```ts +const handler = new RPCHandler(router, { + routingInterceptors: [ + async ({ next, request, context }) => { + if (condition) { + return { matched: false } + } + + const { matched, response } = await next() + return { matched, response } + }, + ], +}) +``` + +### Interceptors + +These interceptors run only for matched requests, after routing and before error handling (but can't use `ORPCError` for [typesafe errors](/docs/error-handling#orpcerror-compatibility)). Use them when you need access to the matched procedure. + +::: tip +In most cases, `interceptors` are the best choice. They provide more context, are easier to work with, and run before error handling. +::: + +```ts +const handler = new RPCHandler(router, { + interceptors: [ + async ({ next, request, procedure, context }) => { + try { + const response = await next() + return response + } + catch (err) { + if (err instanceof CustomError) { + throw new ORPCError('CUSTOM_ERROR', { message: err.message, cause: err }) + } + + throw err + } + }, + async ({ next, path }) => { + console.time(path.join('.')) + + try { + const response = await next() + return response + } + catch (err) { + console.error(`${path.join('.')}:`, err) + throw err + } + finally { + console.timeEnd(path.join('.')) + } + }, + ], +}) +``` + +### Client Interceptors + +Client interceptors run only for matched requests, after input decoding, before output encoding and can use `ORPCError` for [typesafe errors](/docs/error-handling#orpcerror-compatibility). Use them when you need access to the procedure, input, and output. + +```ts +const handler = new RPCHandler(router, { + clientInterceptors: [ + async ({ next, input, context, procedure }) => { + const output = await next() + return output + }, + ], +}) +``` + +### Adapter Interceptors + +Some `RPCHandler` implementations, such as fetch or node adapters, also support adapter interceptors. These run before [Routing Interceptors](#routing-interceptors) and let you work with the adapter's native request and response objects. + +```ts +const handler = new RPCHandler(router, { + fetchInterceptors: [ + async ({ next, request }) => { + const { matched, response } = await next() + return { matched, response } + }, + ], +}) +``` + +::: info +This example uses the fetch adapter. For other adapters, refer to their JSDoc or adapter-specific documentation. +::: + +## Plugins + +Plugins package reusable interceptors. For example, [CORS Plugin](/docs/plugins/cors) adds a [routing interceptor](#routing-interceptors) to handle preflight requests and adds CORS headers to every response. + +```ts +const handler = new RPCHandler(router, { + plugins: [ + new CORSHandlerPlugin() + ], +}) +``` + +::: info +HTTP-based `RPCHandler` implementations enable the [CSRF Guard Plugin](/docs/plugins/csrf-guard) by default to protect RPC requests from CSRF attacks. Disable it with `csrfGuardHandlerPlugin.enabled`. + +```ts +const handler = new RPCHandler(router, { + csrfGuardHandlerPlugin: { + enabled: false, + }, +}) +``` + +::: + +## Custom Serializer + +`RPCHandler` uses a built-in serializer that supports many native types. Provide a custom serializer when you need extra types or different encoding behavior. For more details, see [RPC Serializer](/docs/rpc/serializer). + +```ts +const handler = new RPCHandler(router, { + serializer: new RPCSerializer({ + handlers: { + // ...custom handlers + }, + }), +}) +``` + +## Filtering Procedures + +Use the `filter` option to exclude procedures from matching: + +```ts +const handler = new RPCHandler(router, { + filter: (contract, path) => getIsInternalMeta(contract) !== true, +}) +``` + +## Custom Error Response + +By default, `RPCHandler` uses `COMMON_ERROR_STATUS_MAP` to determine response status codes. Use `errorStatusMap` to customize them: + +```ts +import { COMMON_ERROR_STATUS_MAP } from '@orpc/server' + +const handler = new RPCHandler(router, { + errorStatusMap: { + ...COMMON_ERROR_STATUS_MAP, + CUSTOM_ERROR: 599, + }, +}) +``` + +::: details Common Error Status Map + + + +::: + +## Event Stream Options + +Configure how [event iterators](/docs/event-iterator) are streamed to the client. Available options depend on the adapter. For example, the fetch adapter supports: + +```ts +const handler = new RPCHandler(router, { + toFetchResponse: { + eventStream: { + initialComment: { + /** + * If true, an initial comment is sent immediately upon stream start to flush headers. + * This allows the receiving side to establish the connection without waiting for the first event. + * + * @default true + */ + enabled: true, + /** + * The content of the initial comment sent upon stream start. Must not include newline characters. + * + * @default '' + */ + comment: '', + }, + keepAlive: { + /** + * If true, a ping comment is sent periodically to keep the connection alive. + * + * @default true + */ + enabled: true, + /** + * Interval (in milliseconds) between ping comments sent after the last event. + * + * @default 5000 + */ + interval: 5000, + /** + * The content of the ping comment. Must not include newline characters. + * + * @default '' + */ + comment: '', + }, + /** + * If true, a `close` event is sent even when the iterator completes with `undefined`. + * When the iterator returns a value, a `close` event is always emitted regardless of this setting. + * + * @default true + */ + emptyCloseEventEnabled: true, + }, + }, +}) +``` + +## Lifecycle + +TODO: add lifecycle diagram diff --git a/apps/content/docs/rpc/link.md b/apps/content/docs/rpc/link.md new file mode 100644 index 000000000..6ba4884de --- /dev/null +++ b/apps/content/docs/rpc/link.md @@ -0,0 +1,336 @@ +# RPC Link + +Use `RPCLink` to communicate with [RPC Handler](/docs/rpc/handler) and other servers that implement the [RPC protocol](/docs/rpc/protocol). + +## Overview + +```ts +const link = new RPCLink({ + origin: 'https://example.com', + url: '/rpc', + headers: ({ context }) => ({ + authorization: `Bearer ${token}`, + }), + interceptors: [ + async ({ next, path }) => { + console.time(path.join('.')) + + try { + return await next() + } + catch (err) { + console.error(`${path.join('.')}:`, err) + throw err + } + finally { + console.timeEnd(path.join('.')) + } + }, + ], + plugins: [ + new RetryAfterLinkPlugin(), + ], + fetch: (request, init) => { // <- only available in fetch adapter + return globalThis.fetch(request, { + ...init, + credentials: 'include', // Include cookies on cross-origin requests + }) + }, +}) + +export const client = createORPCClient(link) +``` + + + +## Typesafe Clients + +After you create an `RPCLink`, pass it to `createORPCClient` to build a typesafe client for either a [contract](/docs/contract/router) or a [router](/docs/router): + +```ts +import { createORPCClient } from '@orpc/client' +import { RouterContractClient } from '@orpc/contract' +import { RouterClient } from '@orpc/server' + +// if you are following contract-first approach +const contractClient: RouterContractClient = createORPCClient(link) + +// if you are following normal approach +const normalClient: RouterClient = createORPCClient(link) +``` + +## Client Context + +Client context lets you pass per-call values, such as auth tokens or cache hints. This context is available in link options, interceptors, plugins, and other extensibility points. + +```ts +interface ClientContext { + token?: string +} + +const link = new RPCLink({ + headers: ({ context }) => ({ + authorization: context?.token ? `Bearer ${context.token}` : undefined, + }), + interceptors: [ + async ({ next, context }) => { + console.log('Client context:', context) + return await next() + }, + ], +}) +``` + +::: info +Pass `ClientContext` when creating the client, then provide context on each call as needed: + +```ts +// if you are using the contract-first approach +const client: RouterContractClient = createORPCClient(link) + +// if you are using the standard approach +const client: RouterClient = createORPCClient(link) + +const output = await client.someProcedure(input, { + context: { + token: 'abc123', + }, +}) +``` + +::: + +## URL and Header Options + +Use `origin`, `url`, and `headers` to control request destination and headers. + +- `origin`: Server protocol and domain. Omit in the browser to use the current origin. +- `url`: Usually a path prefix like `/api`. May include query params that are added to every request. +- `headers`: Headers sent with every request, such as auth or trace IDs. Keys should be lowercase. + +```ts +const link = new RPCLink({ + origin: 'https://api.example.com', + url: '/rpc?v=2', + headers: { + authorization: `Bearer ${getAuthToken()}`, + }, +}) +``` + +::: info +Each option can also be a function to dynamically customize values per request. For example, routing to a different `origin` based on the procedure path, or injecting headers from client context: + +```ts +const link = new RPCLink({ + origin: ({ path, context }) => { + if (path[0] === 'internal') { + return 'https://internal.example.com' + } + + return 'https://api.example.com' + }, + headers: ({ context }) => ({ + authorization: context?.token ? `Bearer ${context.token}` : undefined, + }), +}) +``` + +::: + +## Interceptors + +Interceptors let you observe or change different stages of an RPC request. Common use cases include logging, retries, auth, batching, and transport customization. + +### Interceptors + +Interceptors run around the entire call, including input encoding, transport, and response decoding. Use them when you need access to the path, input, output, or error. + +```ts +const link = new RPCLink({ + interceptors: [ + async ({ next, path, input }) => { + console.time(path.join('.')) + + try { + const output = await next() + return output + } + catch (err) { + console.error(`${path.join('.')}:`, err) + throw err + } + finally { + console.timeEnd(path.join('.')) + } + }, + ], +}) +``` + +### Transport Interceptors + +Interceptors run after input encoding and before response decoding. Use them to inspect or rewrite the request. + +```ts +const link = new RPCLink({ + transportInterceptors: [ + async (options) => { + const response = await options.next({ + ...options, + request: { + ...options.request, + headers: { + ...options.request.headers, + 'x-request-id': crypto.randomUUID(), + }, + }, + }) + + return response + }, + ], +}) +``` + +### Adapter Interceptors + +Some `RPCLink` implementations also support adapter-specific interceptors. The fetch adapter exposes `fetchInterceptors`, which run right before `fetch` and give you access to the final `url` and `RequestInit`. + +```ts +const link = new RPCLink({ + fetchInterceptors: [ + async (options) => { + const response = await options.next({ + ...options, + init: { + ...options.init, + credentials: 'include', + }, + }) + + return response + }, + ], +}) +``` + +::: info +This example uses the fetch adapter. For other adapters, refer to their JSDoc or adapter-specific documentation. +::: + +## Plugins + +Plugins package reusable interceptors. For example, [Retry After Plugin](/docs/plugins/retry-after) adds retry behavior based on the `retry-after` response header. + +```ts +const link = new RPCLink({ + plugins: [ + new RetryAfterLinkPlugin(), + ], +}) +``` + +## Custom Serializer + +`RPCLink` uses a built-in serializer that supports many native types. Provide a custom serializer when you need to extend or override the default behavior. For more details, see [RPC Serializer](/docs/rpc/serializer). + +```ts +const link = new RPCLink({ + serializer: new RPCSerializer({ + handlers: { + // ...custom handlers + }, + }), +}) +``` + +## Request Method + +`RPCLink` sends requests with `POST` by default. Use `method` to choose the method per call. + +```ts +type ClientContext = { + cache?: RequestCache +} + +const link = new RPCLink({ + url: '/rpc', + method: ({ context }, path) => { + if (context.cache) { + return 'GET' + } + + if (path.at(-1)?.match(/^(?:get|find|list|search)(?:[A-Z].*)?$/)) { + return 'GET' + } + + return 'POST' + }, + fetch: (url, init, { context }) => { + return fetch(url, { + ...init, + cache: context.cache, + }) + }, +}) +``` + +## Event Stream Options + +Configure how [event iterators](/docs/event-iterator) are streamed to the server. Available options depend on the adapter. For example, the fetch adapter supports: + +```ts +const link = new RPCLink({ + toFetchBody: { + eventStream: { + initialComment: { + /** + * If true, an initial comment is sent immediately upon stream start to flush headers. + * This allows the receiving side to establish the connection without waiting for the first event. + * + * @default true + */ + enabled: true, + /** + * The content of the initial comment sent upon stream start. Must not include newline characters. + * + * @default '' + */ + comment: '', + }, + keepAlive: { + /** + * If true, a ping comment is sent periodically to keep the connection alive. + * + * @default true + */ + enabled: true, + /** + * Interval (in milliseconds) between ping comments sent after the last event. + * + * @default 5000 + */ + interval: 5000, + /** + * The content of the ping comment. Must not include newline characters. + * + * @default '' + */ + comment: '', + }, + /** + * If true, a `close` event is sent even when the iterator completes with `undefined`. + * When the iterator returns a value, a `close` event is always emitted regardless of this setting. + * + * @default true + */ + emptyCloseEventEnabled: true, + }, + }, +}) +``` + +## Lifecycle + +TODO: add lifecycle diagram diff --git a/apps/content/docs/rpc/protocol.md b/apps/content/docs/rpc/protocol.md new file mode 100644 index 000000000..a2eb7aaaf --- /dev/null +++ b/apps/content/docs/rpc/protocol.md @@ -0,0 +1,136 @@ +# RPC Protocol + +The RPC protocol is a lightweight protocol for remote procedure calls. It supports more native types than plain JSON and is used by [RPC Handler](/docs/rpc/handler) and [RPC Link](/docs/rpc/link). + +## Serializer + +Most of the protocol's flexibility comes from its serializer. In addition to JSON-compatible values, it supports native types such as `Date`, `BigInt`, `RegExp`, `URL`, `Set`, `Map`, `Blob`, `File`, `AsyncIteratorObject`, and `ReadableStream`. To learn more, including how to extend it, see [RPC Serializer](/docs/rpc/serializer). + + + +## Routing + +The request `pathname` identifies which procedure to call. + +```bash +curl https://example.com/rpc/planet/create +``` + +This calls the `planet.create` procedure when `/rpc` is the prefix: + +```ts +const router = { + planet: { + create: os.handler(() => {}) // [!code highlight] + } +} +``` + +## Sending Input + +You can use any HTTP method. Send input in the query string or request body, depending on the method. + +::: info +Request payloads depend on the serializer and are not plain JSON. Learn more in [RPC Serializer Format](/docs/rpc/serializer#serialization-format). +::: + +### Query String + +```ts +const url = new URL('https://example.com/rpc/planet/create') + +url.searchParams.append('data', JSON.stringify({ + json: { + name: 'Earth', + detached_at: '2022-01-01T00:00:00.000Z' + }, + meta: [['date', 'detached_at']] +})) + +const response = await fetch(url) +``` + +### Request Body + +```bash +curl -X POST https://example.com/rpc/planet/create \ + -H 'Content-Type: application/json' \ + -d '{ + "json": { + "name": "Earth", + "detached_at": "2022-01-01T00:00:00.000Z" + }, + "meta": [["date", "detached_at"]] + }' +``` + +### With Files + +```ts +const form = new FormData() + +form.set('data', JSON.stringify({ + json: { + name: 'Earth', + thumbnail: {}, + images: [{}], + }, + maps: [['thumbnail'], ['images', 0]] +})) + +form.set('0', new Blob([''], { type: 'image/png' })) +form.set('1', new Blob([''], { type: 'image/png' })) + +const response = await fetch('https://example.com/rpc/planet/create', { + method: 'POST', + body: form +}) +``` + +## Success Response + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "json": { + "id": "1", + "name": "Earth", + "detached_at": "2022-01-01T00:00:00.000Z" + }, + "meta": [["bigint", "id"], ["date", "detached_at"]] +} +``` + +A successful response uses an HTTP status code in the `200-299` range and returns the procedure output. + +::: info +Response bodies depend on the serializer and are not plain JSON. Learn more in [RPC Serializer Format](/docs/rpc/serializer#serialization-format). +::: + +## Error Response + +```http +HTTP/1.1 500 Internal Server Error +Content-Type: application/json + +{ + "json": { + "defined": false, + "inferable": false, + "code": "INTERNAL_SERVER_ERROR", + "message": "Internal server error", + "data": { + "id": "1234567890" + } + }, + "meta": [["bigint", "data", "id"]] +} +``` + +An error response uses an HTTP status code in the `400-599` range and returns an `ORPCError` object. + +::: info +Response bodies depend on the serializer and are not plain JSON. Learn more in [RPC Serializer Format](/docs/rpc/serializer#serialization-format). +::: diff --git a/apps/content/docs/rpc/serializer.md b/apps/content/docs/rpc/serializer.md new file mode 100644 index 000000000..b6100eaa1 --- /dev/null +++ b/apps/content/docs/rpc/serializer.md @@ -0,0 +1,182 @@ +# RPC Serializer + +RPC Serializers handle the serialization and deserialization of data sent between the client and server. They allow you to support complex data types beyond plain JSON, such as `Date`, `BigInt`, `Set`, and even custom classes. + +## Supported Data Types + +`RPCSerializer` supports the following types by default: + +| Type | Handler key | Notes | +| ---------------------------------------- | ----------- | ----------------------------- | +| **string** | | | +| **number** | | | +| **NaN** | `nan` | | +| **boolean** | | | +| **null** | | | +| **undefined** | `undefined` | Ignore `undefined` properties | +| **Date** | `date` | Includes `Invalid Date`. | +| **BigInt** | `bigint` | | +| **RegExp** | `regexp` | | +| **URL** | `url` | | +| **Record (object)** | | `toJSON` methods are ignored | +| **Array** | | | +| **Set** | `set` | | +| **Map** | `map` | | +| **Blob** | | Unsupported in Event Iterator | +| **File** | None | Unsupported in Event Iterator | +| **Event Iterator (AsyncIteratorObject)** | | Only at the root level | +| **ReadableStream\** | | Only at the root level | + + + +## Custom Serializers + +Add custom handlers with unique keys to support additional types, or reuse a built-in key to override the default behavior. + +```ts twoslash +class Person { + constructor( + public name: string, + public age: number, + ) {} +} +// ---cut--- +import { RPCSerializer } from '@orpc/client' + +const serializer = new RPCSerializer({ + handlers: { + person: { // <- add support for Person + condition: v => v instanceof Person, + serialize: (v: Person) => ({ name: v.name, age: v.age }), + deserialize: v => new Person(v.name, v.age), + }, + date: { // <- replace the default Date handler + condition: v => v instanceof Date, + serialize: (v: Date) => v.getTime(), + deserialize: v => new Date(v), + }, + }, +}) +``` + +::: info Use a custom serializer with RPCHandler and RPCLink + +```ts +const handler = new RPCHandler(router, { + serializer, +}) + +const link = new RPCLink({ + serializer, +}) +``` + +::: + +## Serialization Format + +In most cases, serialized data includes two optional fields: `json` and `meta`. `json` contains JSON-serializable data. `meta` contains the metadata needed to deserialize values. + +::: info +`meta` is stored in the format `[handler: string, ...path: (string | number)[]]`. + +- **handler**: The handler key used for serialization (see [Supported Data Types](#supported-data-types)). +- **path**: Path to the value inside `json`. + +::: + +```json +{ + "json": { + "name": "John", + "age": 30, + "createdAt": "2024-01-01T00:00:00.000Z" + }, + "meta": [ + ["date", "createdAt"] + ] +} +``` + +### With Files + +If the data includes `Blob` or `File`, the serializer returns a `FormData` object. The `data` field contains a JSON string with `json`, `meta`, and `maps`, and the remaining fields contain the file parts. + +::: info +`maps` is stored in the format `[...path: (string | number)[]]`, and its order corresponds to the file parts in the `FormData`. + +For example, `[['thumbnail'], ['images', 0]]` means the first file part corresponds to `json.thumbnail` at `form.get('0')`, and the second file part corresponds to `json.images[0]` at `form.get('1')`. +::: + +```ts +const form = new FormData() + +form.set('data', JSON.stringify({ + json: { + name: 'Earth', + thumbnail: {}, + images: [{}], + createdAt: '2022-01-01T00:00:00.000Z' + }, + meta: [['date', 'createdAt']], + maps: [['thumbnail'], ['images', 0]] +})) + +form.set('0', new Blob([''], { type: 'image/png' })) +form.set('1', new Blob([''], { type: 'image/png' })) +``` + +### Direct File + +If the entire data is a single `Blob` or `File`, it can be sent as-is without wrapping in `FormData`. + +```http +HTTP/1.1 200 OK +Content-Type: image/png +Content-Disposition: attachment; filename="earth.png" +Content-Length: 12345 +Standard-Server: file + + +``` + +::: info +If the receiver mistakenly handles this payload as a regular (non-file) body, set the `standard-server` header to help the receiver detect the actual data type and handle it correctly. Learn more about this header in the [Standard Server Documentation](https://github.com/middleapi/standardserver#resolving-body). +::: + +### Event Iterator (AsyncIteratorObject) + +When the output is an event iterator (`AsyncIteratorObject`), it is sent as a [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) stream. Each event contains one serialized chunk of data. + +```http +HTTP/1.1 200 OK +Content-Type: text/event-stream + +event: message +data: {"json":{"name":"John","createdAt":"2024-01-01T00:00:00.000Z"},"meta":[["date","createdAt"]]} + +event: message +data: {"json":{"name":"Jane","createdAt":"2024-01-02T00:00:00.000Z"},"meta":[["date","createdAt"]]} +``` + +### ReadableStream\ + +A `ReadableStream` is passed through as-is and streamed as binary data. + +```http +HTTP/1.1 200 OK +Content-Type: application/octet-stream +Standard-Server: octet-stream + + + +``` + +::: info +If the receiver mistakenly handles this payload as a regular (non-stream) body, set the `standard-server` header to help the receiver detect the actual data type and handle it correctly. Learn more about this header in the [Standard Server Documentation](https://github.com/middleapi/standardserver#resolving-body). +::: + +## Learn More + +The serializer is a small, self-contained module, making it easy to understand. +To explore its behavior in detail, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/client/src/rpc-serializer.ts). diff --git a/apps/content/docs/server-action.md b/apps/content/docs/server-action.md deleted file mode 100644 index b1498d09c..000000000 --- a/apps/content/docs/server-action.md +++ /dev/null @@ -1,316 +0,0 @@ ---- -title: Server Action -description: Integrate oRPC procedures with React Server Actions ---- - -# Server Action - -React [Server Actions](https://react.dev/reference/rsc/server-functions) let client components invoke asynchronous server functions. With oRPC, you simply append the `.actionable` modifier to enable Server Action compatibility. - -## Server Side - -Define your procedure with `.actionable` for Server Action support. - -```ts twoslash -import { onError, onSuccess, os } from '@orpc/server' -import * as z from 'zod' -// ---cut--- -'use server' - -import { redirect } from 'next/navigation' - -export const ping = os - .input(z.object({ name: z.string() })) - .handler(async ({ input }) => `Hello, ${input.name}`) - .actionable({ - context: async () => ({}), // Optional: provide initial context if needed - interceptors: [ - onSuccess(async output => redirect(`/some-where`)), - onError(async error => console.error(error)), - ], - }) -``` - -:::tip -We recommend using [Execution Context](/docs/context#execution-context) instead of [Initial Context](/docs/context#initial-context) when working with Server Actions. -::: - -:::warning -Special errors such as `redirect`, `notFound`, and similar are **only supported in [Next.js](https://nextjs.org/) and [TanStack Start](https://tanstack.com/start/latest)** at the moment. -::: - -## Client Side - -On the client, import and call your procedure as follows: - -```tsx -'use client' - -import { ping } from './actions' - -export function MyComponent() { - const [name, setName] = useState('') - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault() - const [error, data] = await ping({ name }) - console.log(error, data) - } - - return ( -
- setName(e.target.value)} /> - -
- ) -} -``` - -This approach seamlessly integrates server-side procedures with client components via Server Actions. - -## Type‑Safe Error Handling - -The `.actionable` modifier supports type-safe error handling with a JSON-like error object. - -```ts twoslash -import { os } from '@orpc/server' -import * as z from 'zod' - -export const someAction = os - .input(z.object({ name: z.string() })) - .errors({ - SOME_ERROR: { - message: 'Some error message', - data: z.object({ some: z.string() }), - }, - }) - .handler(async ({ input }) => `Hello, ${input.name}`) - .actionable() -// ---cut--- -'use client' - -const [error, data] = await someAction({ name: 'John' }) - -if (error) { - if (error.defined) { - console.log(error.data) - // ^ Typed error data - } - // Handle unknown errors -} -else { - // Handle success - console.log(data) -} -``` - -## `@orpc/react` Package - -The `@orpc/react` package offers utilities to integrate oRPC with React and React Server Actions. - -### Installation - -::: code-group - -```sh [npm] -npm install @orpc/react@latest -``` - -```sh [yarn] -yarn add @orpc/react@latest -``` - -```sh [pnpm] -pnpm add @orpc/react@latest -``` - -```sh [bun] -bun add @orpc/react@latest -``` - -```sh [deno] -deno add npm:@orpc/react@latest -``` - -::: - -### `useServerAction` Hook - -The `useServerAction` hook simplifies invoking server actions in React. - -```tsx twoslash -import * as React from 'react' -import { os } from '@orpc/server' -import * as z from 'zod' - -export const someAction = os - .input(z.object({ name: z.string() })) - .errors({ - SOME_ERROR: { - message: 'Some error message', - data: z.object({ some: z.string() }), - }, - }) - .handler(async ({ input }) => `Hello, ${input.name}`) - .actionable() -// ---cut--- -'use client' - -import { useServerAction } from '@orpc/react/hooks' -import { isDefinedError, onError } from '@orpc/client' - -export function MyComponent() { - const { execute, data, error, status } = useServerAction(someAction, { - interceptors: [ - onError((error) => { - if (isDefinedError(error)) { - console.error(error.data) - // ^ Typed error data - } - }), - ], - }) - - const action = async (form: FormData) => { - const name = form.get('name') as string - execute({ name }) - } - - return ( -
- - - {status === 'pending' &&

Loading...

} -
- ) -} -``` - -### `useOptimisticServerAction` Hook - -The `useOptimisticServerAction` hook enables optimistic UI updates while a server action executes. This provides immediate visual feedback to users before the server responds. - -```tsx -import { useOptimisticServerAction } from '@orpc/react/hooks' -import { onSuccessDeferred } from '@orpc/react' - -export function MyComponent() { - const [todos, setTodos] = useState([]) - const { execute, optimisticState } = useOptimisticServerAction(someAction, { - optimisticPassthrough: todos, - optimisticReducer: (currentState, newTodo) => [...currentState, newTodo], - interceptors: [ - onSuccessDeferred(({ data }) => { - setTodos(prevTodos => [...prevTodos, data]) - }), - ], - }) - - const handleSubmit = (form: FormData) => { - const todo = form.get('todo') as string - execute({ todo }) - } - - return ( -
-
    - {optimisticState.map(todo => ( -
  • {todo.todo}
  • - ))} -
-
- - -
-
- ) -} -``` - -:::info -The `onSuccessDeferred` interceptor defers execution, useful for updating states. -::: - -### `createFormAction` Utility - -The `createFormAction` utility accepts a [procedure](/docs/procedure) and returns a function to handle form submissions. It uses [Bracket Notation](/docs/openapi/bracket-notation) to deserialize form data. - -```tsx -import { createFormAction } from '@orpc/react' - -const dosomething = os - .input( - z.object({ - user: z.object({ - name: z.string(), - age: z.coerce.number(), - }), - }) - ) - .handler(({ input }) => { - console.log('Form action called!') - console.log(input) - }) - -export const redirectSomeWhereForm = createFormAction(dosomething, { - interceptors: [ - onSuccess(async () => { - redirect('/some-where') - }), - ], -}) - -export function MyComponent() { - return ( -
- - - -
- ) -} -``` - -By moving the `redirect('/some-where')` logic into `createFormAction` rather than the procedure, you enhance the procedure's reusability beyond Server Actions. - -::: info -When using `createFormAction`, any `ORPCError` with a status of `401`, `403`, or `404` is automatically converted into the corresponding Next.js error responses: [unauthorized](https://nextjs.org/docs/app/api-reference/functions/unauthorized), [forbidden](https://nextjs.org/docs/app/api-reference/functions/forbidden), and [not found](https://nextjs.org/docs/app/api-reference/functions/not-found). -::: - -### Form Data Utilities - -The `@orpc/react` package re-exports [Form Data Helpers](/docs/helpers/form-data) for seamless form data parsing and validation error handling with [bracket notation](/docs/openapi/bracket-notation) support. - -```tsx -import { getIssueMessage, parseFormData } from '@orpc/react' - -export function MyComponent() { - const { execute, data, error, status } = useServerAction(someAction) - - return ( -
{ execute(parseFormData(form)) }}> - - - - - - - -
- ) -} -``` diff --git a/apps/content/learn-and-contribute/mini-orpc/beyond-the-basics.md b/apps/content/learn-and-contribute/mini-orpc/beyond-the-basics.md index c306655a4..029a6852f 100644 --- a/apps/content/learn-and-contribute/mini-orpc/beyond-the-basics.md +++ b/apps/content/learn-and-contribute/mini-orpc/beyond-the-basics.md @@ -37,7 +37,7 @@ You can implement these features in any order. Pick the ones you find interestin - [ ] Node HTTP Adapter ([reference](https://github.com/middleapi/orpc/tree/main/packages/standard-server-node)) - [ ] Peer Adapter (WebSocket, MessagePort, etc.) ([reference](https://github.com/middleapi/orpc/tree/main/packages/standard-server-peer)) -- [ ] [Contract First](/docs/contract-first/define-contract) Support +- [ ] [Contract First](/docs/contract/procedure) Support - [ ] Contract Builder ([reference](https://github.com/middleapi/orpc/blob/main/packages/contract/src/builder.ts)) - [ ] Contract Implementer ([reference](https://github.com/middleapi/orpc/blob/main/packages/server/src/implementer.ts)) diff --git a/apps/content/package.json b/apps/content/package.json index 62f97bc62..cb728d789 100644 --- a/apps/content/package.json +++ b/apps/content/package.json @@ -8,50 +8,39 @@ "serve": "vitepress serve" }, "devDependencies": { - "@ai-sdk/google": "^3.0.43", - "@ai-sdk/react": "^3.0.118", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/sdk-node": "^0.213.0", - "@opentelemetry/sdk-trace-web": "^2.6.0", - "@orpc/ai-sdk": "workspace:*", + "@opentelemetry/instrumentation": "^0.219.0", + "@opentelemetry/sdk-node": "^0.219.0", + "@opentelemetry/sdk-trace-web": "^2.8.0", "@orpc/arktype": "workspace:*", "@orpc/client": "workspace:*", "@orpc/contract": "workspace:*", - "@orpc/experimental-publisher": "workspace:*", - "@orpc/experimental-ratelimit": "workspace:*", - "@orpc/experimental-react-swr": "workspace:*", + "@orpc/evlog": "workspace:*", "@orpc/openapi": "workspace:*", - "@orpc/openapi-client": "workspace:*", - "@orpc/otel": "workspace:*", - "@orpc/react": "workspace:*", - "@orpc/react-query": "workspace:*", + "@orpc/opentelemetry": "workspace:*", + "@orpc/pino": "workspace:*", + "@orpc/publisher": "workspace:*", + "@orpc/ratelimit": "workspace:*", "@orpc/server": "workspace:*", "@orpc/shared": "workspace:*", "@orpc/valibot": "workspace:*", - "@orpc/vue-colada": "workspace:*", - "@orpc/vue-query": "workspace:*", "@orpc/zod": "workspace:*", - "@pinia/colada": "^0.21.7", - "@sentry/node": "^10.43.0", - "@shikijs/vitepress-twoslash": "^3.22.0", - "@tanstack/react-query": "^5.90.21", - "@tanstack/solid-query": "^5.90.26", - "@tanstack/svelte-query": "^6.1.0", - "@tanstack/vue-query": "^5.92.9", - "@types/node": "^22.19.3", - "ai": "^6.0.116", + "@shikijs/vitepress-twoslash": "^4.2.0", + "@tanstack/react-query": "^5.101.0", + "@tanstack/solid-query": "^5.101.0", + "@tanstack/svelte-query": "^6.1.34", + "@tanstack/vue-query": "^5.101.0", + "@types/node": "^25.9.3", + "effect": "^3.21.3", "markdown-it-task-lists": "^2.1.1", - "mermaid": "^11.13.0", - "openai": "^6.27.0", - "pinia": "^3.0.4", + "mermaid": "^11.15.0", + "openai": "^6.44.0", "superjson": "^2.2.6", - "svelte": "^5.53.11", "vitepress": "1.6.4", - "vitepress-plugin-group-icons": "^1.7.1", - "vitepress-plugin-llms": "^1.11.0", + "vitepress-plugin-group-icons": "^1.7.5", + "vitepress-plugin-llms": "^1.13.1", "vitepress-plugin-mermaid": "^2.0.17", "vitepress-plugin-shiki-twoslash": "^0.0.6", - "vue": "^3.5.30", - "zod": "^4.3.6" + "vue": "^3.5.38", + "zod": "^4.4.3" } } diff --git a/apps/content/public/_redirects b/apps/content/public/_redirects new file mode 100644 index 000000000..3917e5f8d --- /dev/null +++ b/apps/content/public/_redirects @@ -0,0 +1,4 @@ +/docs /docs/getting-started 301 +/docs/openapi /docs/openapi/getting-started 301 +/sponsor https://github.com/sponsors/unnoq 301 +/docs/integrations/nextjs /docs/integrations/next 301 \ No newline at end of file diff --git a/apps/content/shared/common-error-status-map-table.md b/apps/content/shared/common-error-status-map-table.md new file mode 100644 index 000000000..9b99de5d2 --- /dev/null +++ b/apps/content/shared/common-error-status-map-table.md @@ -0,0 +1,24 @@ +| Error Code | HTTP Status Code | +| ---------------------- | ---------------: | +| BAD_REQUEST | 400 | +| UNAUTHORIZED | 401 | +| PAYMENT_REQUIRED | 402 | +| FORBIDDEN | 403 | +| NOT_FOUND | 404 | +| METHOD_NOT_SUPPORTED | 405 | +| NOT_ACCEPTABLE | 406 | +| TIMEOUT | 408 | +| CONFLICT | 409 | +| GONE | 410 | +| PRECONDITION_FAILED | 412 | +| PAYLOAD_TOO_LARGE | 413 | +| UNSUPPORTED_MEDIA_TYPE | 415 | +| UNPROCESSABLE_CONTENT | 422 | +| PRECONDITION_REQUIRED | 428 | +| TOO_MANY_REQUESTS | 429 | +| CLIENT_CLOSED_REQUEST | 499 | +| INTERNAL_SERVER_ERROR | 500 | +| NOT_IMPLEMENTED | 501 | +| BAD_GATEWAY | 502 | +| SERVICE_UNAVAILABLE | 503 | +| GATEWAY_TIMEOUT | 504 | diff --git a/apps/content/shared/common-plugin-handler-compatibility.md b/apps/content/shared/common-plugin-handler-compatibility.md new file mode 100644 index 000000000..3cf06d6d3 --- /dev/null +++ b/apps/content/shared/common-plugin-handler-compatibility.md @@ -0,0 +1,3 @@ +::: info +The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one. +::: diff --git a/apps/content/shared/common-plugin-link-compatibility.md b/apps/content/shared/common-plugin-link-compatibility.md new file mode 100644 index 000000000..7577b5f87 --- /dev/null +++ b/apps/content/shared/common-plugin-link-compatibility.md @@ -0,0 +1,3 @@ +::: info +The `link` can be any supported oRPC link, such as [RPCLink](/docs/rpc/link), [OpenAPILink](/docs/openapi/link), or a custom one. +::: diff --git a/apps/content/shared/planet.ts b/apps/content/shared/planet.ts index 11fdf7695..ded398fdb 100644 --- a/apps/content/shared/planet.ts +++ b/apps/content/shared/planet.ts @@ -11,11 +11,6 @@ export const PlanetSchema = z.object({ }) export const listPlanetContract = oc - .route({ - method: 'GET', - path: '/planets', - summary: 'List all planets', - }) .input( z.object({ limit: z.number().int().min(1).max(100).optional(), @@ -25,20 +20,10 @@ export const listPlanetContract = oc .output(z.array(PlanetSchema)) export const findPlanetContract = oc - .route({ - method: 'GET', - path: '/planets/{id}', - summary: 'Find a planet', - }) .input(PlanetSchema.pick({ id: true })) .output(PlanetSchema) export const createPlanetContract = oc - .route({ - method: 'POST', - path: '/planets', - summary: 'Create a planet', - }) .input(PlanetSchema.omit({ id: true })) .output(PlanetSchema) @@ -65,4 +50,4 @@ export const createPlanet = os.planet.create export const router = os.router({ planet: { list: listPlanet, find: findPlanet, create: createPlanet } }) -export const orpc = {} as RouterClient +export const client = {} as RouterClient diff --git a/apps/content/shared/router-keys-compatibility-warning.md b/apps/content/shared/router-keys-compatibility-warning.md new file mode 100644 index 000000000..73a3ed456 --- /dev/null +++ b/apps/content/shared/router-keys-compatibility-warning.md @@ -0,0 +1,3 @@ +::: warning +For compatibility, do not use these router keys: `then`, `bind`, `valueOf`, `toString`, `toJSON`. +::: diff --git a/apps/content/shared/standard-server-cors-warning.md b/apps/content/shared/standard-server-cors-warning.md new file mode 100644 index 000000000..84dba309f --- /dev/null +++ b/apps/content/shared/standard-server-cors-warning.md @@ -0,0 +1,12 @@ +::: warning +To better support `Blob`, `File`, and `ReadableStream` at the root level in cross-origin scenarios, +extend your [CORS allowlist](https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_response_header) to allow clients to send and receive the `Content-Disposition` and `Standard-Server` headers. Learn more in the [Standard Server documentation](https://github.com/middleapi/standardserver#resolving-body). If you use the [CORS Plugin](/docs/plugins/cors), include them in `allowHeaders` and `exposeHeaders`: + +```ts +const cors = new CORSHandlerPlugin({ + allowHeaders: ['Content-Disposition', 'Standard-Server'], + exposeHeaders: ['Content-Disposition', 'Standard-Server'], +}) +``` + +::: diff --git a/apps/content/vercel.json b/apps/content/vercel.json new file mode 100644 index 000000000..169883f99 --- /dev/null +++ b/apps/content/vercel.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "cleanUrls": true, + "headers": [ + { + "source": "/assets/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "max-age=31536000, immutable" + } + ] + } + ] +} diff --git a/eslint.config.js b/eslint.config.js index a686230d9..a7cb5214a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,6 +7,7 @@ export default antfu({ rules: { 'pnpm/yaml-enforce-settings': 'off', 'yaml/sort-keys': 'off', + 'jsdoc/no-defaults': 'off', }, }, { plugins: { ban: pluginBan }, @@ -27,6 +28,10 @@ export default antfu({ name: 'decodeURIComponent', message: 'decodeURIComponent can throw an error, use tryDecodeURIComponent instead', }, + { + name: ['Reflect', 'get'], + message: 'Use getOrBind instead', + }, ], 'no-restricted-imports': ['error', { patterns: [{ @@ -34,6 +39,7 @@ export default antfu({ '/json-schema-typed', '/openapi-types', '/@standard-schema/spec', + '/@hey-api/spec-types', '/compression', ], message: 'Please import from @orpc/* instead', @@ -88,6 +94,7 @@ export default antfu({ 'unicorn/prefer-type-error': 'off', 'antfu/no-import-node-modules-by-path': 'off', 'no-restricted-globals': 'off', + 'import/no-duplicates': 'off', }, }, { files: ['apps/content/examples/**'], diff --git a/knip.json b/knip.json deleted file mode 100644 index 778cac84b..000000000 --- a/knip.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://unpkg.com/knip@latest/schema.json", - "ignore": ["*/*/*.ts"] -} diff --git a/package.json b/package.json index dd133e4ed..9aff25d0c 100644 --- a/package.json +++ b/package.json @@ -1,62 +1,42 @@ { "name": "@orpc/monorepo", "type": "module", - "version": "1.14.6", + "version": "1.13.4", "private": true, - "packageManager": "pnpm@10.28.1", + "packageManager": "pnpm@11.8.0", "scripts": { "prepare": "simple-git-hooks", - "build": "pnpm run -r build", - "build:packages": "pnpm --filter=\"./packages/*\" run -r build", - "preview": "pnpm run --parallel preview", - "type:check": "pnpm run -r type:check && pnpm run -r type:check:test && tsc --noEmit", + "type:check": "pnpm run -r type:check && tsc", "test": "vitest run", - "test:watch": "vitest watch", - "test:coverage": "vitest run --coverage --coverage.include='packages/*/src/**'", - "test:ui": "vitest --ui --coverage --coverage.include='packages/*/src/**'", + "test:coverage": "vitest run --coverage", "lint": "eslint --max-warnings=0 .", - "lint:fix": "pnpm run lint --fix", - "sherif": "sherif", - "sherif:fix": "pnpm run sherif --fix", - "knip": "knip --production", - "knip:fix": "pnpm run knip --fix --allow-remove-files", - "sponsors:sync": "node scripts/sync-sponsor.ts && eslint --max-warnings=0 --fix **/README.md apps/content/.vitepress/theme/sponsors.ts", - "packages:bump": "bumpp -r", - "packages:publish": "pnpm run build:packages && pnpm --filter='./packages/*' publish -r --access=public", - "packages:publish:commit": "pnpm run build:packages && pkg-pr-new publish './packages/*' --pnpm --compact --template './playgrounds/*'", - "packages:changelog:github": "changelogithub --draft" + "lint:fix": "eslint --max-warnings=0 --fix .", + "repo:fix": "sherif --fix", + "sponsors:sync": "node scripts/sync-sponsors.ts && eslint --max-warnings=0 --fix **/README.md apps/content/.vitepress/theme/sponsors.ts" }, "devDependencies": { - "@antfu/eslint-config": "^7.4.3", - "@cloudflare/vitest-pool-workers": "^0.13.0", - "@solidjs/testing-library": "^0.8.10", - "@sveltejs/vite-plugin-svelte": "^6.2.4", - "@testing-library/jest-dom": "^6.9.1", + "@antfu/eslint-config": "^9.0.0", + "@hono/node-server": "^2.0.4", "@testing-library/react": "^16.3.2", - "@testing-library/svelte": "^5.3.1", - "@testing-library/user-event": "^14.6.1", - "@types/better-sqlite3": "^7.6.13", - "@types/node": "^22.19.7", - "@vitest/coverage-v8": "^3.2.4", - "@vitest/ui": "^3.2.4", - "@vue/test-utils": "^2.4.6", - "better-sqlite3": "^12.6.2", - "bumpp": "^10.4.1", + "@types/node": "^25.9.3", + "@types/supertest": "^7.2.0", + "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^4.1.9", + "bumpp": "^11.1.0", "changelogithub": "^14.0.0", - "eslint": "^10.0.0", + "eslint": "^10.5.0", "eslint-plugin-ban": "^2.0.0", - "eslint-plugin-format": "^1.4.0", - "jsdom": "^28.1.0", - "knip": "^5.86.0", - "lint-staged": "^16.3.3", - "msw": "^2.12.10", - "pkg-pr-new": "^0.0.65", - "sherif": "^1.10.0", + "eslint-plugin-format": "^2.0.1", + "jsdom": "^29.1.1", + "lint-staged": "^17.0.7", + "pkg-pr-new": "^0.0.75", + "sherif": "^1.11.1", "simple-git-hooks": "^2.13.1", - "typescript": "~5.9.3", + "typescript": "^6.0.3", "unbuild": "^3.6.1", - "vite-plugin-solid": "^2.11.10", - "vitest": "^3.2.4" + "vite": "^8.0.16", + "vitest": "^4.1.9", + "ws": "^8.21.0" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged" diff --git a/packages/ai-sdk/README.md b/packages/ai-sdk/README.md deleted file mode 100644 index 8baff4ad9..000000000 --- a/packages/ai-sdk/README.md +++ /dev/null @@ -1,194 +0,0 @@ -
- oRPC logo -
- -

- - - -

Typesafe APIs Made Simple 🪄

- -**oRPC is a powerful combination of RPC and OpenAPI**, makes it easy to build APIs that are end-to-end type-safe and adhere to OpenAPI standards - ---- - -## Highlights - -- **🔗 End-to-End Type Safety**: Ensure type-safe inputs, outputs, and errors from client to server. -- **📘 First-Class OpenAPI**: Built-in support that fully adheres to the OpenAPI standard. -- **📝 Contract-First Development**: Optionally define your API contract before implementation. -- **🔍 First-Class OpenTelemetry**: Seamlessly integrate with OpenTelemetry for observability. -- **⚙️ Framework Integrations**: Seamlessly integrate with TanStack Query (React, Vue, Solid, Svelte, Angular), SWR, Pinia Colada, and more. -- **🚀 Server Actions**: Fully compatible with React Server Actions on Next.js, TanStack Start, and other platforms. -- **🔠 Standard Schema Support**: Works out of the box with Zod, Valibot, ArkType, and other schema validators. -- **🗃️ Native Types**: Supports native types like Date, File, Blob, BigInt, URL, and more. -- **⏱️ Lazy Router**: Enhance cold start times with our lazy routing feature. -- **📡 SSE & Streaming**: Enjoy full type-safe support for SSE and streaming. -- **🌍 Multi-Runtime Support**: Fast and lightweight on Cloudflare, Deno, Bun, Node.js, and beyond. -- **🔌 Extendability**: Easily extend functionality with plugins, middleware, and interceptors. - -## Documentation - -You can find the full documentation [here](https://orpc.dev). - -## Packages - -- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Build your API contract. -- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build your API or implement API contract. -- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API on the client with type-safety. -- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Generate OpenAPI specs and handle OpenAPI requests. -- [@orpc/otel](https://www.npmjs.com/package/@orpc/otel): [OpenTelemetry](https://opentelemetry.io/) integration for observability. -- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Deeply integrate oRPC with [NestJS](https://nestjs.com/). -- [@orpc/react](https://www.npmjs.com/package/@orpc/react): Utilities for integrating oRPC with React and React Server Actions. -- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): [TanStack Query](https://tanstack.com/query/latest) integration. -- [@orpc/experimental-react-swr](https://www.npmjs.com/package/@orpc/experimental-react-swr): [SWR](https://swr.vercel.app/) integration. -- [@orpc/vue-colada](https://www.npmjs.com/package/@orpc/vue-colada): Integration with [Pinia Colada](https://pinia-colada.esm.dev/). -- [@orpc/hey-api](https://www.npmjs.com/package/@orpc/hey-api): [Hey API](https://heyapi.dev/) integration. -- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): More schemas that [Zod](https://zod.dev/) doesn't support yet. -- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): OpenAPI spec generation from [Valibot](https://valibot.dev/). -- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): OpenAPI spec generation from [ArkType](https://arktype.io/). - -## `@orpc/ai-sdk` - -The [AI SDK](https://ai-sdk.dev/) integration for oRPC. - -## Sponsors - -If you find oRPC valuable and would like to support its development, you can do so here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). - -### 🏆 Platinum Sponsor - - - - - -
ScreenshotOne.com
ScreenshotOne.com
- -### 🥈 Silver Sponsor - - - - - -
村上さん
村上さん
- -### Generous Sponsors - - - - - -
LN Markets
LN Markets
- -### Sponsors - - - - - - - - - - - - - - - - - - - - - - - - -
Reece McDonald
Reece McDonald
nk
nk
supastarter
supastarter
Dexter Miguel
Dexter Miguel
herrfugbaum
herrfugbaum
Ryota Murakami
Ryota Murakami
David Cramer
David Cramer
Valerii Petryniak
Valerii Petryniak
Valerii Strilets
Valerii Strilets
Kyle Mistele
Kyle Mistele
Andrew Peters
Andrew Peters
Ryan Vogel
Ryan Vogel
christ12938
christ12938
Peter Adam
Peter Adam
Ryan Soderberg
Ryan Soderberg
shota
shota
- -### Backers - - - - - - - - - - - - - - - - - - - - - - - - - -
David Walsh
David Walsh
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
Alex
Alex
nattstack
nattstack
Andrey Gubanov
Andrey Gubanov
- -### Past Sponsors - -

- Maxie - Stijn Timmer - あわわわとーにゅ - Zuplo - motopods - Francisco Hermida - Théo LUDWIG - Abhay Ramesh - shr.ink oü - 0x4e32 - Ryuz - happyboy - yicchi - Saksham - Roman Hrynevych - rokitg - Omar Khatib - Yu-Sabo - Bapusaheb Patil - grim - Nelson Lai - Lê Cao Nguyên - Robert Soriano - SKostyukovich - Fabworks - Novak Antonijevic - Laduni Estu Syalwa - Chen, Zhi-Yuan - Illarion Koperski - Anees Iqbal - Sefa Eyeoglu - Adam Tkaczyk - plancraft -

- -## License - -Distributed under the MIT License. See [LICENSE](https://github.com/middleapi/orpc/blob/main/LICENSE) for more information. diff --git a/packages/ai-sdk/package.json b/packages/ai-sdk/package.json deleted file mode 100644 index dee782ffc..000000000 --- a/packages/ai-sdk/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@orpc/ai-sdk", - "type": "module", - "version": "1.14.6", - "license": "MIT", - "homepage": "https://orpc.dev", - "repository": { - "type": "git", - "url": "git+https://github.com/middleapi/orpc.git", - "directory": "packages/ai-sdk" - }, - "keywords": [ - "ai-sdk", - "orpc" - ], - "sideEffects": false, - "publishConfig": { - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" - } - } - }, - "exports": { - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], - "scripts": { - "build": "unbuild", - "build:watch": "pnpm run build --watch", - "type:check": "tsc -b" - }, - "peerDependencies": { - "ai": ">=5.0.76" - }, - "dependencies": { - "@orpc/client": "workspace:*", - "@orpc/contract": "workspace:*", - "@orpc/server": "workspace:*", - "@orpc/shared": "workspace:*" - }, - "devDependencies": { - "ai": "^6.0.116", - "zod": "^4.3.6" - } -} diff --git a/packages/ai-sdk/src/index.test.ts b/packages/ai-sdk/src/index.test.ts deleted file mode 100644 index e000a730f..000000000 --- a/packages/ai-sdk/src/index.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -it('exports createTool', async () => { - expect(Object.keys(await import('./index'))).toContain('createTool') -}) diff --git a/packages/ai-sdk/src/index.ts b/packages/ai-sdk/src/index.ts deleted file mode 100644 index 18d45314b..000000000 --- a/packages/ai-sdk/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from './tool' - -export { - AsyncIteratorClass, - asyncIteratorToStream as eventIteratorToStream, - asyncIteratorToUnproxiedDataStream as eventIteratorToUnproxiedDataStream, - streamToAsyncIteratorClass as streamToEventIterator, -} from '@orpc/shared' diff --git a/packages/ai-sdk/src/tool.test-d.ts b/packages/ai-sdk/src/tool.test-d.ts deleted file mode 100644 index a1ee1dff2..000000000 --- a/packages/ai-sdk/src/tool.test-d.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { oc } from '@orpc/contract' -import { os } from '@orpc/server' -import { generateText, tool } from 'ai' -import { z } from 'zod' -import { createTool, implementTool } from './tool' - -describe('tool', () => { - it('throw on missing inputSchema is correct, because tool require inputSchema', () => { - tool({ - inputSchema: z.object({}), - }) - - // @ts-expect-error inputSchema is required - tool({}) - }) -}) - -describe('implementTool', () => { - it('can use as a tool', () => { - const contract = oc - .route({ - summary: 'Get the weather in a location', - }) - .input(z.object({ - location: z.string().describe('The location to get the weather for'), - })) - .output(z.object({ - location: z.string(), - temperature: z.number().describe('The temperature in Fahrenheit'), - })) - - const weatherTool = implementTool(contract, { - execute: async ({ location }) => ({ - location, - temperature: 72 + Math.floor(Math.random() * 21) - 10, - }), - }) - - void generateText({ - model: 'openai/gpt-4o', - tools: { - weather: weatherTool, - }, - prompt: 'What is the weather in San Francisco?', - }) - }) - - it('infer correct input & output', () => { - const contract = oc - .route({ - summary: 'Get the weather in a location', - }) - .input(z.object({ - stringToNumber: z.string().transform(val => Number(val)), - })) - .output(z.object({ - numberToBoolean: z.number().transform(val => Boolean(val)), - })) - - const tool = implementTool(contract, { - execute: async ({ stringToNumber }) => { - expectTypeOf(stringToNumber).toEqualTypeOf() - - return { - numberToBoolean: stringToNumber, - } - }, - }) - - const tool2 = implementTool(contract, { - // @ts-expect-error invalid numberToBoolean - execute: async ({ stringToNumber }) => { - return { - numberToBoolean: true, - } - }, - }) - }) -}) - -describe('createTool', () => { - it('can use as a tool', () => { - const procedure = os - .route({ - summary: 'Get the weather in a location', - }) - .input(z.object({ - location: z.string().describe('The location to get the weather for'), - })) - .output(z.object({ - location: z.string(), - temperature: z.number().describe('The temperature in Fahrenheit'), - })) - .handler(async ({ input }) => { - return { - location: input.location, - temperature: 72 + Math.floor(Math.random() * 21) - 10, - } - }) - - const weatherTool = createTool(procedure) - - void generateText({ - model: 'openai/gpt-4o', - tools: { - weather: weatherTool, - }, - prompt: 'What is the weather in San Francisco?', - }) - }) - - it('infer correct input & output', () => { - const procedure = os - .route({ - summary: 'Get the weather in a location', - }) - .input(z.object({ - stringToNumber: z.string().transform(val => Number(val)), - })) - .output(z.object({ - numberToBoolean: z.number().transform(val => Boolean(val)), - })) - .handler(async ({ input }) => { - return { - numberToBoolean: input.stringToNumber, - } - }) - - const tool = createTool(procedure, { - execute: async ({ stringToNumber }) => { - expectTypeOf(stringToNumber).toEqualTypeOf() - - return { - numberToBoolean: stringToNumber, - } - }, - }) - - const tool2 = createTool(procedure, { - // @ts-expect-error invalid numberToBoolean - execute: async ({ stringToNumber }) => { - return { - numberToBoolean: true, - } - }, - }) - }) - - it('require provide initial context if required', () => { - const procedure = os - .$context<{ authToken: string }>() - .input(z.object({ - location: z.string().describe('The location to get the weather for'), - })) - .handler(async ({ context, input }) => {}) - - void createTool(procedure, { - context: { authToken: '' }, - }) - - // @ts-expect-error missing context - void createTool(procedure, {}) - }) -}) diff --git a/packages/ai-sdk/src/tool.test.ts b/packages/ai-sdk/src/tool.test.ts deleted file mode 100644 index fdc8633ad..000000000 --- a/packages/ai-sdk/src/tool.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { AiSdkToolMeta } from './tool' -import { oc } from '@orpc/contract' -import { os } from '@orpc/server' -import z from 'zod' -import { AI_SDK_TOOL_META_SYMBOL, createTool, implementTool } from './tool' - -describe('implementTool', () => { - const base = oc.$meta({}) - - const inputSchema = z.object({ - name: z.string().describe('Name of the person'), - }) - const outputSchema = z.object({ - greeting: z.string().describe('Greeting message'), - }) - - it('can implement a tool', () => { - const contract = base - .route({ - summary: 'Greet a person', - }) - .input(inputSchema) - .output(outputSchema) - - const execute = vi.fn() - - const tool = implementTool(contract, { - execute, - }) - - expect(tool.inputSchema).toBe(inputSchema) - expect(tool.outputSchema).toBe(outputSchema) - expect(tool.description).toBe('Greet a person') - expect(tool.execute).toBe(execute) - }) - - it('require contract with inputSchema', () => { - expect(() => implementTool(base.input(inputSchema), {})).not.toThrow() - expect(() => implementTool(base, {})).toThrowError('Cannot implement tool from a contract procedure without input schema.') - }) - - it('use route.description when route.summary is not present', () => { - const contract = base.input(inputSchema) - .route({ - description: 'Custom description', - }) - - const tool = implementTool(contract, {}) - - expect(tool.description).toBe('Custom description') - }) - - it('support meta to provide default tool options', () => { - const contract = base - .meta({ - [AI_SDK_TOOL_META_SYMBOL]: { - title: 'title', - description: 'Meta description', - }, - }) - .input(inputSchema) - - const tool = implementTool(contract, { - execute: vi.fn(), - description: 'Override description', - }) - - expect((tool as any).title).toBe('title') - expect(tool.description).toBe('Override description') - }) -}) - -describe('createTool', () => { - const abortSignal = (new AbortController()).signal - const base = os.$meta({}) - - const inputSchema = z.object({ - name: z.string().describe('Name of the person'), - }) - const outputSchema = z.object({ - greeting: z.string().describe('Greeting message'), - }) - - it('can create a tool', async () => { - const handler = vi.fn(async ({ input }) => { - return { - greeting: `Hello, ${input.name}!`, - } - }) - const procedure - = base - .route({ - summary: 'Greet a person', - }) - .input(inputSchema) - .output(outputSchema) - .handler(handler) - - const tool = createTool(procedure, { - context: { authToken: 'auth-token' }, - }) - - expect(tool.inputSchema).toBe(inputSchema) - expect(tool.outputSchema).toBe(outputSchema) - expect(tool.description).toBe('Greet a person') - - await expect((tool as any).execute({ name: 'Alice' }, { abortSignal })).resolves.toEqual({ greeting: 'Hello, Alice!' }) - - expect(handler).toHaveBeenCalledWith(expect.objectContaining({ - signal: abortSignal, - input: { name: 'Alice' }, - context: { authToken: 'auth-token' }, - })) - }) - - it('disable validation at oRPC level to avoid twice times validation', async () => { - const procedure - = base - .route({ - summary: 'Greet a person', - }) - .input(inputSchema) - .output(outputSchema) - .handler(({ input }) => input as any) - - const tool = createTool(procedure) - - await expect(tool.execute?.('invalid' as any, { abortSignal } as any)).resolves.toEqual('invalid') - }) -}) diff --git a/packages/ai-sdk/src/tool.ts b/packages/ai-sdk/src/tool.ts deleted file mode 100644 index f79d41a57..000000000 --- a/packages/ai-sdk/src/tool.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { ClientOptions } from '@orpc/client' -import type { AnySchema, ContractProcedure, ErrorMap, InferSchemaInput, InferSchemaOutput, Meta, Schema } from '@orpc/contract' -import type { Context, CreateProcedureClientOptions } from '@orpc/server' -import type { MaybeOptionalOptions, SetOptional } from '@orpc/shared' -import type { Tool } from 'ai' -import { call, Procedure } from '@orpc/server' -import { resolveMaybeOptionalOptions } from '@orpc/shared' -import { tool } from 'ai' - -export const AI_SDK_TOOL_META_SYMBOL: unique symbol = Symbol('ORPC_AI_SDK_TOOL_META') - -export interface AiSdkToolMeta extends Meta { - [AI_SDK_TOOL_META_SYMBOL]?: Partial> -} - -export class CreateToolError extends Error {} - -/** - * Implements [procedure contract](https://orpc.dev/docs/contract-first/define-contract#procedure-contract) - * as an [AI SDK Tool](https://ai-sdk.dev/docs/foundations/tools) by leveraging existing contract definitions. - * - * @warning Requires a contract with an `input` schema defined. - * @info Standard [procedures](https://orpc.dev/docs/procedure) are also compatible with [procedure contracts](https://orpc.dev/docs/contract-first/define-contract). - * - * @example - * ```ts - * import { oc } from '@orpc/contract' - * import { - * AI_SDK_TOOL_META_SYMBOL, - * AiSdkToolMeta, - * implementTool, - * } from '@orpc/ai-sdk' - * import { z } from 'zod' - * - * interface ORPCMeta extends AiSdkToolMeta {} // optional extend meta - * const base = oc.$meta({}) - * - * const getWeatherContract = base - * .meta({ - * [AI_SDK_TOOL_META_SYMBOL]: { - * title: 'Get Weather', // AI SDK tool title - * }, - * }) - * .route({ - * summary: 'Get the weather in a location', // AI SDK tool description - * }) - * .input( - * z.object({ - * location: z.string().describe('The location to get the weather for'), - * }), - * ) - * .output( - * z.object({ - * location: z.string().describe('The location the weather is for'), - * temperature: z.number().describe('The temperature in Celsius'), - * }), - * ) - * - * const getWeatherTool = implementTool(getWeatherContract, { - * execute: async ({ location }) => ({ - * location, - * temperature: 72 + Math.floor(Math.random() * 21) - 10, - * }), - * }) - * ``` - */ -export function implementTool( - contract: ContractProcedure, Schema, any, AiSdkToolMeta>, - ...rest: MaybeOptionalOptions, 'inputSchema' | 'outputSchema'>> -): Tool { - if (contract['~orpc'].inputSchema === undefined) { - throw new CreateToolError('Cannot implement tool from a contract procedure without input schema.') - } - - const options = resolveMaybeOptionalOptions(rest) - - return tool({ - inputSchema: contract['~orpc'].inputSchema, - outputSchema: contract['~orpc'].outputSchema, - description: contract['~orpc'].route.summary ?? contract['~orpc'].route.description, - ...contract['~orpc'].meta[AI_SDK_TOOL_META_SYMBOL], - ...options, - } as any) -} - -/** - * Converts a [procedure](https://orpc.dev/docs/procedure) into an [AI SDK Tool](https://ai-sdk.dev/docs/foundations/tools) - * by leveraging existing procedure definitions. - * - * @warning Requires a contract with an `input` schema defined. - * - * @example - * ```ts - * import { os } from '@orpc/server' - * import { - * AI_SDK_TOOL_META_SYMBOL, - * AiSdkToolMeta, - * createTool - * } from '@orpc/ai-sdk' - * import { z } from 'zod' - * - * interface ORPCMeta extends AiSdkToolMeta {} // optional extend meta - * const base = os.$meta({}) - * - * const getWeatherProcedure = base - * .meta({ - * [AI_SDK_TOOL_META_SYMBOL]: { - * title: 'Get Weather', // AI SDK tool title - * }, - * }) - * .route({ - * summary: 'Get the weather in a location', - * }) - * .input(z.object({ - * location: z.string().describe('The location to get the weather for'), - * })) - * .output(z.object({ - * location: z.string().describe('The location the weather is for'), - * temperature: z.number().describe('The temperature in Celsius'), - * })) - * .handler(async ({ input }) => ({ - * location: input.location, - * temperature: 72 + Math.floor(Math.random() * 21) - 10, - * })) - * - * const getWeatherTool = createTool(getWeatherProcedure, { - * context: {}, // provide initial context if needed - * }) - * ``` - */ -export function createTool< - TInitialContext extends Context, - TInputSchema extends AnySchema, - TOutputSchema extends AnySchema, - TErrorMap extends ErrorMap, - TMeta extends AiSdkToolMeta, ->( - procedure: Procedure, - ...rest: MaybeOptionalOptions< - & SetOptional, InferSchemaInput>, 'inputSchema' | 'outputSchema' | 'execute'> - & CreateProcedureClientOptions> - & Omit>, 'context'> - > -): Tool, InferSchemaInput> { - const options = resolveMaybeOptionalOptions(rest) - - return implementTool(procedure, { - execute: ((input, callingOptions) => { - const disabledValidation = new Procedure({ - ...procedure['~orpc'], - inputValidationIndex: Number.NaN, // disable input validation - outputValidationIndex: Number.NaN, // disable output validation - }) - - return call( - disabledValidation, - input as InferSchemaInput, - { signal: callingOptions.abortSignal, ...options }, - ) as Promise> - }) satisfies (Tool, InferSchemaInput>['execute']), - ...options, - } as any) -} diff --git a/packages/ai-sdk/tsconfig.json b/packages/ai-sdk/tsconfig.json deleted file mode 100644 index 399022e72..000000000 --- a/packages/ai-sdk/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../openapi" }, - { "path": "../contract" }, - { "path": "../server" } - ], - "include": ["src"], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] -} diff --git a/packages/arktype/README.md b/packages/arktype/README.md index 566382360..4d40e5e62 100644 --- a/packages/arktype/README.md +++ b/packages/arktype/README.md @@ -1,8 +1,4 @@ -
- oRPC logo -
- -

+

oRPC - Typesafe APIs Made Simple 🪄

-

Typesafe APIs Made Simple 🪄

+## Documentation -**oRPC is a powerful combination of RPC and OpenAPI**, makes it easy to build APIs that are end-to-end type-safe and adhere to OpenAPI standards +You can read the documentation [here](https://orpc.dev). ---- +## Packages -## Highlights +**Core** -- **🔗 End-to-End Type Safety**: Ensure type-safe inputs, outputs, and errors from client to server. -- **📘 First-Class OpenAPI**: Built-in support that fully adheres to the OpenAPI standard. -- **📝 Contract-First Development**: Optionally define your API contract before implementation. -- **🔍 First-Class OpenTelemetry**: Seamlessly integrate with OpenTelemetry for observability. -- **⚙️ Framework Integrations**: Seamlessly integrate with TanStack Query (React, Vue, Solid, Svelte, Angular), SWR, Pinia Colada, and more. -- **🚀 Server Actions**: Fully compatible with React Server Actions on Next.js, TanStack Start, and other platforms. -- **🔠 Standard Schema Support**: Works out of the box with Zod, Valibot, ArkType, and other schema validators. -- **🗃️ Native Types**: Supports native types like Date, File, Blob, BigInt, URL, and more. -- **⏱️ Lazy Router**: Enhance cold start times with our lazy routing feature. -- **📡 SSE & Streaming**: Enjoy full type-safe support for SSE and streaming. -- **🌍 Multi-Runtime Support**: Fast and lightweight on Cloudflare, Deno, Bun, Node.js, and beyond. -- **🔌 Extendability**: Easily extend functionality with plugins, middleware, and interceptors. +- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Define type-safe contracts for your API. +- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Implement your API or contract on the server. +- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API from the client with full type safety. +- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Bring OpenAPI compatibility to your APIs. -## Documentation +**Schema validation** -You can find the full documentation [here](https://orpc.dev). +- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): Integrate with [Zod](https://zod.dev/). +- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): Integrate with [Valibot](https://valibot.dev/). +- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): Integrate with [ArkType](https://arktype.io/). -## Packages +**Framework & ecosystem integrations** -- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Build your API contract. -- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build your API or implement API contract. -- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API on the client with type-safety. -- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Generate OpenAPI specs and handle OpenAPI requests. -- [@orpc/otel](https://www.npmjs.com/package/@orpc/otel): [OpenTelemetry](https://opentelemetry.io/) integration for observability. -- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Deeply integrate oRPC with [NestJS](https://nestjs.com/). -- [@orpc/react](https://www.npmjs.com/package/@orpc/react): Utilities for integrating oRPC with React and React Server Actions. -- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): [TanStack Query](https://tanstack.com/query/latest) integration. -- [@orpc/experimental-react-swr](https://www.npmjs.com/package/@orpc/experimental-react-swr): [SWR](https://swr.vercel.app/) integration. -- [@orpc/vue-colada](https://www.npmjs.com/package/@orpc/vue-colada): Integration with [Pinia Colada](https://pinia-colada.esm.dev/). -- [@orpc/hey-api](https://www.npmjs.com/package/@orpc/hey-api): [Hey API](https://heyapi.dev/) integration. -- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): More schemas that [Zod](https://zod.dev/) doesn't support yet. -- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): OpenAPI spec generation from [Valibot](https://valibot.dev/). -- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): OpenAPI spec generation from [ArkType](https://arktype.io/). - -## `@orpc/arktype` - -Provides `ArkTypeToJsonSchemaConverter` for generating OpenAPI specs from [ArkType](https://arktype.io/). - -### Generate OpenAPI Spec - -```ts -import { - experimental_ArkTypeToJsonSchemaConverter as ArkTypeToJsonSchemaConverter -} from '@orpc/arktype' -import { OpenAPIGenerator } from '@orpc/openapi' - -const openAPIGenerator = new OpenAPIGenerator({ - schemaConverters: [ - new ArkTypeToJsonSchemaConverter() - ], -}) - -const specFromContract = await openAPIGenerator.generate(contract, { - info: { - title: 'My App', - version: '0.0.0', - }, -}) - -const specFromRouter = await openAPIGenerator.generate(router, { - info: { - title: 'My App', - version: '0.0.0', - }, -}) -``` +- [@orpc/next](https://www.npmjs.com/package/@orpc/react): Use oRPC with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). +- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). +- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). + +**Built-in features** + +- [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub. +- [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting. +- [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. + +**Observability** + +- [@orpc/opentelemetry](https://www.npmjs.com/package/@orpc/opentelemetry): Instrument your API with [OpenTelemetry](https://opentelemetry.io/). +- [@orpc/pino](https://www.npmjs.com/package/@orpc/pino): Log with [Pino](https://getpino.io/). +- [@orpc/evlog](https://www.npmjs.com/package/@orpc/evlog): Log with [Evlog](https://evlog.dev/). ## Sponsors -If you find oRPC valuable and would like to support its development, you can do so here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). +Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). Every bit helps! 🚀 ### 🏆 Platinum Sponsor @@ -218,6 +176,13 @@ If you find oRPC valuable and would like to support its development, you can do plancraft

+## 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/arktype/package.json b/packages/arktype/package.json index 3e66ba62c..55e2fe200 100644 --- a/packages/arktype/package.json +++ b/packages/arktype/package.json @@ -1,7 +1,7 @@ { "name": "@orpc/arktype", "type": "module", - "version": "1.14.6", + "version": "1.13.4", "license": "MIT", "homepage": "https://orpc.dev", "repository": { @@ -15,6 +15,7 @@ "sideEffects": false, "publishConfig": { "exports": { + "./package.json": "./package.json", ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs", @@ -23,6 +24,7 @@ } }, "exports": { + "./package.json": "./package.json", ".": "./src/index.ts" }, "files": [ @@ -30,20 +32,18 @@ ], "scripts": { "build": "unbuild", - "build:watch": "pnpm run build --watch", "type:check": "tsc -b" }, "peerDependencies": { "@ark/schema": "*", - "@orpc/contract": "workspace:*", "arktype": "*" }, "dependencies": { - "@orpc/openapi": "workspace:*" + "@orpc/json-schema": "workspace:*" }, "devDependencies": { "@ark/schema": "^0.56.0", - "arktype": "^2.2.0", - "zod": "^4.3.6" + "arktype": "^2.1.29", + "zod": "^4.4.3" } } diff --git a/packages/arktype/src/converter.test.ts b/packages/arktype/src/converter.test.ts index 3c3d42abd..93f4279a5 100644 --- a/packages/arktype/src/converter.test.ts +++ b/packages/arktype/src/converter.test.ts @@ -1,56 +1,130 @@ import { type } from 'arktype' import * as z from 'zod' -import { experimental_ArkTypeToJsonSchemaConverter as ArkTypeToJsonSchemaConverter } from './converter' - -it('arkTypeToJsonSchemaConverter.convert', async () => { - const converter = new ArkTypeToJsonSchemaConverter({ - fallback: { - default: () => ({ - 'type': 'null', - 'x-type': 'null', - }), - }, +import { ArkTypeToJsonSchemaConverter } from './converter' + +describe('arkTypeToJsonSchemaConverter', () => { + const converter = new ArkTypeToJsonSchemaConverter() + + describe('.condition', () => { + it.each([ + ['arktype input schema', type({ name: 'string' }), 'input', true], + ['arktype output schema', type('number'), 'output', true], + ['non-arktype schema', z.string() as never, 'input', false], + ['undefined schema', undefined, 'output', false], + ] as const)('matches %s', (_, schema, direction, expected) => { + expect(converter.condition(schema, direction)).toBe(expected) + }) }) - expect(converter.convert(type('string'), { strategy: 'input' })).toEqual( - [true, { - $schema: 'https://json-schema.org/draft/2020-12/schema', - type: 'string', - }], - ) - - expect(converter.convert(type({ a: 'string' }), { strategy: 'input' })).toEqual( - [true, { - $schema: 'https://json-schema.org/draft/2020-12/schema', - type: 'object', - properties: { a: { type: 'string' } }, - required: ['a'], - }], - ) - - expect(converter.convert(type('Date'), { strategy: 'input' })).toEqual( - [true, { - $schema: 'https://json-schema.org/draft/2020-12/schema', - format: 'date-time', - type: 'string', - }], - ) - - expect(converter.convert(type('Error'), { strategy: 'input' })).toEqual( - [true, { - '$schema': 'https://json-schema.org/draft/2020-12/schema', - 'type': 'null', - 'x-type': 'null', - }], - ) -}) + it('keeps converting when standard validation throws while checking optionality', () => { + const schema = type('string') -it('arkTypeToJsonSchemaConverter.condition', async () => { - const converter = new ArkTypeToJsonSchemaConverter() + Object.defineProperty(schema, '~standard', { + value: { + ...schema['~standard'], + validate: () => { + throw new Error('validate failed') + }, + }, + }) + + expect(converter.convert(schema, 'input')).toEqual([{ type: 'string' }, false]) + }) - expect(converter.condition(type({ name: 'string' }))).toBe(true) - expect(converter.condition(type('number'))).toBe(true) + describe('optionality', () => { + it.each([ + ['optional input schema', type('string | undefined'), 'input', { + anyOf: [ + { type: 'string' }, + {}, + ], + }, true], + ['optional output schema', type('string | undefined'), 'output', { + anyOf: [ + { + type: 'string', + }, + {}, + ], + }, true], + ['required input schema', type('string'), 'input', { + type: 'string', + }, false], + ['required output schema', type('string'), 'output', { + type: 'string', + }, false], + ] as const)('marks %s correctly', (_, schema, direction, jsonSchema, optional) => { + expect(converter.convert(schema, direction)).toEqual([jsonSchema, optional]) + }) + }) + + describe('native type extensions', () => { + it.each([ + [type('bigint'), { + 'type': 'string', + 'x-native-type': 'bigint', + 'pattern': '^-?[0-9]+$', + }], + [type('Date'), { + 'type': 'string', + 'x-native-type': 'date', + 'format': 'date-time', + }], + ] as const)('extends conversion for %s', (schema, jsonSchema) => { + expect(converter.convert(schema, 'input')).toEqual([jsonSchema, false]) + }) + }) + + it('passes built-in fallback mutations through custom handlers', () => { + const functionConverter = new ArkTypeToJsonSchemaConverter({ + fallback: (ctx) => { + return { + ...ctx.base, + title: '__EXTENDED__', + } + }, + }) - expect(converter.condition(z.string())).toBe(false) - expect(converter.condition(z.string().optional())).toBe(false) + expect(functionConverter.convert(type('bigint'), 'input')).toEqual([ + { + 'pattern': '^-?[0-9]+$', + 'title': '__EXTENDED__', + 'type': 'string', + 'x-native-type': 'bigint', + }, + false, + ]) + + const objectConverter = new ArkTypeToJsonSchemaConverter({ + fallback: { + date: () => ({ type: 'string', title: '__DATE__' }), + default: (ctx) => { + return { + ...ctx.base, + title: '__EXTENDED__', + } + }, + }, + }) + + expect(objectConverter.convert(type({ a: 'Date', b: 'bigint' }), 'input')).toEqual([ + { + properties: { + a: { + title: '__DATE__', + type: 'string', + }, + b: { + 'pattern': '^-?[0-9]+$', + 'title': '__EXTENDED__', + 'type': 'string', + 'x-native-type': 'bigint', + }, + }, + required: ['a', 'b'], + type: 'object', + }, + false, + ]) + }) }) diff --git a/packages/arktype/src/converter.ts b/packages/arktype/src/converter.ts index 044c2e958..9d9e00005 100644 --- a/packages/arktype/src/converter.ts +++ b/packages/arktype/src/converter.ts @@ -1,37 +1,74 @@ -import type { ToJsonSchema } from '@ark/schema' -import type { AnySchema } from '@orpc/contract' -import type { ConditionalSchemaConverter, JSONSchema, SchemaConvertOptions } from '@orpc/openapi' +import type { JsonSchema as ArkJsonSchema, ToJsonSchema } from '@ark/schema' +import type { AnySchema, JsonSchema, JsonSchemaConverter, JsonSchemaConverterDirection } from '@orpc/json-schema' import type { Type } from 'arktype' -import { JSONSchemaFormat } from '@orpc/openapi' - -const defaultToJsonSchemaFallback: ToJsonSchema.FallbackOption = { - date: ctx => ({ - ...ctx.base, - type: 'string', - format: JSONSchemaFormat.DateTime, - }), -} +import { JsonSchemaFormat, JsonSchemaXNativeType } from '@orpc/json-schema' + +export interface ArkTypeToJsonSchemaConverterOptions extends Omit {} -export class experimental_ArkTypeToJsonSchemaConverter implements ConditionalSchemaConverter { - #options: ToJsonSchema.Options +export class ArkTypeToJsonSchemaConverter implements JsonSchemaConverter { + private readonly toJsonSchemaOptions: ToJsonSchema.Options - constructor(options: ToJsonSchema.Options = {}) { - this.#options = { + constructor(options: ArkTypeToJsonSchemaConverterOptions = {}) { + this.toJsonSchemaOptions = { ...options, + target: 'draft-2020-12', fallback: { - ...defaultToJsonSchemaFallback, - ...options?.fallback, + ...(options.fallback && typeof options.fallback !== 'function' ? options.fallback : undefined), + default: (ctx) => { + if (ctx.code === 'domain') { + if (ctx.domain === 'bigint') { + ;(ctx.base as any).type = 'string' + ;(ctx.base as any).pattern = '^-?[0-9]+$' + ;(ctx.base as any)['x-native-type'] = JsonSchemaXNativeType.BigInt + } + } + else if (ctx.code === 'date') { + ;(ctx.base as any).type = 'string' + ;(ctx.base as any).format = JsonSchemaFormat.DateTime + ;(ctx.base as any)['x-native-type'] = JsonSchemaXNativeType.Date + } + + if (typeof options.fallback === 'function') { + return options.fallback(ctx) + } + + if (options.fallback?.default) { + return options.fallback.default(ctx) + } + + return ctx.base + }, }, } } - condition(schema: AnySchema | undefined): boolean { - return schema !== undefined && schema['~standard'].vendor === 'arktype' + condition(schema: AnySchema | undefined, _direction: JsonSchemaConverterDirection): boolean { + return schema?.['~standard'].vendor === 'arktype' + } + + convert(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): [jsonSchema: JsonSchema, optional: boolean] { + const arkTypeSchema = schema as Type + + const jsonSchema = this.convertArkType(arkTypeSchema, direction) + + let optional = false + try { + const result = arkTypeSchema['~standard'].validate(undefined) + if (!(result instanceof Promise) && !result.issues) { + optional = direction === 'input' ? true : result.value === undefined + } + } + catch {} + + return [jsonSchema as JsonSchema, optional] } - convert(schema: AnySchema | undefined, _options: SchemaConvertOptions): [required: boolean, jsonSchema: Exclude] { - const jsonSchema = (schema as Type).toJsonSchema(this.#options) + private convertArkType(schema: Type, _direction: JsonSchemaConverterDirection): ArkJsonSchema { + const jsonSchema = schema.toJsonSchema(this.toJsonSchemaOptions) - return [true, jsonSchema] + // Since the default oRPC format is always draft/2020-12, + // `$schema` can be safely omitted here. + const { $schema, ...rest } = jsonSchema + return rest } } diff --git a/packages/arktype/src/index.test.ts b/packages/arktype/src/index.test.ts new file mode 100644 index 000000000..ce45c67e6 --- /dev/null +++ b/packages/arktype/src/index.test.ts @@ -0,0 +1,5 @@ +it('exports ArkTypeToJsonSchemaConverter', async () => { + await expect(import('./index')).resolves.toMatchObject({ + ArkTypeToJsonSchemaConverter: expect.any(Function), + }) +}) diff --git a/packages/arktype/tsconfig.json b/packages/arktype/tsconfig.json index 399022e72..7416873eb 100644 --- a/packages/arktype/tsconfig.json +++ b/packages/arktype/tsconfig.json @@ -1,11 +1,9 @@ { "extends": "../../tsconfig.lib.json", "references": [ - { "path": "../openapi" }, - { "path": "../contract" }, - { "path": "../server" } + { "path": "../json-schema" } ], - "include": ["src"], + "include": ["package.json", "src"], "exclude": [ "**/*.test.*", "**/*.test-d.ts", diff --git a/packages/client/README.md b/packages/client/README.md index 62bd1d1e4..27fc183ad 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -1,8 +1,4 @@ -
- oRPC logo -
- -

+

oRPC - Typesafe APIs Made Simple 🪄

-

Typesafe APIs Made Simple 🪄

+## Documentation -**oRPC is a powerful combination of RPC and OpenAPI**, makes it easy to build APIs that are end-to-end type-safe and adhere to OpenAPI standards +You can read the documentation [here](https://orpc.dev). ---- +## Packages -## Highlights +**Core** -- **🔗 End-to-End Type Safety**: Ensure type-safe inputs, outputs, and errors from client to server. -- **📘 First-Class OpenAPI**: Built-in support that fully adheres to the OpenAPI standard. -- **📝 Contract-First Development**: Optionally define your API contract before implementation. -- **🔍 First-Class OpenTelemetry**: Seamlessly integrate with OpenTelemetry for observability. -- **⚙️ Framework Integrations**: Seamlessly integrate with TanStack Query (React, Vue, Solid, Svelte, Angular), SWR, Pinia Colada, and more. -- **🚀 Server Actions**: Fully compatible with React Server Actions on Next.js, TanStack Start, and other platforms. -- **🔠 Standard Schema Support**: Works out of the box with Zod, Valibot, ArkType, and other schema validators. -- **🗃️ Native Types**: Supports native types like Date, File, Blob, BigInt, URL, and more. -- **⏱️ Lazy Router**: Enhance cold start times with our lazy routing feature. -- **📡 SSE & Streaming**: Enjoy full type-safe support for SSE and streaming. -- **🌍 Multi-Runtime Support**: Fast and lightweight on Cloudflare, Deno, Bun, Node.js, and beyond. -- **🔌 Extendability**: Easily extend functionality with plugins, middleware, and interceptors. +- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Define type-safe contracts for your API. +- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Implement your API or contract on the server. +- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API from the client with full type safety. +- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Bring OpenAPI compatibility to your APIs. -## Documentation +**Schema validation** -You can find the full documentation [here](https://orpc.dev). +- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): Integrate with [Zod](https://zod.dev/). +- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): Integrate with [Valibot](https://valibot.dev/). +- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): Integrate with [ArkType](https://arktype.io/). -## Packages +**Framework & ecosystem integrations** -- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Build your API contract. -- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build your API or implement API contract. -- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API on the client with type-safety. -- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Generate OpenAPI specs and handle OpenAPI requests. -- [@orpc/otel](https://www.npmjs.com/package/@orpc/otel): [OpenTelemetry](https://opentelemetry.io/) integration for observability. -- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Deeply integrate oRPC with [NestJS](https://nestjs.com/). -- [@orpc/react](https://www.npmjs.com/package/@orpc/react): Utilities for integrating oRPC with React and React Server Actions. -- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): [TanStack Query](https://tanstack.com/query/latest) integration. -- [@orpc/experimental-react-swr](https://www.npmjs.com/package/@orpc/experimental-react-swr): [SWR](https://swr.vercel.app/) integration. -- [@orpc/vue-colada](https://www.npmjs.com/package/@orpc/vue-colada): Integration with [Pinia Colada](https://pinia-colada.esm.dev/). -- [@orpc/hey-api](https://www.npmjs.com/package/@orpc/hey-api): [Hey API](https://heyapi.dev/) integration. -- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): More schemas that [Zod](https://zod.dev/) doesn't support yet. -- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): OpenAPI spec generation from [Valibot](https://valibot.dev/). -- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): OpenAPI spec generation from [ArkType](https://arktype.io/). - -## `@orpc/client` - -Consume your API on the client with type-safety. Read the [documentation](https://orpc.dev/docs/client/client-side) for more information. - -```ts -import { createORPCClient } from '@orpc/client' -import { RPCLink } from '@orpc/client/fetch' -import { ContractRouterClient } from '@orpc/contract' -import { RouterClient } from '@orpc/server' - -const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - headers: () => ({ - authorization: 'Bearer token', - }), - // fetch: <-- provide fetch polyfill fetch if needed -}) - -// Create a client for your router -const client: RouterClient = createORPCClient(link) -// Or, create a client using a contract -const client: ContractRouterClient = createORPCClient(link) -``` +- [@orpc/next](https://www.npmjs.com/package/@orpc/react): Use oRPC with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). +- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). +- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). + +**Built-in features** + +- [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub. +- [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting. +- [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. + +**Observability** + +- [@orpc/opentelemetry](https://www.npmjs.com/package/@orpc/opentelemetry): Instrument your API with [OpenTelemetry](https://opentelemetry.io/). +- [@orpc/pino](https://www.npmjs.com/package/@orpc/pino): Log with [Pino](https://getpino.io/). +- [@orpc/evlog](https://www.npmjs.com/package/@orpc/evlog): Log with [Evlog](https://evlog.dev/). ## Sponsors -If you find oRPC valuable and would like to support its development, you can do so here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). +Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). Every bit helps! 🚀 ### 🏆 Platinum Sponsor @@ -209,6 +176,13 @@ If you find oRPC valuable and would like to support its development, you can do plancraft

+## 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/client/package.json b/packages/client/package.json index 3d5a33740..365ee9761 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,7 +1,7 @@ { "name": "@orpc/client", "type": "module", - "version": "1.14.6", + "version": "1.13.4", "license": "MIT", "homepage": "https://orpc.dev", "repository": { @@ -15,6 +15,7 @@ "sideEffects": false, "publishConfig": { "exports": { + "./package.json": "./package.json", ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs", @@ -48,6 +49,7 @@ } }, "exports": { + "./package.json": "./package.json", ".": "./src/index.ts", "./plugins": "./src/plugins/index.ts", "./standard": "./src/adapters/standard/index.ts", @@ -60,16 +62,15 @@ ], "scripts": { "build": "unbuild", - "build:watch": "pnpm run build --watch", "type:check": "tsc -b" }, "dependencies": { "@orpc/shared": "workspace:*", - "@orpc/standard-server": "workspace:*", - "@orpc/standard-server-fetch": "workspace:*", - "@orpc/standard-server-peer": "workspace:*" + "@standardserver/core": "^0.0.24", + "@standardserver/fetch": "^0.0.24", + "@standardserver/peer": "^0.0.24" }, "devDependencies": { - "zod": "^4.3.6" + "zod": "^4.4.3" } } diff --git a/packages/client/src/adapters/fetch/index.test.ts b/packages/client/src/adapters/fetch/index.test.ts new file mode 100644 index 000000000..fe981df20 --- /dev/null +++ b/packages/client/src/adapters/fetch/index.test.ts @@ -0,0 +1,3 @@ +it('exports RPCLink', async () => { + await expect(import('.')).resolves.toHaveProperty('RPCLink') +}) diff --git a/packages/client/src/adapters/fetch/index.ts b/packages/client/src/adapters/fetch/index.ts index 98301c83f..cdc9da383 100644 --- a/packages/client/src/adapters/fetch/index.ts +++ b/packages/client/src/adapters/fetch/index.ts @@ -1,2 +1,3 @@ -export * from './link-fetch-client' +export * from './plugin' export * from './rpc-link' +export * from './transport' diff --git a/packages/client/src/adapters/fetch/link-fetch-client.test.ts b/packages/client/src/adapters/fetch/link-fetch-client.test.ts deleted file mode 100644 index fc144e2a0..000000000 --- a/packages/client/src/adapters/fetch/link-fetch-client.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { StandardRequest } from '@orpc/standard-server' -import * as StandardServerFetch from '@orpc/standard-server-fetch' -import { LinkFetchClient } from './link-fetch-client' - -const toFetchRequestSpy = vi.spyOn(StandardServerFetch, 'toFetchRequest') -const toStandardLazyResponseSpy = vi.spyOn(StandardServerFetch, 'toStandardLazyResponse') - -describe('linkFetchClient', () => { - it('call', async () => { - const fetch = vi.fn().mockResolvedValueOnce(new Response('body')) - const interceptor1 = vi.fn(({ next }) => next()) - const interceptor2 = vi.fn(({ next }) => next()) - - const linkOptions = { - fetch, - adapterInterceptors: [interceptor1, interceptor2], - } - - const client = new LinkFetchClient(linkOptions) - - const standardRequest: StandardRequest = { - url: new URL('http://localhost:300/example'), - body: { body: true }, - headers: { - 'x-custom': 'value', - }, - method: 'POST', - signal: AbortSignal.timeout(100), - } - - const options = { - context: { context: true }, - lastEventId: 'last-event-id', - signal: AbortSignal.timeout(100), - } - - const response = await client.call(standardRequest, options, ['example'], { body: true }) - - expect(toFetchRequestSpy).toBeCalledTimes(1) - expect(toFetchRequestSpy).toBeCalledWith(standardRequest, linkOptions) - - expect(response).toBe(toStandardLazyResponseSpy.mock.results[0]!.value) - expect(toStandardLazyResponseSpy).toBeCalledTimes(1) - expect(toStandardLazyResponseSpy).toBeCalledWith( - await fetch.mock.results[0]!.value, - { signal: toFetchRequestSpy.mock.results[0]!.value.signal }, - ) - - expect(fetch).toBeCalledTimes(1) - expect(fetch).toBeCalledWith( - toFetchRequestSpy.mock.results[0]!.value, - { redirect: 'manual' }, - options, - ['example'], - { body: true }, - ) - - expect(interceptor1).toBeCalledTimes(1) - expect(interceptor2).toBeCalledTimes(1) - expect(interceptor1).toBeCalledWith(expect.objectContaining({ - request: toFetchRequestSpy.mock.results[0]!.value, - ...options, - init: { redirect: 'manual' }, - input: { body: true }, - path: ['example'], - })) - expect(interceptor2).toBeCalledWith(expect.objectContaining({ - request: toFetchRequestSpy.mock.results[0]!.value, - ...options, - init: { redirect: 'manual' }, - input: { body: true }, - path: ['example'], - })) - }) - - it('plugins', () => { - const initRuntimeAdapter = vi.fn() - const interceptor = vi.fn() - - const linkOptions = { - plugins: [ - { initRuntimeAdapter }, - ], - adapterInterceptors: [interceptor], - } - - const link = new LinkFetchClient(linkOptions) - - expect(initRuntimeAdapter).toHaveBeenCalledOnce() - expect(initRuntimeAdapter).toHaveBeenCalledWith(linkOptions) - }) -}) diff --git a/packages/client/src/adapters/fetch/link-fetch-client.ts b/packages/client/src/adapters/fetch/link-fetch-client.ts deleted file mode 100644 index 11c43595a..000000000 --- a/packages/client/src/adapters/fetch/link-fetch-client.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Interceptor } from '@orpc/shared' -import type { StandardLazyResponse, StandardRequest } from '@orpc/standard-server' -import type { ToFetchRequestOptions } from '@orpc/standard-server-fetch' -import type { ClientContext, ClientOptions } from '../../types' -import type { StandardLinkClient } from '../standard' -import type { LinkFetchPlugin } from './plugin' -import { intercept, toArray } from '@orpc/shared' -import { toFetchRequest, toStandardLazyResponse } from '@orpc/standard-server-fetch' -import { CompositeLinkFetchPlugin } from './plugin' - -export interface LinkFetchInterceptorOptions extends ClientOptions { - request: Request - init: { redirect?: Request['redirect'] } - path: readonly string[] - input: unknown -} - -export interface LinkFetchClientOptions extends ToFetchRequestOptions { - fetch?: ( - request: Request, - init: LinkFetchInterceptorOptions['init'], - options: ClientOptions, - path: readonly string[], - input: unknown, - ) => Promise - - adapterInterceptors?: Interceptor, Promise>[] - - plugins?: LinkFetchPlugin[] -} - -export class LinkFetchClient implements StandardLinkClient { - private readonly fetch: Exclude['fetch'], undefined> - private readonly toFetchRequestOptions: ToFetchRequestOptions - private readonly adapterInterceptors: Exclude['adapterInterceptors'], undefined> - - constructor(options: LinkFetchClientOptions) { - const plugin = new CompositeLinkFetchPlugin(options.plugins) - - plugin.initRuntimeAdapter(options) - - this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis) - this.toFetchRequestOptions = options - this.adapterInterceptors = toArray(options.adapterInterceptors) - } - - async call(standardRequest: StandardRequest, options: ClientOptions, path: readonly string[], input: unknown): Promise { - const request = toFetchRequest(standardRequest, this.toFetchRequestOptions) - - const fetchResponse = await intercept( - this.adapterInterceptors, - { ...options, request, path, input, init: { redirect: 'manual' } }, - ({ request, path, input, init, ...options }) => this.fetch(request, init, options, path, input), - ) - - const lazyResponse = toStandardLazyResponse(fetchResponse, { signal: request.signal }) - - return lazyResponse - } -} diff --git a/packages/client/src/adapters/fetch/plugin.test-d.ts b/packages/client/src/adapters/fetch/plugin.test-d.ts deleted file mode 100644 index 62e9a2f81..000000000 --- a/packages/client/src/adapters/fetch/plugin.test-d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { StandardLinkPlugin } from '../standard' -import type { LinkFetchPlugin } from './plugin' - -describe('LinkFetchPlugin', () => { - it('backward compatibility', () => { - expectTypeOf>().toExtend>() - expectTypeOf>().toExtend>() - }) -}) diff --git a/packages/client/src/adapters/fetch/plugin.test.ts b/packages/client/src/adapters/fetch/plugin.test.ts index 97edb994c..223967d54 100644 --- a/packages/client/src/adapters/fetch/plugin.test.ts +++ b/packages/client/src/adapters/fetch/plugin.test.ts @@ -1,37 +1,39 @@ -import type { LinkFetchPlugin } from './plugin' -import { CompositeLinkFetchPlugin } from './plugin' +import type { FetchLinkTransportPlugin } from './plugin' +import { CompositeFetchLinkTransportPlugin } from './plugin' -describe('compositeLinkFetchPlugin', () => { - it('forward initRuntimeAdapter and sort plugins', () => { +describe('compositeFetchLinkTransportPlugin', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('forwards initFetchLinkTransportOptions and sorts plugins by dependencies', () => { const plugin1 = { - initRuntimeAdapter: vi.fn(), - order: 1, - } satisfies LinkFetchPlugin - const plugin2 = { - initRuntimeAdapter: vi.fn(), - } satisfies LinkFetchPlugin - const plugin3 = { - initRuntimeAdapter: vi.fn(), - order: -1, - } satisfies LinkFetchPlugin + name: 'plugin-1', + initFetchLinkTransportOptions: vi.fn((options: any) => options), + after: ['plugin-2'], + } satisfies FetchLinkTransportPlugin - const compositePlugin = new CompositeLinkFetchPlugin([plugin1, plugin2, plugin3]) + const plugin2 = { + name: 'plugin-2', + initFetchLinkTransportOptions: vi.fn((options: any) => options), + before: ['plugin-1'], + } satisfies FetchLinkTransportPlugin - const interceptor = vi.fn() + const plugin3 = { + name: 'plugin-3', + after: ['plugin-1'], + } satisfies FetchLinkTransportPlugin - const options = { adapterInterceptors: [interceptor] } + const compositePlugin = new CompositeFetchLinkTransportPlugin([plugin1, plugin2, plugin3]) + const options = { fetchInterceptors: [vi.fn()] } - compositePlugin.initRuntimeAdapter(options) + const result = compositePlugin.initFetchLinkTransportOptions(options) - expect(plugin1.initRuntimeAdapter).toHaveBeenCalledOnce() - expect(plugin2.initRuntimeAdapter).toHaveBeenCalledOnce() - expect(plugin3.initRuntimeAdapter).toHaveBeenCalledOnce() + expect(result).toBe(options) - expect(plugin1.initRuntimeAdapter.mock.calls[0]![0]).toBe(options) - expect(plugin2.initRuntimeAdapter.mock.calls[0]![0]).toBe(options) - expect(plugin3.initRuntimeAdapter.mock.calls[0]![0]).toBe(options) + expect(plugin1.initFetchLinkTransportOptions).toHaveBeenCalledOnce() + expect(plugin2.initFetchLinkTransportOptions).toHaveBeenCalledOnce() - expect(plugin3.initRuntimeAdapter).toHaveBeenCalledBefore(plugin2.initRuntimeAdapter) - expect(plugin2.initRuntimeAdapter).toHaveBeenCalledBefore(plugin1.initRuntimeAdapter) + expect(plugin2.initFetchLinkTransportOptions).toHaveBeenCalledBefore(plugin1.initFetchLinkTransportOptions) }) }) diff --git a/packages/client/src/adapters/fetch/plugin.ts b/packages/client/src/adapters/fetch/plugin.ts index 6f2ce89be..d333c1877 100644 --- a/packages/client/src/adapters/fetch/plugin.ts +++ b/packages/client/src/adapters/fetch/plugin.ts @@ -1,17 +1,28 @@ import type { ClientContext } from '../../types' import type { StandardLinkPlugin } from '../standard' -import type { LinkFetchClientOptions } from './link-fetch-client' -import { CompositeStandardLinkPlugin } from '../standard' +import type { FetchLinkTransportOptions } from './transport' +import { sortPlugins } from '@orpc/shared' -export interface LinkFetchPlugin extends StandardLinkPlugin { - initRuntimeAdapter?(options: LinkFetchClientOptions): void +export interface FetchLinkTransportPlugin extends StandardLinkPlugin { + initFetchLinkTransportOptions?(options: FetchLinkTransportOptions): FetchLinkTransportOptions } -export class CompositeLinkFetchPlugin> - extends CompositeStandardLinkPlugin implements LinkFetchPlugin { - initRuntimeAdapter(options: LinkFetchClientOptions): void { +export class CompositeFetchLinkTransportPlugin implements FetchLinkTransportPlugin { + name = '~composite/fetch-link-transport' + + constructor( + protected readonly plugins: FetchLinkTransportPlugin[] = [], + ) { + this.plugins = sortPlugins(plugins) + } + + initFetchLinkTransportOptions(options: FetchLinkTransportOptions): FetchLinkTransportOptions { for (const plugin of this.plugins) { - plugin.initRuntimeAdapter?.(options) + if (plugin.initFetchLinkTransportOptions) { + options = plugin.initFetchLinkTransportOptions(options) + } } + + return options } } diff --git a/packages/client/src/adapters/fetch/rpc-link.test.ts b/packages/client/src/adapters/fetch/rpc-link.test.ts index 073d7c70a..cbd9ff2ce 100644 --- a/packages/client/src/adapters/fetch/rpc-link.test.ts +++ b/packages/client/src/adapters/fetch/rpc-link.test.ts @@ -1,224 +1,189 @@ -import { getEventMeta, ORPCError, os, withEventMeta } from '@orpc/server' -import { RPCHandler } from '@orpc/server/fetch' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { supportedDataTypes } from '../../../tests/shared' +import { toFetchBody } from '@standardserver/fetch' +import { createORPCClient } from '../../client' import { RPCLink } from './rpc-link' -beforeEach(() => { - vi.clearAllMocks() -}) - -describe.each(supportedDataTypes)('rpcLink: $name', ({ value, expected }) => { - describe.each(['GET', 'POST'] as const)('method: %s', (method) => { - async function assertSuccessCase(value: unknown, expected: unknown): Promise { - const handler = vi.fn(({ input }) => input) - - const rpcHandler = new RPCHandler(os.handler(handler), { - strictGetMethodPluginEnabled: false, - }) +vi.mock('@standardserver/fetch', async (loadOrigin) => { + const origin = await loadOrigin() as any - const rpcLink = new RPCLink({ - url: 'http://api.example.com', - method, - fetch: async (request) => { - const { matched, response } = await rpcHandler.handle(request) + return { + ...origin, + toFetchBody: vi.fn(origin.toFetchBody), + } +}) - if (matched) { - return response - } +describe('rpcLink', () => { + beforeEach(() => { + vi.clearAllMocks() + }) - throw new Error('No procedure match') + it('calls endpoint with fetch transport', async () => { + const fetch = vi.fn(async () => { + return new Response(JSON.stringify({ json: 'pong' }), { + status: 200, + headers: { + 'content-type': 'application/json', }, }) + }) - const output = await rpcLink.call([], value, { context: {} }) - - expect(output).toEqual(expected) - expect(handler).toHaveBeenCalledTimes(1) - expect(handler).toHaveBeenCalledWith(expect.objectContaining({ input: expected })) - - return true - } - - async function assertErrorCase(value: unknown, expected: unknown): Promise { - const handler = vi.fn(({ input }) => { - throw new ORPCError('TEST', { - data: input, - }) - }) - - const rpcHandler = new RPCHandler(os.handler(handler), { - strictGetMethodPluginEnabled: false, - }) - - const rpcLink = new RPCLink({ - url: 'http://api.example.com', - method, - fetch: async (request) => { - const { matched, response } = await rpcHandler.handle(request) - - if (matched) { - return response - } + const orpc = createORPCClient(new RPCLink({ + fetch, + origin: 'http://api.example.com', + })) as any + + await expect(orpc.ping('input')).resolves.toEqual('pong') + + expect(fetch).toHaveBeenCalledOnce() + expect(fetch).toHaveBeenCalledWith( + 'http://api.example.com/ping', + expect.objectContaining({ + method: 'POST', + redirect: 'manual', + }), + expect.objectContaining({ + context: {}, + }), + ['ping'], + ) + }) - throw new Error('No procedure match') + it('supports custom headers and query parameters in origin', async () => { + const fetch = vi.fn(async () => { + return new Response(JSON.stringify({ json: 'pong' }), { + status: 200, + headers: { + 'content-type': 'application/json', }, }) - - await expect(rpcLink.call([], value, { context: {} })).rejects.toSatisfy((e) => { - expect(e).toBeInstanceOf(ORPCError) - expect(e.code).toBe('TEST') - expect(e.data).toEqual(expected) - - return true - }) - - return true - } - - it('should work on flat', async () => { - expect(await assertSuccessCase(value, expected)).toBe(true) - expect(await assertErrorCase(value, expected)).toBe(true) }) - it('should work on nested object', async () => { - expect(await assertSuccessCase({ data: value }, { data: expected })).toBe(true) - expect(await assertErrorCase({ data: value }, { data: expected })).toBe(true) - }) + const headers = vi.fn(() => ({ 'x-custom-header': 'value' })) + + const orpc = createORPCClient(new RPCLink({ + fetch, + origin: 'http://api.example.com/api?token=abc', + headers, + })) as any + + await expect(orpc.ping('input')).resolves.toEqual('pong') + + expect(fetch).toHaveBeenCalledOnce() + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('http://api.example.com/api?token=abc'), + expect.objectContaining({ + method: 'POST', + redirect: 'manual', + headers: expect.toSatisfy((h: Headers) => h.get('x-custom-header') === 'value'), + }), + expect.objectContaining({ + context: {}, + }), + ['ping'], + ) + }) - it('should work on complex object', async () => { - expect(await assertSuccessCase({ - '!@#$%^^&()[]>?<~_<:"~+!_': value, - 'list': [value], - 'map': new Map([[value, value]]), - 'set': new Set([value]), - 'nested': { - nested: value, - }, - }, { - '!@#$%^^&()[]>?<~_<:"~+!_': expected, - 'list': [expected], - 'map': new Map([[expected, expected]]), - 'set': new Set([expected]), - 'nested': { - nested: expected, - }, - })).toBe(true) - - expect(await assertErrorCase({ - '!@#$%^^&()[]>?<~_<:"~+!_': value, - 'list': [value], - 'map': new Map([[value, value]]), - 'set': new Set([value]), - 'nested': { - nested: value, - }, - }, { - '!@#$%^^&()[]>?<~_<:"~+!_': expected, - 'list': [expected], - 'map': new Map([[expected, expected]]), - 'set': new Set([expected]), - 'nested': { - nested: expected, + it('uses default global fetch when fetch option is not provided', async ({ onTestFinished }) => { + const fetchSpy = vi.fn(async () => { + return new Response(JSON.stringify({ json: 'pong' }), { + status: 200, + headers: { + 'content-type': 'application/json', }, - })).toBe(true) + }) }) - }) -}) - -describe('standardRPCLink: event-iterator', async () => { - const OriginalRequest = globalThis.Request - - beforeEach(() => { - (globalThis as any).Request = class Request extends OriginalRequest { - constructor(input: any, init: any) { - super(input, { - ...init, - duplex: 'half', - } as any) - } - } - }) - - afterEach(() => { - globalThis.Request = OriginalRequest - }) - - const handler = vi.fn(({ input }) => input) - - const rpcHandler = new RPCHandler(os.handler(handler), { - strictGetMethodPluginEnabled: false, - }) - - const rpcLink = new RPCLink({ - url: 'http://api.example.com', - fetch: async (request) => { - const { matched, response } = await rpcHandler.handle(new Request(request)) - if (matched) { - return response - } + const originalFetch = globalThis.fetch + ;(globalThis as any).fetch = fetchSpy - throw new Error('No procedure match') - }, - }) - - it('on success', async () => { - const output = await rpcLink.call([], (async function* () { - yield 1 - yield withEventMeta({ hello: 2 }, { id: '29224', retry: 8393 }) - return withEventMeta({ hello: 3 }, { id: '391', retry: 28973 }) - })(), { context: {} }) as any - - expect(await output.next()).toEqual({ value: 1, done: false }) - - expect(await output.next()).toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual({ hello: 2 }) - expect(getEventMeta(value)).toEqual(expect.objectContaining({ id: '29224', retry: 8393 })) - - return true + onTestFinished(() => { + globalThis.fetch = originalFetch }) - expect(await output.next()).toSatisfy(({ value, done }) => { - expect(done).toBe(true) - expect(value).toEqual({ hello: 3 }) - expect(getEventMeta(value)).toEqual(expect.objectContaining({ id: '391', retry: 28973 })) + const orpc = createORPCClient(new RPCLink({ + origin: 'http://api.example.com/', + })) as any + + await expect(orpc.ping('input')).resolves.toEqual('pong') + + expect(fetchSpy).toHaveBeenCalledOnce() + expect(fetchSpy).toHaveBeenCalledWith( + 'http://api.example.com/ping', + expect.objectContaining({ + method: 'POST', + redirect: 'manual', + }), + expect.objectContaining({ + context: {}, + }), + ['ping'], + ) + }) - return true + it('supports transport interceptors and toFetchBodyOptions', async () => { + const fetch = vi.fn(async () => { + return new Response(JSON.stringify({ json: 'pong' }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }) }) - expect(await output.next()).toEqual(expect.objectContaining({ value: undefined, done: true })) - }) + const fetchInterceptor = vi.fn(({ next }) => next()) - it('on error', async () => { - const output = await rpcLink.call([], (async function* () { - yield 1 - yield withEventMeta({ hello: 2 }, { id: '29224', retry: 8393 }) - throw withEventMeta(new ORPCError('INTERNAL', { - data: { hello: 3 }, - }), { id: '391', retry: 28973 }) - })(), { context: {} }) as any + const toFetchBodyOptions = { eventStream: { keepAlive: { enabled: true, comment: 'ok' } } } - expect(await output.next()).toEqual({ value: 1, done: false }) + const orpc = createORPCClient(new RPCLink({ + fetch, + origin: 'http://api.example.com', + fetchInterceptors: [fetchInterceptor], + toFetchBody: toFetchBodyOptions, + })) as any - expect(await output.next()).toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual({ hello: 2 }) - expect(getEventMeta(value)).toEqual(expect.objectContaining({ id: '29224', retry: 8393 })) + await expect(orpc.ping('input')).resolves.toEqual('pong') - return true - }) + expect(fetchInterceptor).toHaveBeenCalledOnce() + expect(fetchInterceptor).toHaveBeenCalledWith(expect.objectContaining({ + context: {}, + path: ['ping'], + url: 'http://api.example.com/ping', + init: expect.objectContaining({ + method: 'POST', + redirect: 'manual', + }), + })) - await expect(output.next()).rejects.toSatisfy((err) => { - expect(err).toBeInstanceOf(ORPCError) - expect(err.code).toBe('INTERNAL') - expect(err.data).toEqual({ hello: 3 }) - expect(getEventMeta(err)).toEqual(expect.objectContaining({ id: '391', retry: 28973 })) + expect(fetch).toHaveBeenCalledOnce() + expect(toFetchBody).toHaveBeenCalledWith({ json: 'input' }, {}, toFetchBodyOptions) + }) - return true + it('supports request without origin', async () => { + const fetch = vi.fn(async () => { + return new Response(JSON.stringify({ json: 'pong' }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }) }) - expect(await output.next()).toEqual(expect.objectContaining({ value: undefined, done: true })) + const orpc = createORPCClient(new RPCLink({ + fetch, + })) as any + + await expect(orpc.ping('input')).resolves.toEqual('pong') + + expect(fetch).toHaveBeenCalledOnce() + expect(fetch).toHaveBeenCalledWith( + '/ping', + expect.objectContaining({ + method: 'POST', + redirect: 'manual', + }), + expect.objectContaining({ + context: {}, + }), + ['ping'], + ) }) }) diff --git a/packages/client/src/adapters/fetch/rpc-link.ts b/packages/client/src/adapters/fetch/rpc-link.ts index 3070f9968..56d0f1294 100644 --- a/packages/client/src/adapters/fetch/rpc-link.ts +++ b/packages/client/src/adapters/fetch/rpc-link.ts @@ -1,22 +1,17 @@ import type { ClientContext } from '../../types' -import type { StandardRPCLinkOptions } from '../standard' -import type { LinkFetchClientOptions } from './link-fetch-client' -import { StandardRPCLink } from '../standard' -import { LinkFetchClient } from './link-fetch-client' +import type { RPCLinkCodecOptions, StandardLinkOptions } from '../standard' +import type { FetchLinkTransportOptions } from './transport' +import { RPCLinkCodec, StandardLink } from '../standard' +import { FetchLinkTransport } from './transport' export interface RPCLinkOptions - extends LinkFetchClientOptions, Omit, 'plugins'> {} + extends Omit, 'plugins'>, FetchLinkTransportOptions, RPCLinkCodecOptions { +} -/** - * The RPC Link communicates with the server using the RPC protocol. - * - * @see {@link https://orpc.dev/docs/client/rpc-link RPC Link Docs} - * @see {@link https://orpc.dev/docs/advanced/rpc-protocol RPC Protocol Docs} - */ -export class RPCLink extends StandardRPCLink { +export class RPCLink extends StandardLink { constructor(options: RPCLinkOptions) { - const linkClient = new LinkFetchClient(options) - - super(linkClient, options) + const codec = new RPCLinkCodec(options) + const transport = new FetchLinkTransport(options) + super(codec, transport, options) } } diff --git a/packages/client/src/adapters/fetch/transport.ts b/packages/client/src/adapters/fetch/transport.ts new file mode 100644 index 000000000..1408704a4 --- /dev/null +++ b/packages/client/src/adapters/fetch/transport.ts @@ -0,0 +1,89 @@ +import type { Interceptor, Promisable, Value } from '@orpc/shared' +import type { StandardLazyResponse, StandardRequest } from '@standardserver/core' +import type { ToFetchBodyOptions } from '@standardserver/fetch' +import type { ClientContext, ClientOptions } from '../../types' +import type { StandardLinkTransport } from '../standard' +import type { FetchLinkTransportPlugin } from './plugin' +import { intercept, value } from '@orpc/shared' +import { toFetchBody, toFetchHeaders, toStandardLazyResponse } from '@standardserver/fetch' +import { CompositeFetchLinkTransportPlugin } from './plugin' + +export interface FetchLinkTransportFetchInterceptorOptions extends ClientOptions { + path: string[] + url: string + init: RequestInit +} +export type FetchLinkTransportFetchInterceptor = Interceptor, Promise> + +export interface FetchLinkTransportOptions { + /** + * The origin to prepend to all request URLs, useful for CORS requests. + * + * @example 'https://api.example.com' + * @example 'http://localhost:3000' + */ + origin?: Value, [options: ClientOptions, path: string[]]> + + /** + * Options for how to convert the Standard Request body to a Fetch body, like event iterator options, etc. + */ + toFetchBody?: ToFetchBodyOptions | undefined + + /** + * Override the default fetch implementation. + * + * @default globalThis.fetch.bind(globalThis) + */ + fetch?(url: string, init: RequestInit, options: ClientOptions, path: string[]): Promise + + /** + * Interceptors that execute before the actual fetch call, useful for modifying the fetch parameters, adding logging, etc. + */ + fetchInterceptors?: FetchLinkTransportFetchInterceptor[] + + plugins?: FetchLinkTransportPlugin[] +} + +export class FetchLinkTransport implements StandardLinkTransport { + private readonly origin: FetchLinkTransportOptions['origin'] + private readonly fetch: Exclude['fetch'], undefined> + private readonly toFetchBodyOptions: FetchLinkTransportOptions['toFetchBody'] + private readonly fetchInterceptors: FetchLinkTransportOptions['fetchInterceptors'] + + constructor(options: FetchLinkTransportOptions) { + options = new CompositeFetchLinkTransportPlugin(options.plugins).initFetchLinkTransportOptions(options) + + this.origin = options.origin + this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis) + this.toFetchBodyOptions = options.toFetchBody + this.fetchInterceptors = options.fetchInterceptors + } + + async send(standardRequest: StandardRequest, path: string[], options: ClientOptions): Promise { + let origin = await value(this.origin, options, path) + if (origin?.endsWith('/')) { + origin = origin.slice(0, -1) + } + + const url = `${origin ?? ''}${standardRequest.url}` + const [body, standardHeaders] = toFetchBody(standardRequest.body, standardRequest.headers, this.toFetchBodyOptions) + + const init: RequestInit = { + body, + headers: toFetchHeaders(standardHeaders), + method: standardRequest.method, + signal: options.signal, + redirect: 'manual', + } + + const response = await intercept( + this.fetchInterceptors, + { ...options, url, path, init }, + ({ url, path, init, ...options }) => this.fetch(url, init, options, path), + ) + + const standardResponse = toStandardLazyResponse(response) + + return standardResponse + } +} diff --git a/packages/client/src/adapters/message-port/index.test.ts b/packages/client/src/adapters/message-port/index.test.ts new file mode 100644 index 000000000..fe981df20 --- /dev/null +++ b/packages/client/src/adapters/message-port/index.test.ts @@ -0,0 +1,3 @@ +it('exports RPCLink', async () => { + await expect(import('.')).resolves.toHaveProperty('RPCLink') +}) diff --git a/packages/client/src/adapters/message-port/index.ts b/packages/client/src/adapters/message-port/index.ts index ac48244b8..32a17d035 100644 --- a/packages/client/src/adapters/message-port/index.ts +++ b/packages/client/src/adapters/message-port/index.ts @@ -1,3 +1,3 @@ -export * from './link-client' export * from './message-port' export * from './rpc-link' +export * from './transport' diff --git a/packages/client/src/adapters/message-port/link-client.ts b/packages/client/src/adapters/message-port/link-client.ts deleted file mode 100644 index a78ac6e3e..000000000 --- a/packages/client/src/adapters/message-port/link-client.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { Promisable, Value } from '@orpc/shared' -import type { StandardLazyResponse, StandardRequest } from '@orpc/standard-server' -import type { DecodedRequestMessage, serializeResponseMessage } from '@orpc/standard-server-peer' -import type { ClientContext, ClientOptions } from '../../types' -import type { StandardLinkClient } from '../standard' -import type { SupportedMessagePort } from './message-port' -import { isObject, value } from '@orpc/shared' -import { experimental_ClientPeerWithoutCodec as ClientPeerWithoutCodec, decodeResponseMessage, deserializeResponseMessage, encodeRequestMessage, serializeRequestMessage } from '@orpc/standard-server-peer' -import { onMessagePortClose, onMessagePortMessage, postMessagePortMessage } from './message-port' - -export interface LinkMessagePortClientOptions { - port: SupportedMessagePort - - /** - * By default, oRPC serializes request/response messages to string/binary data before sending over message port. - * If needed, you can define the this option to utilize full power of [MessagePort: postMessage() method](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort/postMessage), - * such as transferring ownership of objects to the other side or support unserializable objects like `OffscreenCanvas`. - * - * @remarks - * - return null | undefined to disable this feature - * - * @warning Make sure your message port supports `transfer` before using this feature. - * @example - * ```ts - * experimental_transfer: (message, port) => { - * const transfer = deepFindTransferableObjects(message) // implement your own logic - * return transfer.length ? transfer : null // only enable when needed - * } - * ``` - * - * @see {@link https://orpc.dev/docs/adapters/message-port#transfer Message Port Transfer Docs} - */ - experimental_transfer?: Value, [message: DecodedRequestMessage, port: SupportedMessagePort]> -} - -export class LinkMessagePortClient implements StandardLinkClient { - private readonly peer: ClientPeerWithoutCodec - - constructor(options: LinkMessagePortClientOptions) { - this.peer = new ClientPeerWithoutCodec(async (message) => { - const [id, type, payload] = message - const transfer = await value(options.experimental_transfer, message, options.port) - - if (transfer) { - postMessagePortMessage(options.port, serializeRequestMessage(id, type, payload), transfer) - } - else { - postMessagePortMessage(options.port, await encodeRequestMessage(id, type, payload)) - } - }) - - onMessagePortMessage(options.port, async (message) => { - if (isObject(message)) { - await this.peer.message(deserializeResponseMessage(message as any as ReturnType)) - } - else { - await this.peer.message(await decodeResponseMessage(message)) - } - }) - - onMessagePortClose(options.port, () => { - this.peer.close() - }) - } - - async call(request: StandardRequest, _options: ClientOptions, _path: readonly string[], _input: unknown): Promise { - const response = await this.peer.request(request) - return { ...response, body: () => Promise.resolve(response.body) } - } -} diff --git a/packages/client/src/adapters/message-port/message-port.test.ts b/packages/client/src/adapters/message-port/message-port.test.ts index d70ed9893..3b451a6a6 100644 --- a/packages/client/src/adapters/message-port/message-port.test.ts +++ b/packages/client/src/adapters/message-port/message-port.test.ts @@ -1,186 +1,144 @@ import { onMessagePortClose, onMessagePortMessage, postMessagePortMessage } from './message-port' describe('postMessagePortMessage', () => { - it('calls postMessage on the port', () => { - const mockPort = { - addEventListener: vi.fn(), - postMessage: vi.fn(), - } - const data = 'hello' - postMessagePortMessage(mockPort, data) - expect(mockPort.postMessage).toBeCalledTimes(1) - expect(mockPort.postMessage).toHaveBeenCalledWith(data) + it('posts message without transfer', () => { + const port = { postMessage: vi.fn() } as any + + postMessagePortMessage(port, 'hello') + + expect(port.postMessage).toHaveBeenCalledTimes(1) + expect(port.postMessage).toHaveBeenCalledWith('hello') }) - it('calls postMessage on the port with transfer', () => { - const mockPort = { - addEventListener: vi.fn(), - postMessage: vi.fn(), - } - const data = new Uint8Array([1, 2, 3]) - const transfer = [data.buffer] - postMessagePortMessage(mockPort, data, transfer) - expect(mockPort.postMessage).toBeCalledTimes(1) - expect(mockPort.postMessage).toHaveBeenCalledWith(data, transfer) + it('posts message with transfer', () => { + const port = { postMessage: vi.fn() } as any + const transferable = new Uint8Array([1, 2, 3]).buffer + + postMessagePortMessage(port, 'hello', [transferable]) + + expect(port.postMessage).toHaveBeenCalledTimes(1) + expect(port.postMessage).toHaveBeenCalledWith('hello', [transferable]) }) }) describe('onMessagePortMessage', () => { - it('uses addEventListener if available', () => { + it('uses addEventListener for MessagePort', () => { + const callback = vi.fn() const port = { addEventListener: vi.fn(), - postMessage: vi.fn(), - } + } as any - const callback = vi.fn() onMessagePortMessage(port, callback) - expect(port.addEventListener).toBeCalledTimes(1) - const [event, handler] = port.addEventListener.mock.calls[0]! - expect(event).toBe('message') + expect(port.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)) + + const handler = port.addEventListener.mock.calls[0]![1] + handler({ data: 'hello' }) - handler({ data: 'test-data' }) - expect(callback).toHaveBeenCalledWith('test-data') + expect(callback).toHaveBeenCalledWith('hello') }) - it('uses on if available', () => { + it('uses on for MessagePortMainLike', () => { + const callback = vi.fn() const port = { on: vi.fn(), - postMessage: vi.fn(), - } + } as any - const callback = vi.fn() onMessagePortMessage(port, callback) - expect(port.on).toBeCalledTimes(1) - const [event, handler] = port.on.mock.calls[0]! - expect(event).toBe('message') + expect(port.on).toHaveBeenCalledWith('message', expect.any(Function)) - handler({ data: 'test-data' }) - expect(callback).toHaveBeenCalledWith('test-data') - }) - - it('uses onMessage if available', () => { - const port = { - onMessage: { - addListener: vi.fn(), - }, - onDisconnect: { - addListener: vi.fn(), - }, - postMessage: vi.fn(), - } - - const callback = vi.fn() - onMessagePortMessage(port, callback) + const handler = port.on.mock.calls[0]![1] - expect(port.onMessage.addListener).toBeCalledTimes(1) - const listener = port.onMessage.addListener.mock.calls[0]![0] + handler({ data: 'hello' }) + expect(callback).toHaveBeenCalledWith('hello') - listener('test-data') - expect(callback).toHaveBeenCalledWith('test-data') + // event?.data handles undefined event + handler(undefined) + expect(callback).toHaveBeenCalledWith(undefined) }) - it('prefer addEventListener over on', () => { + it('uses onMessage.addListener for BrowserPortLike', () => { + const callback = vi.fn() const port = { - on: vi.fn(), - addEventListener: vi.fn(), - postMessage: vi.fn(), - } + onMessage: { addListener: vi.fn() }, + } as any - const callback = vi.fn() onMessagePortMessage(port, callback) - expect(port.on).toBeCalledTimes(0) - expect(port.addEventListener).toBeCalledTimes(1) - const [event, handler] = port.addEventListener.mock.calls[0]! - expect(event).toBe('message') + expect(port.onMessage.addListener).toHaveBeenCalledWith(expect.any(Function)) + + const handler = port.onMessage.addListener.mock.calls[0]![0] + handler('hello') - handler({ data: 'test-data' }) - expect(callback).toHaveBeenCalledWith('test-data') + expect(callback).toHaveBeenCalledWith('hello') }) - it('throws if invalid port', () => { - expect(() => onMessagePortMessage({} as any, () => {})).toThrowError() + it('throws on unsupported port', () => { + const callback = vi.fn() + const port = {} as any + + expect(() => onMessagePortMessage(port, callback)).toThrow( + 'Cannot find a addEventListener/on/onMessage method on the port', + ) }) }) describe('onMessagePortClose', () => { - it('uses addEventListener if available', () => { + it('uses addEventListener for MessagePort', () => { + const callback = vi.fn() const port = { addEventListener: vi.fn(), - postMessage: vi.fn(), - } + } as any - const callback = vi.fn() onMessagePortClose(port, callback) - expect(port.addEventListener).toBeCalledTimes(1) - const [event, handler] = port.addEventListener.mock.calls[0]! - expect(event).toBe('close') + expect(port.addEventListener).toHaveBeenCalledWith('close', expect.any(Function)) + const handler = port.addEventListener.mock.calls[0]![1] handler() - expect(callback).toHaveBeenCalled() + + expect(callback).toHaveBeenCalledTimes(1) }) - it('uses on if available', () => { + it('uses on for MessagePortMainLike', () => { + const callback = vi.fn() const port = { on: vi.fn(), - postMessage: vi.fn(), - } + } as any - const callback = vi.fn() onMessagePortClose(port, callback) - expect(port.on).toBeCalledTimes(1) - const [event, handler] = port.on.mock.calls[0]! - expect(event).toBe('close') + expect(port.on).toHaveBeenCalledWith('close', expect.any(Function)) + + const handler = port.on.mock.calls[0]![1] + handler() - handler({}) - expect(callback).toHaveBeenCalled() + expect(callback).toHaveBeenCalledTimes(1) }) - it('uses onDisconnect if available', () => { + it('uses onDisconnect.addListener for BrowserPortLike', () => { + const callback = vi.fn() const port = { - onMessage: { - addListener: vi.fn(), - }, - onDisconnect: { - addListener: vi.fn(), - }, - postMessage: vi.fn(), - } + onDisconnect: { addListener: vi.fn() }, + } as any - const callback = vi.fn() onMessagePortClose(port, callback) - expect(port.onDisconnect.addListener).toBeCalledTimes(1) - const listener = port.onDisconnect.addListener.mock.calls[0]![0] + expect(port.onDisconnect.addListener).toHaveBeenCalledWith(expect.any(Function)) - listener() - expect(callback).toHaveBeenCalled() - }) + const handler = port.onDisconnect.addListener.mock.calls[0]![0] + handler() - it('prefer addEventListener over on', () => { - const port = { - on: vi.fn(), - addEventListener: vi.fn(), - postMessage: vi.fn(), - } + expect(callback).toHaveBeenCalledTimes(1) + }) + it('throws on unsupported port', () => { const callback = vi.fn() - onMessagePortClose(port, callback) - - expect(port.on).toBeCalledTimes(0) - expect(port.addEventListener).toBeCalledTimes(1) - const [event, handler] = port.addEventListener.mock.calls[0]! - expect(event).toBe('close') - - handler({}) - expect(callback).toHaveBeenCalled() - }) + const port = {} as any - it('throws if invalid port', () => { - expect(() => onMessagePortClose({} as any, () => {})).toThrowError() + expect(() => onMessagePortClose(port, callback)).toThrow( + 'Cannot find a addEventListener/on/onDisconnect method on the port', + ) }) }) diff --git a/packages/client/src/adapters/message-port/message-port.ts b/packages/client/src/adapters/message-port/message-port.ts index 919a08721..eaa83bb35 100644 --- a/packages/client/src/adapters/message-port/message-port.ts +++ b/packages/client/src/adapters/message-port/message-port.ts @@ -22,7 +22,7 @@ export interface BrowserPortLike { export type SupportedMessagePort = Pick | MessagePortMainLike | BrowserPortLike /** - * Message port can support [The structured clone algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) + * Message port can support [The structured clone algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) */ export type SupportedMessagePortData = any @@ -70,12 +70,12 @@ export function onMessagePortClose(port: SupportedMessagePort, callback: () => v * So we need check "addEventListener" before "on" */ if ('addEventListener' in port) { - port.addEventListener('close', async () => { + port.addEventListener('close', () => { callback() }) } else if ('on' in port) { - port.on('close', async () => { + port.on('close', () => { callback() }) } diff --git a/packages/client/src/adapters/message-port/rpc-link.test.ts b/packages/client/src/adapters/message-port/rpc-link.test.ts index 6fa8ee525..1b63ccb23 100644 --- a/packages/client/src/adapters/message-port/rpc-link.test.ts +++ b/packages/client/src/adapters/message-port/rpc-link.test.ts @@ -1,117 +1,164 @@ -import { MessageChannel } from 'node:worker_threads' -import { isObject } from '@orpc/shared' -import { decodeRequestMessage, deserializeRequestMessage, encodeResponseMessage, MessageType, serializeResponseMessage } from '@orpc/standard-server-peer' +import { sleep } from '@orpc/shared' +import { decodePeerMessage, encodePeerMessage } from '@standardserver/peer' import { createORPCClient } from '../../client' import { RPCLink } from './rpc-link' describe('rpcLink', () => { - let orpc: any - let receivedMessages: any[] - let clientPort: any - let serverPort: any - let transfer: ReturnType - beforeEach(() => { - const channel = new MessageChannel() - clientPort = channel.port1 - serverPort = channel.port2 - - clientPort.start() - serverPort.start() + vi.clearAllMocks() + }) - receivedMessages = [] - serverPort.addEventListener('message', (event: any) => { - receivedMessages.push(event.data) + let onMessage: any + let onClose: any + + const createPort = () => { + const port = { + addEventListener: vi.fn((event: string, callback: any) => { + if (event === 'message') + onMessage = callback + if (event === 'close') + onClose = callback + }), + postMessage: vi.fn(), + } + + return port + } + + const createResponseMessage = async ({ + id, + body = { json: 'pong' }, + status = 200, + prefix, + }: { id: string, body?: unknown, status?: number, prefix?: string }) => { + return encodePeerMessage({ + id, + kind: 'response', + json: { body, status, headers: {} }, + }, prefix ? { prefix } : undefined) + } + + const decodeRequest = (sent: any, prefix?: string) => { + return decodePeerMessage(sent, prefix ? { prefix } : undefined) as { + matched: true + message: { id: string, kind: string, json: any } + } + } + + it.each([ + ['string', async (encoded: string | Uint8Array) => encoded], + ['bytes', async (encoded: string | Uint8Array) => new TextEncoder().encode(encoded as string)], + ])('handles %s response', async (_type, transform) => { + const port = createPort() + const orpc = createORPCClient(new RPCLink({ port })) as any + + const promise = expect(orpc.ping('input')).resolves.toEqual('pong') + + await vi.waitFor(() => expect(port.postMessage).toHaveBeenCalledTimes(1)) + + const decoded = decodeRequest(port.postMessage.mock.calls[0]![0]) + + expect(decoded.matched).toBe(true) + expect(decoded.message.kind).toBe('request') + expect(decoded.message.id).toBeTypeOf('string') + expect(decoded.message.json).toEqual({ + url: '/ping', + body: { json: 'input' }, + headers: {}, + method: 'POST', }) - transfer = vi.fn() - orpc = createORPCClient(new RPCLink({ - port: clientPort, - experimental_transfer: transfer, - })) + const raw = await createResponseMessage({ id: decoded.message.id }) + onMessage({ data: await transform(raw) }) + + await promise }) - it('on success', async () => { - expect(orpc.ping('input')).resolves.toEqual('pong') + it('aborts pending requests on close', async () => { + const port = createPort() + const orpc = createORPCClient(new RPCLink({ port })) as any - await vi.waitFor(() => expect(receivedMessages.length).toBe(1)) + const promise = expect(orpc.ping('input')).rejects.toThrow() - const [id, , payload] = (await decodeRequestMessage(receivedMessages[0])) + await sleep(0) - expect(id).toBeTypeOf('string') - expect(payload).toEqual({ - url: new URL('http://orpc/ping'), - body: { json: 'input' }, - headers: {}, - method: 'POST', - }) + onClose() - serverPort.postMessage( - await encodeResponseMessage(id, MessageType.RESPONSE, { body: { json: 'pong' }, status: 200, headers: {} }), - ) + await promise }) - it('on success with blob', async () => { - expect(orpc.ping(new Blob(['input']))).resolves.toEqual('pong') + it('can encode messages with prefix', async () => { + const port = createPort() + const orpc = createORPCClient(new RPCLink({ + port, + encodePeerMessage: { prefix: 'orpc:' }, + })) as any - await vi.waitFor(() => expect(receivedMessages.length).toBe(1)) + const promise = expect(orpc.ping('input')).resolves.toEqual('pong') - const [id, , payload] = (await decodeRequestMessage(receivedMessages[0])) + await vi.waitFor(() => expect(port.postMessage).toHaveBeenCalledTimes(1)) - expect(id).toBeTypeOf('string') - expect(payload).toEqual({ - url: new URL('http://orpc/ping'), - body: expect.any(FormData), - headers: expect.any(Object), - method: 'POST', - }) + const decoded = decodeRequest(port.postMessage.mock.calls[0]![0], 'orpc:') - serverPort.postMessage( - await encodeResponseMessage(id, MessageType.RESPONSE, { body: { json: 'pong' }, status: 200, headers: {} }), - ) - }) + expect(decoded.matched).toBe(true) + expect(decoded.message.kind).toBe('request') - it('on success with transfer', async () => { - const array = new Uint8Array([1, 2, 3]) + onMessage({ data: await createResponseMessage({ id: decoded.message.id }) }) - transfer.mockResolvedValueOnce([array.buffer]) + await promise + }) - const promise = expect(orpc.ping(array)).resolves.toEqual('pong') + it('can decode messages with prefix and ignore messages with mismatched prefix', async () => { + const port = createPort() + const orpc = createORPCClient(new RPCLink({ + port, + decodePeerMessage: { prefix: 'orpc:' }, + })) as any - await vi.waitFor(() => expect(receivedMessages.length).toBe(1)) - expect(receivedMessages[0]).toSatisfy(isObject) - const [id, type, payload] = deserializeRequestMessage(receivedMessages[0]) + const promise = expect(orpc.ping('input')).resolves.toEqual('pong') - expect(array.byteLength).toBe(0) // transferred so length is 0 + await vi.waitFor(() => expect(port.postMessage).toHaveBeenCalledTimes(1)) - expect(transfer).toHaveBeenCalledTimes(1) - expect(transfer).toHaveBeenCalledWith([id, type, expect.objectContaining({ - url: new URL('http://orpc/ping'), - body: { json: expect.toBeOneOf([array]) }, - headers: {}, - method: 'POST', - })], expect.toBeOneOf([clientPort])) + const decoded = decodeRequest(port.postMessage.mock.calls[0]![0]) + const id = decoded.message.id - expect(id).toBeTypeOf('string') - expect(payload).toEqual({ - url: new URL('http://orpc/ping'), - body: { json: expect.toSatisfy(v => v !== array && v instanceof Uint8Array && v.byteLength === 3) }, - headers: {}, - method: 'POST', - }) + // Message with wrong prefix — should be ignored + onMessage({ data: await createResponseMessage({ id, prefix: 'wrong:' }) }) - serverPort.postMessage( - serializeResponseMessage(id, MessageType.RESPONSE, { body: { json: 'pong' }, status: 200, headers: {} }), - ) + // Correct message — should be processed + onMessage({ data: await createResponseMessage({ id, prefix: 'orpc:' }) }) await promise }) - it('on close', async () => { - expect(orpc.ping('input')).rejects.toThrow(/aborted/) + it('can receive and send un-encoded messages with transfer option (structured clone)', async () => { + const port = createPort() + const transferable = new Uint8Array([1, 2, 3]).buffer + const transfer = vi.fn(async () => [transferable]) + const orpc = createORPCClient(new RPCLink({ + port, + experimental_transfer: transfer, + })) as any + + const promise = expect(orpc.ping('input')).resolves.toEqual('pong') + + await vi.waitFor(() => expect(port.postMessage).toHaveBeenCalledTimes(1)) + + const message = port.postMessage.mock.calls[0]![0] - await new Promise(resolve => setTimeout(resolve, 0)) + expect(port.postMessage).toHaveBeenCalledWith( + message, + [transferable], + ) + + onMessage({ + data: { + id: message.id, + kind: 'response', + json: { body: { json: 'pong' }, status: 200, headers: {} }, + }, + }) - serverPort.close() + await promise }) }) diff --git a/packages/client/src/adapters/message-port/rpc-link.ts b/packages/client/src/adapters/message-port/rpc-link.ts index 15605077f..0298e0637 100644 --- a/packages/client/src/adapters/message-port/rpc-link.ts +++ b/packages/client/src/adapters/message-port/rpc-link.ts @@ -1,21 +1,17 @@ import type { ClientContext } from '../../types' -import type { StandardRPCLinkOptions } from '../standard' -import type { LinkMessagePortClientOptions } from './link-client' -import { StandardRPCLink } from '../standard' -import { LinkMessagePortClient } from './link-client' +import type { RPCLinkCodecOptions, StandardLinkOptions } from '../standard' +import type { MessagePortLinkTransportOptions } from './transport' +import { RPCLinkCodec, StandardLink } from '../standard' +import { MessagePortLinkTransport } from './transport' export interface RPCLinkOptions - extends Omit, 'url' | 'method' | 'fallbackMethod' | 'maxUrlLength'>, LinkMessagePortClientOptions {} + extends StandardLinkOptions, MessagePortLinkTransportOptions, RPCLinkCodecOptions { +} -/** - * The RPC Link for common message port implementations. - * - * @see {@link https://orpc.dev/docs/client/rpc-link RPC Link Docs} - * @see {@link https://orpc.dev/docs/adapters/message-port Message Port Adapter Docs} - */ -export class RPCLink extends StandardRPCLink { +export class RPCLink extends StandardLink { constructor(options: RPCLinkOptions) { - const linkClient = new LinkMessagePortClient(options) - super(linkClient, { ...options, url: 'http://orpc' }) + const codec = new RPCLinkCodec(options) + const transport = new MessagePortLinkTransport(options) + super(codec, transport, options) } } diff --git a/packages/client/src/adapters/message-port/transport.ts b/packages/client/src/adapters/message-port/transport.ts new file mode 100644 index 000000000..1cff85fcc --- /dev/null +++ b/packages/client/src/adapters/message-port/transport.ts @@ -0,0 +1,80 @@ +import type { Promisable, Value } from '@orpc/shared' +import type { StandardLazyResponse, StandardRequest } from '@standardserver/core' +import type { DecodePeerMessageOptions, EncodePeerMessageOptions } from '@standardserver/peer' +import type { ClientContext, ClientOptions } from '../../types' +import type { StandardLinkTransport } from '../standard' +import type { SupportedMessagePort } from './message-port' +import { isPlainObject, toStringOrBytes, value } from '@orpc/shared' +import { ClientPeer, decodePeerMessage, encodePeerMessage, isServerPeerSendMessage } from '@standardserver/peer' +import { onMessagePortClose, onMessagePortMessage, postMessagePortMessage } from './message-port' + +type DecodedRequestMessage = ConstructorParameters[0] extends (message: infer TMessage) => unknown + ? TMessage + : never + +export interface MessagePortLinkTransportOptions<_T extends ClientContext> { + port: SupportedMessagePort + + /** + * By default, oRPC serializes request/response messages to string/binary data before sending over message port. + * If needed, define this option to utilize full power of [MessagePort: postMessage() method](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort/postMessage), + * such as transferring ownership of objects to the other side or support unserializable objects like `OffscreenCanvas`. + * + * @remarks + * - return null | undefined to disable this feature + * + * @warning Make sure your message port supports `transfer` before using this feature. + */ + experimental_transfer?: Value, [message: DecodedRequestMessage, port: SupportedMessagePort]> + + /** + * Options for encoding peer messages. such as `prefix` for distinguishing messages on the same channel.. + */ + encodePeerMessage?: EncodePeerMessageOptions | undefined + + /** + * Options for decoding peer messages, such as `prefix` for distinguishing messages on the same channel. + */ + decodePeerMessage?: DecodePeerMessageOptions | undefined +} + +export class MessagePortLinkTransport implements StandardLinkTransport { + private readonly peer: ClientPeer + + constructor({ port, experimental_transfer, encodePeerMessage: encodePeerMessageOptions, decodePeerMessage: decodePeerMessageOptions }: MessagePortLinkTransportOptions) { + this.peer = new ClientPeer(async (message) => { + const transfer = await value(experimental_transfer, message, port) + + if (transfer) { + postMessagePortMessage(port, message, transfer) + } + else { + postMessagePortMessage(port, await encodePeerMessage(message, encodePeerMessageOptions)) + } + }) + + onMessagePortMessage(port, async (message) => { + if (isPlainObject(message)) { + await this.peer.message(message as any) + return + } + + const encodedMessage = await toStringOrBytes(message) + + const result = decodePeerMessage(encodedMessage, decodePeerMessageOptions) + + if (result.matched && isServerPeerSendMessage(result.message)) { + await this.peer.message(result.message) + } + }) + + onMessagePortClose(port, () => { + this.peer.close() + }) + } + + async send(standardRequest: StandardRequest, _path: string[], _options: ClientOptions): Promise { + const standardResponse = await this.peer.request(standardRequest) + return standardResponse + } +} diff --git a/packages/client/src/adapters/standard/codec.ts b/packages/client/src/adapters/standard/codec.ts new file mode 100644 index 000000000..cf6a19ec3 --- /dev/null +++ b/packages/client/src/adapters/standard/codec.ts @@ -0,0 +1,20 @@ +import type { Promisable } from '@orpc/shared' +import type { StandardLazyResponse, StandardRequest } from '@standardserver/core' +import type { AnyORPCError } from '../../error' +import type { ClientContext, ClientOptions } from '../../types' + +export type StandardLinkCodecDecodedResponse = { kind: 'output', output: unknown } | { kind: 'error', error: AnyORPCError } + +export interface StandardLinkCodec { + encodeInput( + input: unknown, + path: string[], + options: ClientOptions + ): Promisable + + decodeResponse( + response: StandardLazyResponse, + path: string[], + options: ClientOptions + ): Promisable +} diff --git a/packages/client/src/adapters/standard/index.test.ts b/packages/client/src/adapters/standard/index.test.ts new file mode 100644 index 000000000..97ea9309e --- /dev/null +++ b/packages/client/src/adapters/standard/index.test.ts @@ -0,0 +1,6 @@ +it('exports StandardLink, RPCLinkCodec', async () => { + await expect(import('.')).resolves.toMatchObject({ + StandardLink: expect.any(Function), + RPCLinkCodec: expect.any(Function), + }) +}) diff --git a/packages/client/src/adapters/standard/index.ts b/packages/client/src/adapters/standard/index.ts index 9201feab6..5427ab9a8 100644 --- a/packages/client/src/adapters/standard/index.ts +++ b/packages/client/src/adapters/standard/index.ts @@ -1,8 +1,5 @@ +export * from './codec' export * from './link' export * from './plugin' -export * from './rpc-json-serializer' -export * from './rpc-link' export * from './rpc-link-codec' -export * from './rpc-serializer' -export * from './types' -export * from './utils' +export * from './transport' diff --git a/packages/client/src/adapters/standard/link.test.ts b/packages/client/src/adapters/standard/link.test.ts index 8aa0445ec..626909d03 100644 --- a/packages/client/src/adapters/standard/link.test.ts +++ b/packages/client/src/adapters/standard/link.test.ts @@ -1,4 +1,8 @@ -import type { StandardRequest, StandardResponse } from '@orpc/standard-server' +import type { StandardLazyResponse, StandardRequest } from '@standardserver/core' +import type { StandardLinkCodec } from './codec' +import type { StandardLinkTransport } from './transport' +import { isAsyncIteratorObject } from '@orpc/shared' +import { ORPCError } from '../../error' import { StandardLink } from './link' beforeEach(() => { @@ -6,35 +10,48 @@ beforeEach(() => { }) describe('standardLink', () => { - const codec = { encode: vi.fn(), decode: vi.fn() } - const client = { call: vi.fn() } + function makeCodec(): StandardLinkCodec { + return { + encodeInput: vi.fn(), + decodeResponse: vi.fn(), + } + } + + function makeTransport(): StandardLinkTransport { + return { + send: vi.fn(), + } + } it('workflow is correct', async () => { const interceptor = vi.fn(({ next }) => next()) - const clientInterceptor = vi.fn(({ next }) => next()) + const transportInterceptor = vi.fn(({ next }) => next()) + + const codec = makeCodec() + const transport = makeTransport() - const link = new StandardLink(codec, client, { + const link = new StandardLink(codec, transport, { interceptors: [interceptor], - clientInterceptors: [clientInterceptor], + transportInterceptors: [transportInterceptor], }) const __standardRequest: StandardRequest = { method: 'POST', - url: new URL('http://localhost:3000'), + url: '/planet/create', headers: {}, body: '__standard_request__', signal: AbortSignal.timeout(100), } - const __standardResponse: StandardResponse = { + const __standardResponse: StandardLazyResponse = { status: 200, headers: {}, - body: '__standard_response__', + resolveBody: () => Promise.resolve('__body__'), } - codec.encode.mockReturnValueOnce(__standardRequest) - client.call.mockResolvedValueOnce(__standardResponse) - codec.decode.mockReturnValueOnce('__output__') + vi.mocked(codec.encodeInput).mockResolvedValueOnce(__standardRequest) + vi.mocked(transport.send).mockResolvedValueOnce(__standardResponse) + vi.mocked(codec.decodeResponse).mockResolvedValueOnce({ kind: 'output', output: '__output__' }) const context = { context: true } const signal = AbortSignal.timeout(100) @@ -44,14 +61,26 @@ describe('standardLink', () => { expect(output).toEqual('__output__') - expect(codec.encode).toHaveBeenCalledTimes(1) - expect(codec.encode).toHaveBeenCalledWith(['planet', 'create'], { name: 'Earth' }, { context, signal, lastEventId }) - - expect(client.call).toHaveBeenCalledTimes(1) - expect(client.call).toHaveBeenCalledWith(__standardRequest, { context, signal, lastEventId }, ['planet', 'create'], { name: 'Earth' }) - - expect(codec.decode).toHaveBeenCalledTimes(1) - expect(codec.decode).toHaveBeenCalledWith(__standardResponse, { context, signal, lastEventId }, ['planet', 'create'], { name: 'Earth' }) + expect(codec.encodeInput).toHaveBeenCalledTimes(1) + expect(codec.encodeInput).toHaveBeenCalledWith( + { name: 'Earth' }, + ['planet', 'create'], + { context, signal, lastEventId }, + ) + + expect(transport.send).toHaveBeenCalledTimes(1) + expect(transport.send).toHaveBeenCalledWith( + __standardRequest, + ['planet', 'create'], + { context, signal, lastEventId }, + ) + + expect(codec.decodeResponse).toHaveBeenCalledTimes(1) + expect(codec.decodeResponse).toHaveBeenCalledWith( + __standardResponse, + ['planet', 'create'], + { context, signal, lastEventId }, + ) expect(interceptor).toHaveBeenCalledTimes(1) expect(interceptor).toHaveBeenCalledWith({ @@ -62,32 +91,86 @@ describe('standardLink', () => { signal, lastEventId, }) + await expect(interceptor.mock.results[0]!.value).resolves.toBe('__output__') - expect(clientInterceptor).toHaveBeenCalledTimes(1) - expect(clientInterceptor).toHaveBeenCalledWith({ + expect(transportInterceptor).toHaveBeenCalledTimes(1) + expect(transportInterceptor).toHaveBeenCalledWith({ next: expect.any(Function), request: __standardRequest, path: ['planet', 'create'], - input: { name: 'Earth' }, context, signal, lastEventId, }) + await expect(transportInterceptor.mock.results[0]!.value).resolves.toBe(__standardResponse) }) - it('plugins', () => { - const init = vi.fn() + it('throws decoded error when response kind is error', async () => { + const codec = makeCodec() + const transport = makeTransport() + const link = new StandardLink(codec, transport) + + const error = new ORPCError('NOT_FOUND') + + vi.mocked(codec.encodeInput).mockResolvedValueOnce({ + method: 'POST', + url: '/test', + headers: {}, + body: undefined, + }) + vi.mocked(transport.send).mockResolvedValueOnce({ + status: 404, + headers: {}, + resolveBody: () => Promise.resolve(undefined), + }) + vi.mocked(codec.decodeResponse).mockResolvedValueOnce({ kind: 'error', error }) + + await expect(link.call(['test'], 'input', { context: {} })).rejects.toThrow(error) + }) - const options = { - plugins: [ - { init }, - ], - interceptors: [vi.fn()], - clientInterceptors: [vi.fn()], + it('traces input & output event iterator', async () => { + const codec = makeCodec() + const transport = makeTransport() + const link = new StandardLink(codec, transport) + + async function* gen() { + yield 1 + yield 2 } - const link = new StandardLink(codec, client, options) + const input = gen() + const output = gen() + + vi.mocked(codec.encodeInput).mockResolvedValueOnce({ + method: 'POST', + url: '/test', + headers: {}, + body: undefined, + }) + vi.mocked(transport.send).mockResolvedValueOnce({ + status: 200, + headers: {}, + resolveBody: () => Promise.resolve(undefined), + }) + vi.mocked(codec.decodeResponse).mockResolvedValueOnce({ kind: 'output', output }) + + const tracedOutput = await link.call(['test'], input, { context: {} }) + + const passedInput = vi.mocked(codec.encodeInput).mock.calls[0]![0] + expect(isAsyncIteratorObject(passedInput)).not.toBe(input) // should be a wrapped version of the original input + expect(isAsyncIteratorObject(passedInput)).toBe(true) + + expect(tracedOutput).not.toBe(output) // should be a wrapped version of the original output + expect(isAsyncIteratorObject(tracedOutput)).toBe(true) + }) + + it('supports plugins', async () => { + const codec = makeCodec() + const transport = makeTransport() + + const link = new StandardLink(codec, transport, { + plugins: [{ name: 'test-plugin', init: () => ({ interceptors: [async () => '__INTERCEPTED__'] }) }], + }) - expect(init).toHaveBeenCalledOnce() - expect(init).toHaveBeenCalledWith(options) + await expect(link.call(['test'], 'input', { context: {} })).resolves.toBe('__INTERCEPTED__') }) }) diff --git a/packages/client/src/adapters/standard/link.ts b/packages/client/src/adapters/standard/link.ts index e841f4d52..73906a56b 100644 --- a/packages/client/src/adapters/standard/link.ts +++ b/packages/client/src/adapters/standard/link.ts @@ -1,109 +1,141 @@ import type { Interceptor } from '@orpc/shared' -import type { StandardLazyResponse, StandardRequest } from '@orpc/standard-server' +import type { StandardLazyResponse, StandardRequest } from '@standardserver/core' import type { ClientContext, ClientLink, ClientOptions } from '../../types' +import type { StandardLinkCodec } from './codec' import type { StandardLinkPlugin } from './plugin' -import type { StandardLinkClient, StandardLinkCodec } from './types' -import { asyncIteratorWithSpan, getGlobalOtelConfig, intercept, isAsyncIteratorObject, ORPC_NAME, runWithSpan, toArray } from '@orpc/shared' +import type { StandardLinkTransport } from './transport' +import { getOpenTelemetryConfig, intercept, isAsyncIteratorObject, ORPC_NAME, override, runWithSpan, traceAsyncIterator } from '@orpc/shared' import { CompositeStandardLinkPlugin } from './plugin' export interface StandardLinkInterceptorOptions extends ClientOptions { - path: readonly string[] + path: string[] input: unknown } +export type StandardLinkInterceptor = Interceptor, Promise> -export interface StandardLinkClientInterceptorOptions extends StandardLinkInterceptorOptions { +export interface StandardLinkTransportInterceptorOptions extends ClientOptions { + path: string[] request: StandardRequest } +export type StandardLinkTransportInterceptor = Interceptor, Promise> export interface StandardLinkOptions { - interceptors?: Interceptor, Promise>[] - clientInterceptors?: Interceptor, Promise>[] + /** + * Interceptors that execute around the entire call, including transport and codec. + * Useful for error handling, logging, metrics, ... + */ + interceptors?: StandardLinkInterceptor[] + + /** + * Interceptors that execute around the transport layer, after encoding and before decoding. + * Useful for modifying the request or response, adding transport-level logging, ... + */ + transportInterceptors?: StandardLinkTransportInterceptor[] + plugins?: StandardLinkPlugin[] } export class StandardLink implements ClientLink { - private readonly interceptors: Exclude['interceptors'], undefined> - private readonly clientInterceptors: Exclude['clientInterceptors'], undefined> + private readonly interceptors: StandardLinkOptions['interceptors'] + private readonly transportInterceptors: StandardLinkOptions['transportInterceptors'] constructor( - public readonly codec: StandardLinkCodec, - public readonly sender: StandardLinkClient, + private readonly codec: StandardLinkCodec, + private readonly transport: StandardLinkTransport, options: StandardLinkOptions = {}, ) { - const plugin = new CompositeStandardLinkPlugin(options.plugins) - - plugin.init(options) + options = new CompositeStandardLinkPlugin(options.plugins).init(options) - this.interceptors = toArray(options.interceptors) - this.clientInterceptors = toArray(options.clientInterceptors) + this.interceptors = options.interceptors + this.transportInterceptors = options.transportInterceptors } - call(path: readonly string[], input: unknown, options: ClientOptions): Promise { - /** - * [Semantic conventions for RPC spans](https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/) - */ - return runWithSpan( - { name: `${ORPC_NAME}.${path.join('/')}`, signal: options.signal }, - (span) => { + /** + * @throws ORPCError, transport-level errors (network failures, timeouts, etc.) + */ + call(path: string[], input: unknown, options: ClientOptions): Promise { + return runWithSpan(`${ORPC_NAME}.${path.join('/')}`, (span) => { + /** + * [Semantic conventions for RPC spans](https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/) + */ + span?.setAttribute('rpc.system', ORPC_NAME) + span?.setAttribute('rpc.method', path.join('.')) + + if (isAsyncIteratorObject(input)) { /** - * [Semantic conventions for RPC spans](https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/) + * @warning + * Remember use `override` for event iterator to remain other special properties */ - span?.setAttribute('rpc.system', ORPC_NAME) - span?.setAttribute('rpc.method', path.join('.')) - - if (isAsyncIteratorObject(input)) { - input = asyncIteratorWithSpan( - { name: 'consume_event_iterator_input', signal: options.signal }, - input, - ) - } + input = override(input, traceAsyncIterator('consume_event_iterator_input', input)) + } - return intercept(this.interceptors, { ...options, path, input }, async ({ path, input, ...options }) => { + return intercept(this.interceptors, { ...options, path, input }, async ({ path, input, ...options }) => { /** * In browsers, the OpenTelemetry context manager may not work reliably with async functions, - * so we manually manage the context here. + * so we should manually manage the context here. */ - const otelConfig = getGlobalOtelConfig() - let otelContext: ReturnType['context']['active']> | undefined - const currentSpan = otelConfig?.trace.getActiveSpan() ?? span - if (currentSpan && otelConfig) { - otelContext = otelConfig?.trace.setSpan(otelConfig.context.active(), currentSpan) - } - - const request = await runWithSpan( - { name: 'encode_request', context: otelContext }, - () => this.codec.encode(path, input, options), - ) - - const response = await intercept( - this.clientInterceptors, - { ...options, input, path, request }, - ({ input, path, request, ...options }) => { - return runWithSpan( - { name: 'send_request', signal: options.signal, context: otelContext }, - () => this.sender.call(request, options, path, input), - ) - }, - ) - - const output = await runWithSpan( - { name: 'decode_response', context: otelContext }, - () => this.codec.decode(response, options, path, input), - ) - - if (isAsyncIteratorObject(output)) { + const otel = getOpenTelemetryConfig() + let activeContext: ReturnType['context']['active']> | undefined + const activeSpan = otel?.trace.getActiveSpan() ?? span + if (activeSpan && otel) { + activeContext = otel.trace.setSpan(otel.context.active(), activeSpan) + } + + let request = await runWithSpan( + { name: 'encode_input', context: activeContext }, + () => this.codec.encodeInput(input, path, options), + ) + + if (activeContext && otel?.propagation) { + const headers = { ...request.headers } + otel.propagation.inject(activeContext, headers) + request = { ...request, headers } + } + + const response = await intercept( + this.transportInterceptors, + { ...options, path, request }, + ({ path, request, ...options }) => { /** - * Do not use otelContext here, as it is a lazy span. + * In browsers, the OpenTelemetry context manager may not work reliably with async functions, + * so we should manually manage the context here. */ - return asyncIteratorWithSpan( - { name: 'consume_event_iterator_output', signal: options.signal }, - output, + let activeTransportContext: ReturnType['context']['active']> | undefined + const activeTransportSpan = otel?.trace.getActiveSpan() ?? activeSpan + if (activeTransportSpan && otel) { + activeTransportContext = otel.trace.setSpan(otel.context.active(), activeTransportSpan) + } + + return runWithSpan( + { name: 'send_request', context: activeTransportContext }, + () => this.transport.send(request, path, options), ) - } + }, + ) + + const decodedResult = await runWithSpan( + { name: 'decode_response', context: activeContext }, + () => this.codec.decodeResponse(response, path, options), + ) + + if (decodedResult.kind === 'error') { + throw decodedResult.error + } + + const output = decodedResult.output + + if (isAsyncIteratorObject(output)) { + /** + * Do not use otelContext here, as it is a lazy span. + * + * @warning + * Remember use `override` for event iterator to remain other special properties + */ + return override(output, traceAsyncIterator('consume_event_iterator_output', output)) + } - return output - }) - }, - ) + return output + }) + }) } } diff --git a/packages/client/src/adapters/standard/plugin.test.ts b/packages/client/src/adapters/standard/plugin.test.ts index 7cd4a289e..cddd9cb00 100644 --- a/packages/client/src/adapters/standard/plugin.test.ts +++ b/packages/client/src/adapters/standard/plugin.test.ts @@ -2,36 +2,40 @@ import type { StandardLinkPlugin } from './plugin' import { CompositeStandardLinkPlugin } from './plugin' describe('compositeStandardLinkPlugin', () => { - it('forward init and sort plugins', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('sorts plugins by before/after dependencies and forwards transformed options', () => { const plugin1 = { - init: vi.fn(), - order: 1, + name: 'plugin-1', + after: ['plugin-2'], + init: vi.fn((options: any) => ({ ...options, marks: [...options.marks, '1'] })), } satisfies StandardLinkPlugin + const plugin2 = { - init: vi.fn(), + name: 'plugin-2', + init: vi.fn((options: any) => ({ ...options, marks: [...options.marks, '2'] })), } satisfies StandardLinkPlugin + const plugin3 = { - init: vi.fn(), - order: -1, + name: 'plugin-3', + before: ['plugin-2'], + init: vi.fn((options: any) => ({ ...options, marks: [...options.marks, '3'] })), } satisfies StandardLinkPlugin - const compositePlugin = new CompositeStandardLinkPlugin([plugin1, plugin2, plugin3]) - - const interceptor = vi.fn() - - const options = { interceptors: [interceptor] } + const composite = new CompositeStandardLinkPlugin([plugin1, plugin2, plugin3]) - compositePlugin.init(options) + const result = composite.init({ marks: [] } as any) expect(plugin1.init).toHaveBeenCalledOnce() expect(plugin2.init).toHaveBeenCalledOnce() expect(plugin3.init).toHaveBeenCalledOnce() - expect(plugin1.init.mock.calls[0]![0]).toBe(options) - expect(plugin2.init.mock.calls[0]![0]).toBe(options) - expect(plugin3.init.mock.calls[0]![0]).toBe(options) + expect(plugin3.init).toHaveBeenCalledWith({ marks: [] }) + expect(plugin2.init).toHaveBeenCalledWith({ marks: ['3'] }) + expect(plugin1.init).toHaveBeenCalledWith({ marks: ['3', '2'] }) - expect(plugin3.init).toHaveBeenCalledBefore(plugin2.init) - expect(plugin2.init).toHaveBeenCalledBefore(plugin1.init) + expect(result).toEqual({ marks: ['3', '2', '1'] }) }) }) diff --git a/packages/client/src/adapters/standard/plugin.ts b/packages/client/src/adapters/standard/plugin.ts index db712ebc4..f816b9a72 100644 --- a/packages/client/src/adapters/standard/plugin.ts +++ b/packages/client/src/adapters/standard/plugin.ts @@ -1,21 +1,47 @@ +import type { OrderablePlugin } from '@orpc/shared' import type { ClientContext } from '../../types' import type { StandardLinkOptions } from './link' +import { sortPlugins } from '@orpc/shared' -export interface StandardLinkPlugin { - order?: number - init?(options: StandardLinkOptions): void +export interface StandardLinkPlugin extends OrderablePlugin { + /** + * Initializes the plugin and returns new link options. + * Called once per plugin instance during composition. + * + * This method allows plugins to wrap, extend, or transform link options + * such as interceptors, or configuration. + * + * @param options - The current link options from previous plugins or base configuration + * @returns Transformed link options with plugin's modifications applied + * + * @example + * ```ts + * init(options) { + * return { + * ...options, + * interceptors: [...(options.interceptors || []), myInterceptor] + * } + * } + * ``` + */ + init?(options: StandardLinkOptions): StandardLinkOptions } -export class CompositeStandardLinkPlugin> implements StandardLinkPlugin { - protected readonly plugins: TPlugin[] +export class CompositeStandardLinkPlugin implements StandardLinkPlugin { + name = '~composite' + protected readonly plugins: StandardLinkPlugin[] - constructor(plugins: readonly TPlugin[] = []) { - this.plugins = [...plugins].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + constructor(plugins: StandardLinkPlugin[] = []) { + this.plugins = sortPlugins(plugins) } - init(options: StandardLinkOptions): void { + init(options: StandardLinkOptions): StandardLinkOptions { for (const plugin of this.plugins) { - plugin.init?.(options) + if (plugin.init) { + options = plugin.init(options) + } } + + return options } } diff --git a/packages/client/src/adapters/standard/rpc-json-serializer.test.ts b/packages/client/src/adapters/standard/rpc-json-serializer.test.ts deleted file mode 100644 index cc77c8fca..000000000 --- a/packages/client/src/adapters/standard/rpc-json-serializer.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { supportedDataTypes } from '../../../tests/shared' -import { StandardRPCJsonSerializer } from './rpc-json-serializer' - -class Person { - constructor( - public name: string, - public date: Date, - ) {} - - toJSON() { - return { - name: this.name, - date: this.date, - } - } -} - -class Person2 { - constructor( - public name: string, - public data: any, - ) { } - - toJSON() { - return { - name: this.name, - data: this.data, - } - } -} - -const customSupportedDataTypes: { name: string, value: unknown, expected: unknown }[] = [ - { - name: 'person - 1', - value: new Person('Dinh Le', new Date('2023-01-01')), - expected: new Person('Dinh Le', new Date('2023-01-01')), - }, - { - name: 'person - 2', - value: new Person2('Dinh Le - 2', [{ nested: new Date('2023-01-02') }, /uic/gi]), - expected: new Person2('Dinh Le - 2', [{ nested: new Date('2023-01-02') }, /uic/gi]), - }, - { - name: 'should not resolve toJSON', - value: { value: { toJSON: () => 'hello' } }, - expected: { value: { } }, - }, - { - name: 'should resolve invalid toJSON', - value: { value: { toJSON: 'hello' } }, - expected: { value: { toJSON: 'hello' } }, - }, -] - -describe.each([ - ...supportedDataTypes, - ...customSupportedDataTypes, -])('standardRPCJsonSerializer: $name', ({ value, expected }) => { - const serializer = new StandardRPCJsonSerializer({ - customJsonSerializers: [ - { - type: 20, - condition: data => data instanceof Person, - serialize: data => data.toJSON(), - deserialize: data => new Person(data.name, data.date), - }, - { - type: 21, - condition: data => data instanceof Person2, - serialize: data => data.toJSON(), - deserialize: data => new Person2(data.name, data.data), - }, - ], - }) - - function assert(value: unknown, expected: unknown) { - const [json, meta, maps, blobs] = serializer.serialize(value) - - const result = JSON.parse(JSON.stringify({ json, meta, maps })) - - const deserialized = serializer.deserialize( - result.json, - result.meta, - result.maps, - (i: number) => blobs[i]!, - ) - expect(deserialized).toEqual(expected) - } - - it('flat', () => { - assert(value, expected) - }) - - it('nested object', () => { - assert({ - data: value, - nested: { - data: value, - }, - }, { - data: expected, - nested: { - data: expected, - }, - }) - }) - - it('nested array', () => { - assert([value, [value]], [expected, [expected]]) - }) - - it('complex', () => { - assert({ - 'date': new Date('2023-01-01'), - 'regexp': /uic/gi, - 'url': new URL('https://orpc.dev'), - '!@#$%^^&()[]>?<~_<:"~+!_': value, - 'list': [value], - 'map': new Map([[value, value]]), - 'set': new Set([value]), - 'nested': { - nested: value, - }, - }, { - 'date': new Date('2023-01-01'), - 'regexp': /uic/gi, - 'url': new URL('https://orpc.dev'), - '!@#$%^^&()[]>?<~_<:"~+!_': expected, - 'list': [expected], - 'map': new Map([[expected, expected]]), - 'set': new Set([expected]), - 'nested': { - nested: expected, - }, - }) - }) -}) - -describe('standardRPCJsonSerializer: undefined in arrays produces JSON-safe output', () => { - const serializer = new StandardRPCJsonSerializer() - - it('serialize uses null as placeholder for undefined array elements', () => { - const [json] = serializer.serialize([undefined, 'a', undefined]) - expect(json).toEqual([null, 'a', null]) - }) - - it('round-trips undefined array elements through JSON.parse(JSON.stringify(...))', () => { - const [json, meta, maps, blobs] = serializer.serialize([undefined, 'a', undefined]) - const result = JSON.parse(JSON.stringify({ json, meta, maps })) - const deserialized = serializer.deserialize(result.json, result.meta, result.maps, (i: number) => blobs[i]!) - expect(deserialized).toEqual([undefined, 'a', undefined]) - }) - - it('round-trips nested undefined array elements (e.g. TanStack Query pageParams)', () => { - const data = { pageParams: [undefined, 'cursor_abc'], pages: [{ items: [1, 2] }] } - const [json, meta, maps, blobs] = serializer.serialize(data) - const result = JSON.parse(JSON.stringify({ json, meta, maps })) - const deserialized = serializer.deserialize(result.json, result.meta, result.maps, (i: number) => blobs[i]!) - expect(deserialized).toEqual(data) - }) -}) - -describe('standardRPCJsonSerializer: custom serializers', () => { - it('should throw when type is duplicated', () => { - expect(() => { - return new StandardRPCJsonSerializer({ - customJsonSerializers: [ - { - type: 20, - condition: data => data instanceof Person, - serialize: data => data.toJSON(), - deserialize: data => new Person(data.name, data.date), - }, - { - type: 20, - condition: data => data instanceof Person, - serialize: data => data.toJSON(), - deserialize: data => new Person(data.name, data.date), - }, - ], - }) - }).toThrow('Custom serializer type must be unique.') - }) - - it.each(['nonExist', '__proto__', 'constructor'])('should throw when accessing non-existent path during deserialization: %s', (segment) => { - const serializer = new StandardRPCJsonSerializer() - - expect( - () => serializer.deserialize({ a: 1 }, [[1, segment]]), - ).toThrow(`Security error: accessing non-existent path during deserialization. Path segment: ${segment}`) - - expect( - () => serializer.deserialize({ a: 1 }, [[1, 'a', segment]]), - ).toThrow(`Security error: accessing non-existent path during deserialization. Path segment: ${segment}`) - - expect( - () => serializer.deserialize({ a: 1 }, [[1, segment, 'role']]), - ).toThrow(`Security error: accessing non-existent path during deserialization. Path segment: ${segment}`) - - expect( - () => serializer.deserialize({ a: 1 }, [], [[segment]], () => new Blob([])), - ).toThrow(`Security error: accessing non-existent path during deserialization. Path segment: ${segment}`) - - expect( - () => serializer.deserialize({ a: 1 }, [], [['a', segment]], () => new Blob([])), - ).toThrow(`Security error: accessing non-existent path during deserialization. Path segment: ${segment}`) - - expect( - () => serializer.deserialize({ a: 1 }, [], [[segment, 'role']], () => new Blob([])), - ).toThrow(`Security error: accessing non-existent path during deserialization. Path segment: ${segment}`) - }) -}) diff --git a/packages/client/src/adapters/standard/rpc-json-serializer.ts b/packages/client/src/adapters/standard/rpc-json-serializer.ts deleted file mode 100644 index c644b4d08..000000000 --- a/packages/client/src/adapters/standard/rpc-json-serializer.ts +++ /dev/null @@ -1,222 +0,0 @@ -import type { Segment } from '@orpc/shared' -import { isObject } from '@orpc/shared' - -export const STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES = { - BIGINT: 0, - DATE: 1, - NAN: 2, - UNDEFINED: 3, - URL: 4, - REGEXP: 5, - SET: 6, - MAP: 7, -} as const - -export type StandardRPCJsonSerializedMetaItem = readonly [type: number, ...path: Segment[]] -export type StandardRPCJsonSerialized = [json: unknown, meta: StandardRPCJsonSerializedMetaItem[], maps: Segment[][], blobs: Blob[]] - -export interface StandardRPCCustomJsonSerializer { - type: number - condition(data: unknown): boolean - serialize(data: any): unknown - deserialize(serialized: any): unknown -} - -export interface StandardRPCJsonSerializerOptions { - customJsonSerializers?: readonly StandardRPCCustomJsonSerializer[] -} - -export class StandardRPCJsonSerializer { - private readonly customSerializers: readonly StandardRPCCustomJsonSerializer[] - - constructor(options: StandardRPCJsonSerializerOptions = {}) { - this.customSerializers = options.customJsonSerializers ?? [] - - if (this.customSerializers.length !== new Set(this.customSerializers.map(custom => custom.type)).size) { - throw new Error('Custom serializer type must be unique.') - } - } - - serialize(data: unknown, segments: Segment[] = [], meta: StandardRPCJsonSerializedMetaItem[] = [], maps: Segment[][] = [], blobs: Blob[] = []): StandardRPCJsonSerialized { - for (const custom of this.customSerializers) { - if (custom.condition(data)) { - const result = this.serialize(custom.serialize(data), segments, meta, maps, blobs) - - meta.push([custom.type, ...segments]) - - return result - } - } - - if (data instanceof Blob) { - maps.push(segments) - blobs.push(data) - return [data, meta, maps, blobs] - } - - if (typeof data === 'bigint') { - meta.push([STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES.BIGINT, ...segments]) - return [data.toString(), meta, maps, blobs] - } - - if (data instanceof Date) { - meta.push([STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES.DATE, ...segments]) - - if (Number.isNaN(data.getTime())) { - return [null, meta, maps, blobs] - } - - return [data.toISOString(), meta, maps, blobs] - } - - if (Number.isNaN(data)) { - meta.push([STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES.NAN, ...segments]) - return [null, meta, maps, blobs] - } - - if (data instanceof URL) { - meta.push([STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES.URL, ...segments]) - return [data.toString(), meta, maps, blobs] - } - - if (data instanceof RegExp) { - meta.push([STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES.REGEXP, ...segments]) - return [data.toString(), meta, maps, blobs] - } - - if (data instanceof Set) { - const result = this.serialize(Array.from(data), segments, meta, maps, blobs) - meta.push([STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES.SET, ...segments]) - return result - } - - if (data instanceof Map) { - const result = this.serialize(Array.from(data.entries()), segments, meta, maps, blobs) - meta.push([STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES.MAP, ...segments]) - return result - } - - if (Array.isArray(data)) { - const json = data.map((v, i) => { - if (v === undefined) { - meta.push([STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES.UNDEFINED, ...segments, i]) - return null - } - - return this.serialize(v, [...segments, i], meta, maps, blobs)[0] - }) - - return [json, meta, maps, blobs] - } - - if (isObject(data)) { - const json: Record = {} - - for (const k in data) { - /** - * Skip custom toJSON methods to avoid JSON.stringify invoking them, - * which could cause meta and serialized data mismatches during deserialization. - * Instead, rely on custom serializers. - */ - if (k === 'toJSON' && typeof data[k] === 'function') { - continue - } - - json[k] = this.serialize(data[k], [...segments, k], meta, maps, blobs)[0] - } - - return [json, meta, maps, blobs] - } - - return [data, meta, maps, blobs] - } - - deserialize(json: unknown, meta: readonly StandardRPCJsonSerializedMetaItem[]): unknown - deserialize(json: unknown, meta: readonly StandardRPCJsonSerializedMetaItem[], maps: readonly Segment[][], getBlob: (index: number) => Blob): unknown - - deserialize(json: unknown, meta: readonly StandardRPCJsonSerializedMetaItem[], maps?: readonly Segment[][], getBlob?: (index: number) => Blob): unknown { - const ref = { data: json } - - if (maps && getBlob) { - maps.forEach((segments, i) => { - let currentRef: any = ref - let preSegment: string | number = 'data' - - segments.forEach((segment) => { - currentRef = currentRef[preSegment] - preSegment = segment - - if (!Object.hasOwn(currentRef, preSegment)) { - throw new Error(`Security error: accessing non-existent path during deserialization. Path segment: ${preSegment}`) - } - }) - - currentRef[preSegment] = getBlob(i) - }) - } - - for (const item of meta) { - const type = item[0] - - let currentRef: any = ref - let preSegment: string | number = 'data' - - for (let i = 1; i < item.length; i++) { - currentRef = currentRef[preSegment] - preSegment = item[i]! - - if (!Object.hasOwn(currentRef, preSegment)) { - throw new Error(`Security error: accessing non-existent path during deserialization. Path segment: ${preSegment}`) - } - } - - for (const custom of this.customSerializers) { - if (custom.type === type) { - currentRef[preSegment] = custom.deserialize(currentRef[preSegment]) - - break - } - } - - switch (type) { - case STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES.BIGINT: - currentRef[preSegment] = BigInt(currentRef[preSegment]) - break - - case STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES.DATE: - currentRef[preSegment] = new Date(currentRef[preSegment] ?? 'Invalid Date') - break - - case STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES.NAN: - currentRef[preSegment] = Number.NaN - break - - case STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES.UNDEFINED: - currentRef[preSegment] = undefined - break - - case STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES.URL: - currentRef[preSegment] = new URL(currentRef[preSegment]) - break - - case STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES.REGEXP: { - const [, pattern, flags] = currentRef[preSegment].match(/^\/(.*)\/([a-z]*)$/) - - currentRef[preSegment] = new RegExp(pattern!, flags) - - break - } - - case STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES.SET: - currentRef[preSegment] = new Set(currentRef[preSegment]) - break - - case STANDARD_RPC_JSON_SERIALIZER_BUILT_IN_TYPES.MAP: - currentRef[preSegment] = new Map(currentRef[preSegment]) - break - } - } - - return ref.data - } -} diff --git a/packages/client/src/adapters/standard/rpc-link-codec.test.ts b/packages/client/src/adapters/standard/rpc-link-codec.test.ts index 9f10cb7bb..a617cf4a4 100644 --- a/packages/client/src/adapters/standard/rpc-link-codec.test.ts +++ b/packages/client/src/adapters/standard/rpc-link-codec.test.ts @@ -1,122 +1,138 @@ -import * as StandardServer from '@orpc/standard-server' -import * as ErrorModule from '../../error' -import { StandardRPCJsonSerializer } from './rpc-json-serializer' -import { StandardRPCLinkCodec } from './rpc-link-codec' -import { StandardRPCSerializer } from './rpc-serializer' -import * as UtilsModule from './utils' - -const ORPCError = ErrorModule.ORPCError -const isORPCErrorStatusSpy = vi.spyOn(ErrorModule, 'isORPCErrorStatus') -const mergeStandardHeadersSpy = vi.spyOn(StandardServer, 'mergeStandardHeaders') -const getMalformedResponseErrorCodeSpy = vi.spyOn(UtilsModule, 'getMalformedResponseErrorCode') +import type { StandardUrl } from '@standardserver/core' +import { ORPCError } from '../../error' +import { RPCSerializer } from '../../rpc-serializer' +import { RPCLinkCodec } from './rpc-link-codec' beforeEach(() => { vi.clearAllMocks() }) -describe('standardRPCLinkCodec', () => { - const serializer = new StandardRPCSerializer(new StandardRPCJsonSerializer()) - +describe('rpcLinkCodec', () => { + const serializer = new RPCSerializer() const serializeSpy = vi.spyOn(serializer, 'serialize') const deserializeSpy = vi.spyOn(serializer, 'deserialize') - describe('encode', () => { - const method = vi.fn() - const codec = new StandardRPCLinkCodec(serializer, { - url: 'http://localhost:3000', - method, - headers: () => ({ 'x-custom-header': 'custom-value' }), - }) + it('uses sensible defaults when no options provided', async () => { + const codec = new RPCLinkCodec({}) + + const request = await codec.encodeInput('input', ['ping'], { context: {} }) - it('with method=GET', async () => { - method.mockResolvedValueOnce('GET') + expect(request.url).toBe('/ping') + expect(request.method).toBe('POST') + expect(request.headers).toEqual({}) + expect(request.body).toBeDefined() + }) + describe('.encodeInput', () => { + it('with method=POST (default)', async () => { + const codec = new RPCLinkCodec({ url: '/api', serializer }) const signal = AbortSignal.timeout(100) - const output = await codec.encode(['test'], 'input', { context: {}, signal }) - expect(output).toEqual(expect.objectContaining({ - url: new URL(`http://localhost:3000/test?data=${encodeURIComponent(JSON.stringify(serializeSpy.mock.results[0]!.value))}`), - method: 'GET', - headers: { - 'x-custom-header': 'custom-value', - }, - body: undefined, + const request = await codec.encodeInput('input', ['ping'], { context: {}, signal }) + + expect(request).toEqual({ + url: '/api/ping', + method: 'POST', + headers: {}, + body: serializeSpy.mock.results[0]!.value, signal, - })) + }) + expect(serializeSpy).toHaveBeenCalledWith('input') }) - it('with method=POST', async () => { - method.mockResolvedValueOnce('POST') - + it('with method=GET serializes input as query param', async () => { + const codec = new RPCLinkCodec({ url: '/api', method: 'GET', serializer }) const signal = AbortSignal.timeout(100) - const output = await codec.encode(['test'], 'input', { context: {}, signal }) - expect(output).toEqual(expect.objectContaining({ - url: new URL(`http://localhost:3000/test`), - method: 'POST', - headers: { - 'x-custom-header': 'custom-value', - }, - body: serializeSpy.mock.results[0]!.value, - signal, - })) + const request = await codec.encodeInput('input', ['ping'], { context: {}, signal }) + + expect(request.method).toBe('GET') + expect(request.body).toBeUndefined() + expect(request.url).toContain('/api/ping?data=') + expect(request.signal).toBe(signal) }) - it.each([ - ['exceeds max length', '_'.repeat(100)], - ['blob', new Blob(['blob'], { type: 'text/plain' })], - ['blob inside', { blob: new Blob(['blob'], { type: 'text/plain' }) }], - ['event-iterator', (async function* () { })()], - ])('fallback method when method=GET: %s', async (_, input) => { - const codec = new StandardRPCLinkCodec(serializer, { - url: 'http://localhost:3000', + it('with method=GET falls back dataParam to empty string when serializer returns undefined', async () => { + const codec = new RPCLinkCodec({ url: '/api', method: 'GET', serializer }) + serializeSpy.mockReturnValueOnce(undefined as any) + + const request = await codec.encodeInput(undefined, ['ping'], { context: {} }) + + expect(request.method).toBe('GET') + expect(request.url).toBe('/api/ping?data=') + }) + + it('with method=PUT sends body', async () => { + const codec = new RPCLinkCodec({ url: '/api', method: 'PUT', serializer }) + + const request = await codec.encodeInput({ data: 123 }, ['test'], { context: {} }) + + expect(request.method).toBe('PUT') + expect(request.body).toBe(serializeSpy.mock.results[0]!.value) + expect(request.url).toBe('/api/test') + }) + + it('falls back to fallbackMethod when GET url exceeds maxUrlLength', async () => { + const codec = new RPCLinkCodec({ + url: '/api', method: 'GET', - maxUrlLength: 100, + maxUrlLength: 10, fallbackMethod: 'PATCH', + serializer, }) - const output = await codec.encode(['test'], input, { context: {} }) + const request = await codec.encodeInput('input', ['ping'], { context: {} }) - expect(output).toEqual(expect.objectContaining({ - url: new URL(`http://localhost:3000/test`), - method: 'PATCH', - headers: {}, - body: serializeSpy.mock.results[0]!.value, - })) + expect(request.method).toBe('PATCH') + expect(request.body).toBe(serializeSpy.mock.results[0]!.value) + expect(request.url).toBe('/api/ping') + }) - expect(serializeSpy).toBeCalledTimes(1) - expect(serializeSpy).toBeCalledWith(input) + it.each([ + ['FormData', () => { + const f = new FormData() + f.set('k', 'v') + return f + }], + ['Blob', () => new Blob(['data'])], + ['ReadableStream', () => new ReadableStream()], + ['async iterator', () => (async function* () { yield 1 })()], + ] as const)('falls back to POST when GET with %s', async (_, factory) => { + const codec = new RPCLinkCodec({ url: '/api', method: 'GET', serializer }) + const value = factory() + serializeSpy.mockReturnValueOnce(value as any) + + const request = await codec.encodeInput(value, ['test'], { context: {} }) + + expect(request.method).toBe('POST') + expect(request.body).toBe(value) }) - it('last-event-id', async () => { - const codec = new StandardRPCLinkCodec(serializer, { - url: 'http://localhost:3000', - method, - headers: () => ({ 'x-custom-header': 'custom-value' }), - }) + it('merges last-event-id header when present', async () => { + const codec = new RPCLinkCodec({ url: '/api', headers: { 'x-custom': 'value' }, serializer }) + + const request = await codec.encodeInput('input', ['ping'], { context: {}, lastEventId: '' }) + + expect(request.headers).toEqual({ 'x-custom': 'value', 'last-event-id': '' }) + }) - const request = await codec.encode(['test'], 'input', { context: {}, lastEventId: '1' }) + it('does not merge last-event-id when absent', async () => { + const codec = new RPCLinkCodec({ url: '/api', headers: { 'x-custom': 'value' }, serializer }) - expect(request.headers['last-event-id']).toEqual('1') + const request = await codec.encodeInput('input', ['ping'], { context: {} }) - expect(mergeStandardHeadersSpy).toBeCalledWith({ 'x-custom-header': 'custom-value' }, { 'last-event-id': '1' }) - expect(mergeStandardHeadersSpy).toBeCalledTimes(1) - expect(request.headers).toBe(mergeStandardHeadersSpy.mock.results[0]!.value) + expect(request.headers).toEqual({ 'x-custom': 'value' }) }) - it('support fetch headers', async () => { + it('supports fetch Headers', async () => { const headers = new Headers() headers.append('cookie', 'a=1') headers.append('cookie', 'b=2') headers.append('set-cookie', 'a1=1') headers.append('set-cookie', 'b1=2') - const codec = new StandardRPCLinkCodec(serializer, { - url: 'http://localhost:3000', - headers, - }) - - const request = await codec.encode(['test'], 'input', { context: {} }) + const codec = new RPCLinkCodec({ url: '/api', headers, serializer }) + const request = await codec.encodeInput('input', ['ping'], { context: {} }) expect(request.headers).toEqual({ 'cookie': 'a=1; b=2', @@ -124,151 +140,165 @@ describe('standardRPCLinkCodec', () => { }) }) - describe('base url', () => { - it('works with /prefix', async () => { - const codec = new StandardRPCLinkCodec(serializer, { - url: 'http://localhost:3000/prefix', - method: 'GET', - }) + it('supports headers as a function', async () => { + const headersFn = vi.fn(() => ({ 'x-dynamic': 'yes' })) + const codec = new RPCLinkCodec({ url: '/api', headers: headersFn, serializer }) + const options = { context: {} } - const request = await codec.encode(['test'], 'input', { context: {} }) + await codec.encodeInput('input', ['ping'], options) - expect(request.url.toString()).toEqual('http://localhost:3000/prefix/test?data=%7B%22json%22%3A%22input%22%7D') - }) + expect(headersFn).toHaveBeenCalledWith(options, ['ping'], 'input') + }) - it('works with /prefix/', async () => { - const codec = new StandardRPCLinkCodec(serializer, { - url: 'http://localhost:3000/prefix/', - method: 'GET', - }) + it('strips trailing slash from base url', async () => { + const codec = new RPCLinkCodec({ url: '/prefix/', serializer }) - const request = await codec.encode(['test'], 'input', { context: {} }) + const request = await codec.encodeInput('input', ['test'], { context: {} }) - expect(request.url.toString()).toEqual('http://localhost:3000/prefix/test?data=%7B%22json%22%3A%22input%22%7D') - }) + expect(request.url).toBe('/prefix/test') + }) - it('works with /prefix/?a=5', async () => { - const codec = new StandardRPCLinkCodec(serializer, { - url: 'http://localhost:3000/prefix/?a=5', - method: 'GET', - }) + it('appends data param to existing query string on GET', async () => { + const codec = new RPCLinkCodec({ url: '/prefix?existing=1', method: 'GET', serializer }) - const request = await codec.encode(['test'], 'input', { context: {} }) + const request = await codec.encodeInput('input', ['test'], { context: {} }) - expect(request.url.toString()).toEqual('http://localhost:3000/prefix/test?a=5&data=%7B%22json%22%3A%22input%22%7D') - }) + expect(request.url).toMatch(/\/prefix\/test\?existing=1&data=/) }) - }) - describe('decode', () => { - const codec = new StandardRPCLinkCodec(serializer, { - url: 'http://localhost:3000', + it('preserves hash in url', async () => { + const codec = new RPCLinkCodec({ url: '/prefix#frag', serializer }) + + const request = await codec.encodeInput('input', ['test'], { context: {} }) + + expect(request.url).toBe('/prefix/test#frag') }) - it('should decode output', async () => { - const serialized = serializer.serialize({ - data: 'hello world', - }) + it('handles nested paths', async () => { + const codec = new RPCLinkCodec({ url: '/api', serializer }) - const output = await codec.decode({ - status: 200, - headers: {}, - body: () => Promise.resolve(serialized), - }) + const request = await codec.encodeInput('input', ['nested', 'path', 'here'], { context: {} }) - expect(output).toEqual(deserializeSpy.mock.results[0]!.value) + expect(request.url).toBe('/api/nested/path/here') + }) + + it('encodes path segments', async () => { + const codec = new RPCLinkCodec({ url: '/api', serializer }) - expect(deserializeSpy).toBeCalledTimes(1) - expect(deserializeSpy).toBeCalledWith(serialized) + const request = await codec.encodeInput('input', ['with/slash'], { context: {} }) - expect(isORPCErrorStatusSpy).toBeCalledTimes(1) - expect(isORPCErrorStatusSpy).toBeCalledWith(200) + expect(request.url).toBe('/api/with%2Fslash') }) - it('should decode error', async () => { - const error = new ORPCError('TEST', { - data: { - message: 'hello world', - }, - }) + it('supports url as a function', async () => { + const urlFn = vi.fn(() => '/dynamic' as StandardUrl) + const codec = new RPCLinkCodec({ url: urlFn, serializer }) + const options = { context: {} } - const serialized = serializer.serialize(error.toJSON()) + await codec.encodeInput('input', ['ping'], options) - await expect(codec.decode({ - status: 499, - headers: {}, - body: () => Promise.resolve(serialized), - })).rejects.toSatisfy((e) => { - expect(e).toEqual(error) + expect(urlFn).toHaveBeenCalledWith(options, ['ping'], 'input') + }) - return true - }) + it('supports method as a function', async () => { + const methodFn = vi.fn(() => 'DELETE' as const) + const codec = new RPCLinkCodec({ url: '/api', method: methodFn, serializer }) + const options = { context: {} } - expect(deserializeSpy).toBeCalledTimes(1) - expect(deserializeSpy).toBeCalledWith(serialized) + const request = await codec.encodeInput('input', ['ping'], options) - expect(isORPCErrorStatusSpy).toBeCalledTimes(1) - expect(isORPCErrorStatusSpy).toBeCalledWith(499) + expect(request.method).toBe('DELETE') + expect(methodFn).toHaveBeenCalledWith(options, ['ping'], 'input') }) - it('error: Cannot parse response body', async () => { - await expect(codec.decode({ - status: 200, - headers: {}, - body: () => { - throw new Error('test') - }, - })).rejects.toThrow('Cannot parse response body, please check the response body and content-type.') + it('supports maxUrlLength as a function', async () => { + const maxUrlLengthFn = vi.fn(() => 10) + const codec = new RPCLinkCodec({ + url: '/api', + method: 'GET', + maxUrlLength: maxUrlLengthFn, + fallbackMethod: 'PATCH', + serializer, + }) + + const request = await codec.encodeInput('input', ['ping'], { context: {} }) - expect(deserializeSpy).toBeCalledTimes(0) + expect(request.method).toBe('PATCH') + expect(maxUrlLengthFn).toHaveBeenCalledOnce() }) + }) - it('error: Invalid RPC response format.', async () => { - await expect(codec.decode({ - status: 200, + describe('.decodeResponse', () => { + const codec = new RPCLinkCodec({ url: '/api', serializer }) + + it.each([200, 201, 399])('decodes successful output (status %i)', async (status) => { + const serialized = serializer.serialize({ data: 'hello' }) + + const result = await codec.decodeResponse({ + status, headers: {}, - body: () => Promise.resolve({ meta: 123 }), - })).rejects.toThrow('Invalid RPC response format.') + resolveBody: () => Promise.resolve(serialized), + }) - expect(deserializeSpy).toBeCalledTimes(1) - expect(deserializeSpy).toBeCalledWith({ meta: 123 }) + expect(result).toEqual({ kind: 'output', output: deserializeSpy.mock.results[0]!.value }) }) - it('error: Malformed Response Error', async () => { - const error = new ORPCError('TEST', { - data: { - message: 'hello world', - }, + it('decodes ORPCError JSON from error response', async () => { + const error = new ORPCError('NOT_FOUND', { message: 'Resource not found', data: { id: '123' } }) + const serialized = serializer.serialize(error.toJSON()) + + const result = await codec.decodeResponse({ + status: 404, + headers: {}, + resolveBody: () => Promise.resolve(serialized), }) - const serialized = serializer.serialize({ something: 'value' }) as any + expect(result).toEqual({ + kind: 'error', + error: expect.objectContaining({ code: 'NOT_FOUND', message: 'Resource not found', data: { id: '123' } }), + }) + }) - getMalformedResponseErrorCodeSpy.mockReturnValueOnce('__MOCKED_CODE__') + it('wraps non-ORPCError error response with generic MALFORMED_ORPC_ERROR_RESPONSE ORPCError', async () => { + const serialized = serializer.serialize({ something: 'unexpected' }) - await expect(codec.decode({ + const result = await codec.decodeResponse({ status: 403, - headers: {}, - body: () => Promise.resolve(serialized), - })).rejects.toSatisfy((e) => { - expect(e).toBeInstanceOf(ORPCError) - expect(e.defined).toBe(false) - expect(e.code).toEqual('__MOCKED_CODE__') - expect(e.data).toEqual({ - body: { - something: 'value', - }, - headers: {}, + headers: { 'x-header': 'value' }, + resolveBody: () => Promise.resolve(serialized), + }) + + expect(result.kind).toBe('error') + if (result.kind === 'error') { + expect(result.error).toBeInstanceOf(ORPCError) + expect(result.error.code).toBe('MALFORMED_ORPC_ERROR_RESPONSE') + expect(result.error.data).toEqual(expect.objectContaining({ status: 403, - }) + headers: { 'x-header': 'value' }, + body: { something: 'unexpected' }, + })) + } + }) + + it.each([400, 500, 100])('treats status %i as error', async (status) => { + const error = new ORPCError('BAD_REQUEST') + const serialized = serializer.serialize(error.toJSON()) - return true + const result = await codec.decodeResponse({ + status, + headers: {}, + resolveBody: () => Promise.resolve(serialized), }) - expect(deserializeSpy).toBeCalledTimes(1) - expect(deserializeSpy).toBeCalledWith(serialized) + expect(result.kind).toBe('error') + }) - expect(getMalformedResponseErrorCodeSpy).toBeCalledTimes(1) - expect(getMalformedResponseErrorCodeSpy).toBeCalledWith(403) + it('throws on invalid RPC response format', async () => { + await expect(codec.decodeResponse({ + status: 200, + headers: {}, + resolveBody: () => Promise.resolve({ meta: 123 }), + })).rejects.toThrow('Invalid RPC response format.') }) }) }) diff --git a/packages/client/src/adapters/standard/rpc-link-codec.ts b/packages/client/src/adapters/standard/rpc-link-codec.ts index ef796420f..2d0e25b05 100644 --- a/packages/client/src/adapters/standard/rpc-link-codec.ts +++ b/packages/client/src/adapters/standard/rpc-link-codec.ts @@ -1,32 +1,39 @@ import type { Promisable, Value } from '@orpc/shared' -import type { StandardHeaders, StandardLazyResponse, StandardRequest, StandardResponse } from '@orpc/standard-server' -import type { ClientContext, ClientOptions, HTTPMethod } from '../../types' -import type { StandardRPCSerializer } from './rpc-serializer' -import type { StandardLinkCodec } from './types' -import { isAsyncIteratorObject, stringifyJSON, value } from '@orpc/shared' -import { mergeStandardHeaders } from '@orpc/standard-server' -import { createORPCErrorFromJson, isORPCErrorJson, isORPCErrorStatus, ORPCError } from '../../error' -import { getMalformedResponseErrorCode, toHttpPath, toStandardHeaders } from './utils' - -export interface StandardRPCLinkCodecOptions { +import type { StandardHeaders, StandardLazyResponse, StandardRequest, StandardResponse, StandardUrl } from '@standardserver/core' +import type { ClientContext, ClientOptions } from '../../types' +import type { StandardLinkCodec, StandardLinkCodecDecodedResponse } from '../standard' +import { isAsyncIteratorObject, pathToHttpPath, stringifyJSON, value } from '@orpc/shared' +import { mergeStandardHeaders, parseStandardUrl } from '@standardserver/core' +import { toStandardHeaders } from '@standardserver/fetch' +import { ORPCError } from '../../error' +import { createORPCErrorFromJson, isORPCErrorJson } from '../../error-utils' +import { RPCSerializer } from '../../rpc-serializer' + +export interface RPCLinkCodecOptions { /** - * Base url for all requests. + * Base url for all requests (without origin). Should match with handler's prefix. + * + * @example '/rpc?base=1' + * + * @default '/' */ - url: Value, [options: ClientOptions, path: readonly string[], input: unknown]> + url?: Value, [options: ClientOptions, path: string[], input: unknown]> /** * The maximum length of the URL. * + * If the URL exceeds this length, the codec should use the `fallbackMethod` to send the request with the payload in the body instead of the URL. + * * @default 2083 */ - maxUrlLength?: Value, [options: ClientOptions, path: readonly string[], input: unknown]> + maxUrlLength?: Value, [options: ClientOptions, path: string[], input: unknown]> /** * The method used to make the request. * * @default 'POST' */ - method?: Value>, [options: ClientOptions, path: readonly string[], input: unknown]> + method?: Value, [options: ClientOptions, path: string[], input: unknown]> /** * The method to use when the payload cannot safely pass to the server with method return from method function. @@ -34,66 +41,78 @@ export interface StandardRPCLinkCodecOptions { * * @default 'POST' */ - fallbackMethod?: Exclude + fallbackMethod?: 'POST' | 'PUT' | 'PATCH' | 'DELETE' /** * Inject headers to the request. */ - headers?: Value, [options: ClientOptions, path: readonly string[], input: unknown]> + headers?: Value, [options: ClientOptions, path: string[], input: unknown]> + + /** + * Override the default RPC serializer. + */ + serializer?: Pick } -export class StandardRPCLinkCodec implements StandardLinkCodec { - private readonly baseUrl: Exclude['url'], undefined> - private readonly maxUrlLength: Exclude['maxUrlLength'], undefined> - private readonly fallbackMethod: Exclude['fallbackMethod'], undefined> - private readonly expectedMethod: Exclude['method'], undefined> - private readonly headers: Exclude['headers'], undefined> +const END_SLASH_REGEX = /\/$/ + +export class RPCLinkCodec implements StandardLinkCodec { + private readonly baseUrl: Exclude['url'], undefined> + private readonly maxUrlLength: Exclude['maxUrlLength'], undefined> + private readonly fallbackMethod: Exclude['fallbackMethod'], undefined> + private readonly expectedMethod: Exclude['method'], undefined> + private readonly headers: Exclude['headers'], undefined> + private readonly serializer: Exclude['serializer'], undefined> constructor( - private readonly serializer: StandardRPCSerializer, - options: StandardRPCLinkCodecOptions, + options: RPCLinkCodecOptions, ) { - this.baseUrl = options.url + this.baseUrl = options.url ?? '/' this.maxUrlLength = options.maxUrlLength ?? 2083 this.fallbackMethod = options.fallbackMethod ?? 'POST' this.expectedMethod = options.method ?? this.fallbackMethod this.headers = options.headers ?? {} + this.serializer = options.serializer ?? new RPCSerializer() } - async encode(path: readonly string[], input: unknown, options: ClientOptions): Promise { - let headers = toStandardHeaders(await value(this.headers, options, path, input)) + async encodeInput(input: unknown, path: string[], options: ClientOptions): Promise { + let headers = toResolvedStandardHeaders(await value(this.headers, options, path, input)) if (options.lastEventId !== undefined) { headers = mergeStandardHeaders(headers, { 'last-event-id': options.lastEventId }) } const expectedMethod = await value(this.expectedMethod, options, path, input) const baseUrl = await value(this.baseUrl, options, path, input) - const url = new URL(baseUrl) - url.pathname = `${url.pathname.replace(/\/$/, '')}${toHttpPath(path)}` + const [pathname, search, hash] = parseStandardUrl(baseUrl) + const newPathname = `${pathname.replace(END_SLASH_REGEX, '')}${pathToHttpPath(path)}` as StandardUrl const serialized = this.serializer.serialize(input) if ( expectedMethod === 'GET' + && !(serialized instanceof Blob) + && !(serialized instanceof ReadableStream) && !(serialized instanceof FormData) && !isAsyncIteratorObject(serialized) ) { const maxUrlLength = await value(this.maxUrlLength, options, path, input) - const getUrl = new URL(url) - - getUrl.searchParams.append('data', stringifyJSON(serialized)) + const mergedSearch = new URLSearchParams(search) + mergedSearch.append('data', stringifyJSON(serialized) ?? '') + const url = `${newPathname}?${mergedSearch}${hash ?? ''}` as StandardUrl - if (getUrl.toString().length <= maxUrlLength) { + if (url.length <= maxUrlLength) { return { body: undefined, method: expectedMethod, headers, - url: getUrl, + url, signal: options.signal, } } } + const url = `${newPathname}${search ?? ''}${hash ?? ''}` as StandardUrl + return { url, method: expectedMethod === 'GET' ? this.fallbackMethod : expectedMethod, @@ -103,43 +122,48 @@ export class StandardRPCLinkCodec implements StandardLi } } - async decode(response: StandardLazyResponse): Promise { - const isOk = !isORPCErrorStatus(response.status) + async decodeResponse(response: StandardLazyResponse): Promise { + const isOk = response.status >= 200 && response.status < 400 - const deserialized = await (async () => { - let isBodyOk = false + const body = await response.resolveBody() + const deserialized = await (async () => { try { - const body = await response.body() - - isBodyOk = true - return this.serializer.deserialize(body) } - catch (error) { - if (!isBodyOk) { - throw new Error('Cannot parse response body, please check the response body and content-type.', { - cause: error, - }) - } - + catch (cause) { throw new Error('Invalid RPC response format.', { - cause: error, + cause, }) } })() if (!isOk) { if (isORPCErrorJson(deserialized)) { - throw createORPCErrorFromJson(deserialized) + return { kind: 'error', error: createORPCErrorFromJson(deserialized) } } - throw new ORPCError(getMalformedResponseErrorCode(response.status), { - status: response.status, - data: { ...response, body: deserialized }, - }) + return { + kind: 'error', + error: new ORPCError<'MALFORMED_ORPC_ERROR_RESPONSE', StandardResponse>('MALFORMED_ORPC_ERROR_RESPONSE', { + data: { headers: response.headers, status: response.status, body: deserialized }, + }), + } } - return deserialized + return { kind: 'output', output: deserialized } } } + +function toResolvedStandardHeaders(headers: Headers | StandardHeaders): StandardHeaders { + /** + * Headers class might not be available in some environments, + * so we check for the existence of `forEach` and `get` + * methods to determine if it's a Headers instance. + */ + if (typeof headers.forEach === 'function') { + return toStandardHeaders(headers as Headers) + } + + return headers as StandardHeaders +} diff --git a/packages/client/src/adapters/standard/rpc-link.test.ts b/packages/client/src/adapters/standard/rpc-link.test.ts deleted file mode 100644 index 96eec6b95..000000000 --- a/packages/client/src/adapters/standard/rpc-link.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { getEventMeta, ORPCError, os, withEventMeta } from '@orpc/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { StandardRPCHandler } from '../../../../server/src/adapters/standard/rpc-handler' -import { supportedDataTypes } from '../../../tests/shared' -import { StandardRPCLink } from './rpc-link' - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe.each(supportedDataTypes)('standardRPCLink: $name', ({ value, expected }) => { - describe.each(['GET', 'POST'] as const)('method: %s', (method) => { - async function assertSuccessCase(value: unknown, expected: unknown): Promise { - const handler = vi.fn(({ input }) => input) - - const rpcHandler = new StandardRPCHandler(os.handler(handler)) - - const rpcLink = new StandardRPCLink({ - async call(request, options, path, input) { - const { response } = await rpcHandler.handle({ ...request, body: () => Promise.resolve(request.body) }, { - context: {}, - prefix: '/prefix', - }) - - if (!response) { - throw new Error('No response') - } - - return { ...response, body: () => Promise.resolve(response.body) } - }, - }, { - method, - url: new URL('http://localhost/prefix'), - }) - - const output = await rpcLink.call([], value, { context: {} }) - - expect(output).toEqual(expected) - expect(handler).toHaveBeenCalledTimes(1) - expect(handler).toHaveBeenCalledWith(expect.objectContaining({ input: expected })) - - return true - } - - async function assertErrorCase(value: unknown, expected: unknown): Promise { - const handler = vi.fn(({ input }) => { - throw new ORPCError('TEST', { - data: input, - }) - }) - - const rpcHandler = new StandardRPCHandler(os.handler(handler)) - - const rpcLink = new StandardRPCLink({ - async call(request, options, path, input) { - const { response } = await rpcHandler.handle({ ...request, body: () => Promise.resolve(request.body) }, { - context: {}, - prefix: '/prefix', - }) - - if (!response) { - throw new Error('No response') - } - - return { ...response, body: () => Promise.resolve(response.body) } - }, - }, { - url: new URL('http://localhost/prefix'), - }) - - await expect(rpcLink.call([], value, { context: {} })).rejects.toSatisfy((e) => { - expect(e).toBeInstanceOf(ORPCError) - expect(e.code).toBe('TEST') - expect(e.data).toEqual(expected) - - return true - }) - - return true - } - - it('should work on flat', async () => { - expect(await assertSuccessCase(value, expected)).toBe(true) - expect(await assertErrorCase(value, expected)).toBe(true) - }) - - it('should work on nested object', async () => { - expect(await assertSuccessCase({ data: value }, { data: expected })).toBe(true) - expect(await assertErrorCase({ data: value }, { data: expected })).toBe(true) - }) - - it('should work on complex object', async () => { - expect(await assertSuccessCase({ - '!@#$%^^&()[]>?<~_<:"~+!_': value, - 'list': [value], - 'map': new Map([[value, value]]), - 'set': new Set([value]), - 'nested': { - nested: value, - }, - }, { - '!@#$%^^&()[]>?<~_<:"~+!_': expected, - 'list': [expected], - 'map': new Map([[expected, expected]]), - 'set': new Set([expected]), - 'nested': { - nested: expected, - }, - })).toBe(true) - - expect(await assertErrorCase({ - '!@#$%^^&()[]>?<~_<:"~+!_': value, - 'list': [value], - 'map': new Map([[value, value]]), - 'set': new Set([value]), - 'nested': { - nested: value, - }, - }, { - '!@#$%^^&()[]>?<~_<:"~+!_': expected, - 'list': [expected], - 'map': new Map([[expected, expected]]), - 'set': new Set([expected]), - 'nested': { - nested: expected, - }, - })).toBe(true) - }) - }) -}) - -describe('standardRPCLink: event-iterator', async () => { - const handler = vi.fn(({ input }) => input) - - const rpcHandler = new StandardRPCHandler(os.handler(handler)) - - const rpcLink = new StandardRPCLink({ - async call(request, options, path, input) { - const { response } = await rpcHandler.handle({ ...request, body: () => Promise.resolve(request.body) }, { - context: {}, - prefix: '/prefix', - }) - - if (!response) { - throw new Error('No response') - } - - return { ...response, body: () => Promise.resolve(response.body) } - }, - }, { - url: new URL('http://localhost/prefix'), - }) - - it('on success', async () => { - const output = await rpcLink.call([], (async function* () { - yield 1 - yield withEventMeta({ hello: 2 }, { id: '29224', retry: 8393 }) - return withEventMeta({ hello: 3 }, { id: '391', retry: 28973 }) - })(), { context: {} }) as any - - expect(await output.next()).toEqual({ value: 1, done: false }) - - expect(await output.next()).toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual({ hello: 2 }) - expect(getEventMeta(value)).toEqual({ id: '29224', retry: 8393 }) - - return true - }) - - expect(await output.next()).toSatisfy(({ value, done }) => { - expect(done).toBe(true) - expect(value).toEqual({ hello: 3 }) - expect(getEventMeta(value)).toEqual({ id: '391', retry: 28973 }) - - return true - }) - - expect(await output.next()).toEqual({ value: undefined, done: true }) - }) - - it('on error', async () => { - const output = await rpcLink.call([], (async function* () { - yield 1 - yield withEventMeta({ hello: 2 }, { id: '29224', retry: 8393 }) - throw withEventMeta(new ORPCError('INTERNAL', { - data: { hello: 3 }, - }), { id: '391', retry: 28973 }) - })(), { context: {} }) as any - - expect(await output.next()).toEqual({ value: 1, done: false }) - - expect(await output.next()).toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual({ hello: 2 }) - expect(getEventMeta(value)).toEqual({ id: '29224', retry: 8393 }) - - return true - }) - - await expect(output.next()).rejects.toSatisfy((err) => { - expect(err).toBeInstanceOf(ORPCError) - expect(err.code).toBe('INTERNAL') - expect(err.data).toEqual({ hello: 3 }) - expect(getEventMeta(err)).toEqual({ id: '391', retry: 28973 }) - - return true - }) - - expect(await output.next()).toEqual({ value: undefined, done: true }) - }) -}) diff --git a/packages/client/src/adapters/standard/rpc-link.ts b/packages/client/src/adapters/standard/rpc-link.ts deleted file mode 100644 index 89f46a68b..000000000 --- a/packages/client/src/adapters/standard/rpc-link.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ClientContext } from '../../types' -import type { StandardLinkOptions } from './link' -import type { StandardRPCJsonSerializerOptions } from './rpc-json-serializer' -import type { StandardRPCLinkCodecOptions } from './rpc-link-codec' -import type { StandardLinkClient } from './types' -import { StandardLink } from './link' -import { StandardRPCJsonSerializer } from './rpc-json-serializer' -import { StandardRPCLinkCodec } from './rpc-link-codec' -import { StandardRPCSerializer } from './rpc-serializer' - -export interface StandardRPCLinkOptions - extends StandardLinkOptions, StandardRPCLinkCodecOptions, StandardRPCJsonSerializerOptions {} - -export class StandardRPCLink extends StandardLink { - constructor(linkClient: StandardLinkClient, options: StandardRPCLinkOptions) { - const jsonSerializer = new StandardRPCJsonSerializer(options) - const serializer = new StandardRPCSerializer(jsonSerializer) - const linkCodec = new StandardRPCLinkCodec(serializer, options) - - super(linkCodec, linkClient, options) - } -} diff --git a/packages/client/src/adapters/standard/rpc-serializer.test.ts b/packages/client/src/adapters/standard/rpc-serializer.test.ts deleted file mode 100644 index 4d3065a7b..000000000 --- a/packages/client/src/adapters/standard/rpc-serializer.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { ORPCError } from '@orpc/contract' -import { isAsyncIteratorObject, parseEmptyableJSON } from '@orpc/shared' -import { ErrorEvent, getEventMeta, withEventMeta } from '@orpc/standard-server' -import { supportedDataTypes } from '../../../tests/shared' -import { StandardRPCJsonSerializer } from './rpc-json-serializer' -import { StandardRPCSerializer } from './rpc-serializer' - -describe.each(supportedDataTypes)('standardRPCSerializer: $name', ({ value, expected }) => { - const serializer = new StandardRPCSerializer(new StandardRPCJsonSerializer()) - - function serializeAndDeserialize(value: unknown): unknown { - const serialized = serializer.serialize(value) - - if (serialized instanceof FormData || serialized instanceof Blob) { - return serializer.deserialize(serialized) - } - - return serializer.deserialize(parseEmptyableJSON(JSON.stringify(serialized) ?? '')) // like in the real world - } - - it('should work on flat', async () => { - expect( - serializeAndDeserialize(value), - ).toEqual( - expected, - ) - }) - - it('should work on nested object', async () => { - expect( - serializeAndDeserialize({ - data: value, - }), - ).toEqual( - { - data: expected, - }, - ) - }) - - it('should work on complex object', async () => { - expect( - serializeAndDeserialize({ - '!@#$%^^&()[]>?<~_<:"~+!_': value, - 'list': [value], - 'map': new Map([[value, value]]), - 'set': new Set([value]), - 'nested': { - nested: value, - }, - }), - ).toEqual({ - '!@#$%^^&()[]>?<~_<:"~+!_': expected, - 'list': [expected], - 'map': new Map([[expected, expected]]), - 'set': new Set([expected]), - 'nested': { - nested: expected, - }, - }) - }) -}) - -describe('standardRPCSerializer: event iterator', async () => { - const serializer = new StandardRPCSerializer(new StandardRPCJsonSerializer()) - - function serializeAndDeserialize(value: unknown): unknown { - const serialized = serializer.serialize(value) - return serializer.deserialize(serialized) - } - - const date = new Date() - - it('on success', async () => { - const iterator = (async function* () { - yield 1 - yield withEventMeta({ order: 2, date }, { retry: 1000 }) - return withEventMeta({ order: 3 }, { id: '123456' }) - })() - - const deserialized = serializeAndDeserialize(iterator) as any - - expect(deserialized).toSatisfy(isAsyncIteratorObject) - await expect(deserialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual(1) - expect(getEventMeta(value)).toEqual(undefined) - - return true - }) - - await expect(deserialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual({ order: 2, date }) - expect(getEventMeta(value)).toEqual({ retry: 1000 }) - - return true - }) - - await expect(deserialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(true) - expect(value).toEqual({ order: 3 }) - expect(getEventMeta(value)).toEqual({ id: '123456' }) - - return true - }) - }) - - it('on error with ORPCError', async () => { - const error = withEventMeta(new ORPCError('BAD_GATEWAY', { data: { order: 3 } }), { id: '123456' }) - - const iterator = (async function* () { - yield 1 - yield withEventMeta({ order: 2, date }, { retry: 1000 }) - throw error - })() - - const deserialized = serializeAndDeserialize(iterator) as any - - expect(deserialized).toSatisfy(isAsyncIteratorObject) - await expect(deserialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual(1) - expect(getEventMeta(value)).toEqual(undefined) - - return true - }) - - await expect(deserialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual({ order: 2, date }) - expect(getEventMeta(value)).toEqual({ retry: 1000 }) - - return true - }) - - await expect(deserialized.next()).rejects.toSatisfy((e: any) => { - expect(e).toEqual(error) - expect(e).toBeInstanceOf(ORPCError) - expect(e.cause).toBeInstanceOf(ErrorEvent) - - return true - }) - }) - - it('on error with unknown error when deserialize', async () => { - const error = withEventMeta(new Error('UNKNOWN'), { id: '123456' }) - - const iterator = (async function* () { - yield serializer.serialize(1) - yield withEventMeta(serializer.serialize({ order: 2, date }) as any, { retry: 1000 }) - throw error - })() - - const deserialized = serializer.deserialize(iterator as any) as any - - expect(deserialized).toSatisfy(isAsyncIteratorObject) - await expect(deserialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual(1) - expect(getEventMeta(value)).toEqual(undefined) - - return true - }) - - await expect(deserialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual({ order: 2, date }) - expect(getEventMeta(value)).toEqual({ retry: 1000 }) - - return true - }) - - await expect(deserialized.next()).rejects.toSatisfy((e: any) => { - expect(e).toBe(error) - - return true - }) - }) - - it('deserialize an invalid-ORPCError', async () => { - const iterator = serializer.deserialize((async function* () { - throw new ErrorEvent({ - data: { json: { value: 1234 } }, - }) - })()) as any - - await expect(iterator.next()).rejects.toSatisfy((e: any) => { - expect(e).toBeInstanceOf(ErrorEvent) - expect(e.data).toEqual({ value: 1234 }) - - return true - }) - }) -}) - -it('standardRPCSerializer support deserialize undefined data', () => { - const serializer = new StandardRPCSerializer(new StandardRPCJsonSerializer()) - expect(serializer.deserialize(undefined)).toEqual(undefined) -}) diff --git a/packages/client/src/adapters/standard/rpc-serializer.ts b/packages/client/src/adapters/standard/rpc-serializer.ts deleted file mode 100644 index f07aff2a8..000000000 --- a/packages/client/src/adapters/standard/rpc-serializer.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { StandardRPCJsonSerializer } from './rpc-json-serializer' -import { isAsyncIteratorObject, stringifyJSON } from '@orpc/shared' -import { ErrorEvent } from '@orpc/standard-server' -import { createORPCErrorFromJson, isORPCErrorJson, toORPCError } from '../../error' -import { mapEventIterator } from '../../event-iterator' - -export class StandardRPCSerializer { - constructor( - private readonly jsonSerializer: StandardRPCJsonSerializer, - ) {} - - serialize(data: unknown): object { - if (isAsyncIteratorObject(data)) { - return mapEventIterator(data, { - value: async (value: unknown) => this.#serialize(value, false), - error: async (e) => { - return new ErrorEvent({ - data: this.#serialize(toORPCError(e).toJSON(), false), - cause: e, - }) - }, - }) - } - - return this.#serialize(data, true) - } - - #serialize( - data: unknown, - enableFormData: boolean, - ): object { - const [json, meta_, maps, blobs] = this.jsonSerializer.serialize(data) - - const meta = meta_.length === 0 ? undefined : meta_ - - if (!enableFormData || blobs.length === 0) { - return { - json, - meta, - } - } - - const form = new FormData() - - form.set('data', stringifyJSON({ json, meta, maps })) - - blobs.forEach((blob, i) => { - form.set(i.toString(), blob) - }) - - return form - } - - deserialize(data: unknown): unknown { - if (isAsyncIteratorObject(data)) { - return mapEventIterator(data, { - value: async value => this.#deserialize(value), - error: async (e) => { - if (!(e instanceof ErrorEvent)) { - return e - } - - const deserialized = this.#deserialize(e.data) - - if (isORPCErrorJson(deserialized)) { - return createORPCErrorFromJson(deserialized, { cause: e }) - } - - return new ErrorEvent({ - data: deserialized, - cause: e, - }) - }, - }) - } - - return this.#deserialize(data) - } - - #deserialize(data: any): unknown { - /** - * Only deserializing undefined is supported, while the return of serialize(undefined) is always an object. - * This is for supporting calling RPC endpoints without arguments. - * - * @todo Consider supporting serialize(undefined) -> undefined in the future - */ - if (data === undefined) { - return undefined - } - - if (!(data instanceof FormData)) { - return this.jsonSerializer.deserialize(data.json, data.meta ?? []) - } - - const serialized = JSON.parse(data.get('data') as string) - - return this.jsonSerializer.deserialize( - serialized.json, - serialized.meta ?? [], - serialized.maps, - (i: number) => data.get(i.toString()) as Blob, - ) - } -} diff --git a/packages/client/src/adapters/standard/transport.ts b/packages/client/src/adapters/standard/transport.ts new file mode 100644 index 000000000..a9d812328 --- /dev/null +++ b/packages/client/src/adapters/standard/transport.ts @@ -0,0 +1,15 @@ +import type { StandardLazyResponse, StandardRequest } from '@standardserver/core' +import type { ClientContext, ClientOptions } from '../../types' + +/** + * Handles the transport layer for sending requests and receiving responses. + * + * Implementations are responsible for the actual network communication, + * such as HTTP fetch, WebSocket, or other transport mechanisms. + */ +export interface StandardLinkTransport { + /** + * @throws Transport-level errors (network failures, timeouts, etc.) + */ + send(request: StandardRequest, path: string[], options: ClientOptions): Promise +} diff --git a/packages/client/src/adapters/standard/types.ts b/packages/client/src/adapters/standard/types.ts deleted file mode 100644 index cd04a31c5..000000000 --- a/packages/client/src/adapters/standard/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { StandardLazyResponse, StandardRequest } from '@orpc/standard-server' -import type { ClientContext, ClientOptions } from '../../types' - -export interface StandardLinkCodec { - encode(path: readonly string[], input: unknown, options: ClientOptions): Promise - decode(response: StandardLazyResponse, options: ClientOptions, path: readonly string[], input: unknown): Promise -} - -export interface StandardLinkClient { - call(request: StandardRequest, options: ClientOptions, path: readonly string[], input: unknown): Promise -} diff --git a/packages/client/src/adapters/standard/utils.test.ts b/packages/client/src/adapters/standard/utils.test.ts deleted file mode 100644 index 0de7d87bd..000000000 --- a/packages/client/src/adapters/standard/utils.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { getMalformedResponseErrorCode, toHttpPath, toStandardHeaders } from './utils' - -it('convertPathToHttpPath', () => { - expect(toHttpPath(['ping'])).toEqual('/ping') - expect(toHttpPath(['nested', 'ping'])).toEqual('/nested/ping') - expect(toHttpPath(['nested/', 'ping'])).toEqual('/nested%2F/ping') -}) - -it('toStandardHeaders', () => { - expect(toStandardHeaders({})).toEqual({}) - expect(toStandardHeaders({ 'content-type': 'application/json' })).toEqual({ 'content-type': 'application/json' }) - - expect(toStandardHeaders(new Headers())).toEqual({}) - const headers = new Headers({ 'content-type': 'application/json' }) - expect(toStandardHeaders(headers)).toEqual({ 'content-type': 'application/json' }) - expect(toStandardHeaders({ forEach: headers.forEach.bind(headers) } as any)).toEqual({ 'content-type': 'application/json' }) -}) - -it('getMalformedResponseErrorCode', () => { - expect(getMalformedResponseErrorCode(400)).toEqual('BAD_REQUEST') - expect(getMalformedResponseErrorCode(401)).toEqual('UNAUTHORIZED') - expect(getMalformedResponseErrorCode(433)).toEqual('MALFORMED_ORPC_ERROR_RESPONSE') -}) diff --git a/packages/client/src/adapters/standard/utils.ts b/packages/client/src/adapters/standard/utils.ts deleted file mode 100644 index c3b7869bd..000000000 --- a/packages/client/src/adapters/standard/utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { StandardHeaders } from '@orpc/standard-server' -import type { HTTPPath } from '../../types' -import { toStandardHeaders as fetchHeadersToStandardHeaders } from '@orpc/standard-server-fetch' -import { COMMON_ORPC_ERROR_DEFS } from '../../error' - -export function toHttpPath(path: readonly string[]): HTTPPath { - return `/${path.map(encodeURIComponent).join('/')}` -} - -export function toStandardHeaders(headers: Headers | StandardHeaders): StandardHeaders { - /** - * Determines if the provided `headers` is a headers-like object. - * Avoids `instanceof` checks as this is intended for standard APIs where the Headers constructor may not be available. - */ - if (typeof headers.forEach === 'function') { - return fetchHeadersToStandardHeaders(headers as Headers) - } - - return headers as StandardHeaders -} - -export function getMalformedResponseErrorCode(status: number): string { - return Object.entries(COMMON_ORPC_ERROR_DEFS).find(([, def]) => def.status === status)?.[0] ?? 'MALFORMED_ORPC_ERROR_RESPONSE' -} diff --git a/packages/client/src/adapters/websocket/index.test.ts b/packages/client/src/adapters/websocket/index.test.ts new file mode 100644 index 000000000..fe981df20 --- /dev/null +++ b/packages/client/src/adapters/websocket/index.test.ts @@ -0,0 +1,3 @@ +it('exports RPCLink', async () => { + await expect(import('.')).resolves.toHaveProperty('RPCLink') +}) diff --git a/packages/client/src/adapters/websocket/index.ts b/packages/client/src/adapters/websocket/index.ts index ffb52e53c..9a7dc93c1 100644 --- a/packages/client/src/adapters/websocket/index.ts +++ b/packages/client/src/adapters/websocket/index.ts @@ -1,2 +1,2 @@ -export * from './link-client' export * from './rpc-link' +export * from './transport' diff --git a/packages/client/src/adapters/websocket/link-client.ts b/packages/client/src/adapters/websocket/link-client.ts deleted file mode 100644 index 30eed60ab..000000000 --- a/packages/client/src/adapters/websocket/link-client.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { StandardLazyResponse, StandardRequest } from '@orpc/standard-server' -import type { ClientContext, ClientOptions } from '../../types' -import type { StandardLinkClient } from '../standard' -import { readAsBuffer } from '@orpc/shared' -import { ClientPeer } from '@orpc/standard-server-peer' - -/** - * Some env maybe not available WebSocket global - */ -const WEBSOCKET_CONNECTING = 0 satisfies WebSocket['CONNECTING'] -const WEBSOCKET_OPEN = 1 satisfies WebSocket['OPEN'] - -export interface LinkWebsocketClientOptions { - websocket: Pick -} - -export class LinkWebsocketClient implements StandardLinkClient { - private readonly peer: ClientPeer - - constructor(options: LinkWebsocketClientOptions) { - this.peer = new ClientPeer(async (message) => { - if (options.websocket.readyState === WEBSOCKET_CONNECTING) { - await new Promise((resolve) => { - const settle = () => { - options.websocket.removeEventListener('open', settle) - options.websocket.removeEventListener('close', settle) - resolve() - } - - options.websocket.addEventListener('open', settle, { once: true }) - options.websocket.addEventListener('close', settle, { once: true }) - }) - } - - if (options.websocket.readyState !== WEBSOCKET_OPEN) { - throw new Error('Cannot send message, WebSocket is not open.') - } - - return options.websocket.send(message) - }) - - options.websocket.addEventListener('message', async (event) => { - const message = event.data instanceof Blob - ? await readAsBuffer(event.data) - : event.data - - this.peer.message(message) - }) - - options.websocket.addEventListener('close', () => { - this.peer.close() - }) - } - - async call(request: StandardRequest, _options: ClientOptions, _path: readonly string[], _input: unknown): Promise { - const response = await this.peer.request(request) - return { ...response, body: () => Promise.resolve(response.body) } - } -} diff --git a/packages/client/src/adapters/websocket/rpc-link.test.ts b/packages/client/src/adapters/websocket/rpc-link.test.ts index 21ae817a1..aca0a1140 100644 --- a/packages/client/src/adapters/websocket/rpc-link.test.ts +++ b/packages/client/src/adapters/websocket/rpc-link.test.ts @@ -1,4 +1,5 @@ -import { decodeRequestMessage, encodeResponseMessage, MessageType } from '@orpc/standard-server-peer' +import { promiseWithResolvers } from '@orpc/shared' +import { decodePeerMessage, encodePeerMessage } from '@standardserver/peer' import { createORPCClient } from '../../client' import { RPCLink } from './rpc-link' @@ -6,204 +7,416 @@ 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('rpcLink', () => { - let onMessage: any - let onClose: any - - const websocket = { - readyState: 1, - addEventListener: vi.fn((event, callback) => { - if (event === 'message') - onMessage = callback - if (event === 'close') - onClose = callback - }), - removeEventListener: vi.fn(), - send: vi.fn(), + const createWs = (readyState: 0 | 1 | 2 | 3 = WEBSOCKET_OPEN) => { + const openListeners = new Set<() => void | Promise>() + const messageListeners = new Set<(event: { data: unknown }) => void | Promise>() + const closeListeners = new Set<(event: { code: number, reason: string }) => void | Promise>() + + const websocket = { + readyState: readyState as any, + removeEventListener: vi.fn((event: string, callback: any) => { + if (event === 'open') { + openListeners.delete(callback) + return + } + + if (event === 'message') { + messageListeners.delete(callback) + return + } + + if (event === 'close') { + closeListeners.delete(callback) + return + } + + throw new Error(`${event} is not supported`) + }), + addEventListener: vi.fn((event: string, callback: any) => { + if (event === 'open') { + openListeners.add(callback) + return + } + + if (event === 'message') { + messageListeners.add(callback) + return + } + + if (event === 'close') { + closeListeners.add(callback) + return + } + + throw new Error(`${event} is not supported`) + }), + send: vi.fn(), + async open() { + websocket.readyState = WEBSOCKET_OPEN + await Promise.all([...openListeners].map(listener => listener())) + }, + async receive(data: unknown) { + await Promise.all([...messageListeners].map(listener => listener({ data }))) + }, + async close(event: Partial<{ code: number, reason: string }> = {}) { + websocket.readyState = WEBSOCKET_CLOSED + + await Promise.all([...closeListeners].map(listener => listener({ + code: event.code ?? 1006, + reason: event.reason ?? '', + }))) + }, + } + + return websocket } - const link = new RPCLink({ - websocket, - }) + const createResponseMessage = async ({ + id, + body = { json: 'pong' }, + status = 200, + prefix, + }: { id: string, body?: unknown, status?: number, prefix?: string }) => { + return encodePeerMessage({ + id, + kind: 'response', + json: { body, status, headers: {} }, + }, prefix ? { prefix } : undefined) + } - const orpc = createORPCClient(link) as any + const decodeRequest = (sent: any, prefix?: string) => { + return decodePeerMessage(sent, prefix ? { prefix } : undefined) as { + matched: true + message: { id: string, kind: string, json: any } + } + } + + const getSentRequest = (ws: ReturnType, index = 0, prefix?: string) => { + return decodeRequest(ws.send.mock.calls[index]![0], prefix) + } + + it.each([ + ['string', async (encoded: string | Uint8Array) => encoded], + ['blob', async (encoded: string | Uint8Array) => new Blob([encoded])], + ])('sends RPC requests and resolves %s websocket responses', async (_type, transform) => { + const ws = createWs() + const orpc = createORPCClient(new RPCLink({ connect: () => ws })) as any - it('on success', async () => { - const promise = expect(orpc.ping('input')).resolves.toEqual('pong') + const promise = orpc.ping('input') - await vi.waitFor(() => expect(websocket.send).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(1)) - const [id,, payload] = (await decodeRequestMessage(websocket.send.mock.calls[0]![0])) + const decoded = getSentRequest(ws) - expect(id).toBeTypeOf('string') - expect(payload).toEqual({ - url: new URL('http://orpc/ping'), + expect(decoded.matched).toBe(true) + expect(decoded.message.kind).toBe('request') + expect(decoded.message.id).toBeTypeOf('string') + expect(decoded.message.json).toEqual({ + url: '/ping', body: { json: 'input' }, headers: {}, method: 'POST', }) - onMessage({ data: await encodeResponseMessage(id, MessageType.RESPONSE, { body: { json: 'pong' }, status: 200, headers: {} }) }) + const raw = await createResponseMessage({ id: decoded.message.id }) + await ws.receive(await transform(raw)) - await promise + await expect(promise).resolves.toEqual('pong') }) - it('on success - blob', async () => { - const promise = expect(orpc.ping('input')).resolves.toEqual('pong') + it('connects eagerly on init and reuses that websocket for the first call', async () => { + const ws = createWs(WEBSOCKET_CONNECTING) + const connect = vi.fn(() => ws) + const orpc = createORPCClient(new RPCLink({ connect, connectOnInit: true })) as any - await vi.waitFor(() => expect(websocket.send).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(1)) + expect(connect).toHaveBeenCalledWith({ totalAttempt: 1, attempt: 1 }) - const [id, , payload] = (await decodeRequestMessage(websocket.send.mock.calls[0]![0])) + const promise = orpc.ping('input') - expect(id).toBeTypeOf('string') - expect(payload).toEqual({ - url: new URL('http://orpc/ping'), - body: { json: 'input' }, - headers: {}, - method: 'POST', + expect(ws.send).toHaveBeenCalledTimes(0) + + await ws.open() + await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(1)) + + const decoded = getSentRequest(ws) + await ws.receive(await createResponseMessage({ id: decoded.message.id })) + + await expect(promise).resolves.toEqual('pong') + expect(connect).toHaveBeenCalledTimes(1) + }) + + it('connects eagerly on init ignore background error', async ({ onTestFinished }) => { + const unhandledRejectionHandler = vi.fn() + process.on('unhandledRejection', unhandledRejectionHandler) + + onTestFinished(() => { + process.off('unhandledRejection', unhandledRejectionHandler) }) - onMessage({ data: new Blob([await encodeResponseMessage(id, MessageType.RESPONSE, { body: { json: 'pong' }, status: 200, headers: {} })]) }) + const connect = vi.fn().mockRejectedValueOnce(new Error('TEST')) + const orpc = createORPCClient(new RPCLink({ connect, connectOnInit: true })) as any - await promise + await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(1)) + expect(connect).toHaveBeenCalledWith({ totalAttempt: 1, attempt: 1 }) + expect(unhandledRejectionHandler).toHaveBeenCalledTimes(0) // no background error }) - it('on close', async () => { - expect(orpc.ping('input')).rejects.toThrow(/aborted/) + it('shares a single lazy websocket connection across concurrent requests', async () => { + const ws = createWs(WEBSOCKET_CONNECTING) + const connect = vi.fn(() => ws) + const orpc = createORPCClient(new RPCLink({ connect })) as any + + const promise1 = orpc.ping('input-1') + const promise2 = orpc.ping('input-2') - await new Promise(resolve => setTimeout(resolve, 0)) + await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(1)) + expect(ws.send).toHaveBeenCalledTimes(0) - onClose() + await ws.open() + await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(2)) + + const firstRequest = getSentRequest(ws, 0) + const secondRequest = getSentRequest(ws, 1) + + await ws.receive(await createResponseMessage({ id: firstRequest.message.id, body: { json: 'pong-1' } })) + await ws.receive(await createResponseMessage({ id: secondRequest.message.id, body: { json: 'pong-2' } })) + + await expect(promise1).resolves.toEqual('pong-1') + await expect(promise2).resolves.toEqual('pong-2') + expect(connect).toHaveBeenCalledTimes(1) }) - it('waits until open before sending', async () => { - let onOpen: any + it('aborts a call while the websocket connection is still being resolved', async () => { + const pendingSocket = createWs() + const connection = promiseWithResolvers() + const connect = vi.fn(() => connection.promise) + const orpc = createORPCClient(new RPCLink({ connect })) as any + const controller = new AbortController() + const reason = new Error('request aborted') - const websocket = { - readyState: 0, - addEventListener: vi.fn((event, callback) => { - if (event === 'message') - onMessage = callback - if (event === 'close') - onClose = callback - if (event === 'open') - onOpen = callback - }), - removeEventListener: vi.fn(), - send: vi.fn(), - } + const promise = orpc.ping('input', { signal: controller.signal }) + + await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(1)) + + controller.abort(reason) + connection.resolve(pendingSocket) + + await expect(promise).rejects.toBe(reason) + expect(pendingSocket.send).toHaveBeenCalledTimes(0) + }) + + it('aborts a call if signal was aborted before connect', async () => { + const pendingSocket = createWs() + const connection = promiseWithResolvers() + const connect = vi.fn(() => connection.promise) + const orpc = createORPCClient(new RPCLink({ connect })) as any + const controller = new AbortController() + const reason = new Error('request aborted') + controller.abort(reason) + + const promise = orpc.ping('input', { signal: controller.signal }) + + await expect(promise).rejects.toBe(reason) + expect(pendingSocket.send).toHaveBeenCalledTimes(0) + expect(connect).toHaveBeenCalledTimes(0) + }) + + it('supports prefixed peer messages and ignores unrelated frames', async () => { + const ws = createWs() const orpc = createORPCClient(new RPCLink({ - websocket, + connect: () => ws, + encodePeerMessage: { prefix: 'orpc:' }, + decodePeerMessage: { prefix: 'orpc:' }, })) as any - const promise = expect(orpc.ping('input')).resolves.toEqual('pong') + const promise = orpc.ping('input') + + await vi.waitFor(() => expect(ws.send).toHaveBeenCalledTimes(1)) - await new Promise(resolve => setTimeout(resolve, 10)) - expect(websocket.send).toHaveBeenCalledTimes(0) + const decoded = getSentRequest(ws, 0, 'orpc:') - websocket.readyState = 1 - onOpen() - await vi.waitFor(() => expect(websocket.send).toHaveBeenCalledTimes(1)) + expect(decoded.matched).toBe(true) + expect(decoded.message.kind).toBe('request') - const [id] = (await decodeRequestMessage(websocket.send.mock.calls[0]![0])) - onMessage({ data: await encodeResponseMessage(id, MessageType.RESPONSE, { body: { json: 'pong' }, status: 200, headers: {} }) }) + await ws.receive(await createResponseMessage({ id: decoded.message.id, prefix: 'wrong:' })) + await ws.receive('not-a-peer-message') + await ws.receive(await createResponseMessage({ id: decoded.message.id, prefix: 'orpc:' })) - await promise + await expect(promise).resolves.toEqual('pong') }) - describe('non-open sockets', () => { - function createWebSocket(readyState: number) { - const listeners = new Map void>>() - - return { - readyState, - addEventListener: vi.fn((event: string, callback: (event?: any) => void) => { - if (!listeners.has(event)) { - listeners.set(event, new Set()) - } - listeners.get(event)!.add(callback) - }), - removeEventListener: vi.fn((event: string, callback: (event?: any) => void) => { - listeners.get(event)?.delete(callback) - }), - send: vi.fn(), - emit: (event: string, payload?: any) => { - [...listeners.get(event) ?? []].forEach(callback => callback(payload)) - }, - } - } + it('propagates connection failures when reconnect is disabled', async () => { + const error = new Error('connect failed') + const orpc = createORPCClient(new RPCLink({ connect: () => Promise.reject(error) })) as any + + await expect(orpc.ping('input')).rejects.toBe(error) + }) + + it('stops retrying after the configured reconnect attempts', async () => { + const delay = vi.fn(() => 0) + const connect = vi.fn(() => Promise.reject(new Error('temporary outage'))) + const orpc = createORPCClient(new RPCLink({ + connect, + reconnect: { + enabled: true, + delay, + maxAttempt: 1, + }, + })) as any - it('rejects instead of sending when socket is not open', async () => { - const websocket = createWebSocket(3) - const orpc = createORPCClient(new RPCLink({ - websocket, - })) as any + await expect(orpc.ping('input')).rejects.toThrow('WebSocket reconnect failed after 1 attempt(s)') + expect(delay).toHaveBeenCalledWith({ totalAttempt: 1, attempt: 1 }) + expect(connect).toHaveBeenCalledTimes(1) + }) - await expect(orpc.ping('input')).rejects.toThrow('WebSocket is not open') - expect(websocket.send).toHaveBeenCalledTimes(0) + it('uses the default reconnect backoff before retrying a transient connection failure', async ({ onTestFinished }) => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() }) - it('rejects instead of sending when socket closes between requests (reconnecting wrappers)', async () => { - const websocket = createWebSocket(1) - const orpc = createORPCClient(new RPCLink({ - websocket, - })) as any + const recoveredSocket = createWs() + const connect = vi.fn() + .mockRejectedValueOnce(new Error('temporary outage')) + .mockResolvedValueOnce(recoveredSocket) + const orpc = createORPCClient(new RPCLink({ + connect, + reconnect: { enabled: true }, + })) as any - const promise = expect(orpc.ping('input')).resolves.toEqual('pong') - await vi.waitFor(() => expect(websocket.send).toHaveBeenCalledTimes(1)) - const [id] = (await decodeRequestMessage(websocket.send.mock.calls[0]![0])) - websocket.emit('message', { data: await encodeResponseMessage(id, MessageType.RESPONSE, { body: { json: 'pong' }, status: 200, headers: {} }) }) - await promise + const promise = orpc.ping('input') - websocket.readyState = 3 + await vi.advanceTimersByTimeAsync(0) + expect(connect).toHaveBeenNthCalledWith(1, { totalAttempt: 1, attempt: 1 }) - await expect(orpc.ping('input')).rejects.toThrow('WebSocket is not open') - expect(websocket.send).toHaveBeenCalledTimes(1) - }) + await vi.advanceTimersByTimeAsync(1_999) + expect(connect).toHaveBeenCalledTimes(1) - it('waits during a reconnect window and sends once reopened', async () => { - const websocket = createWebSocket(1) - const orpc = createORPCClient(new RPCLink({ - websocket, - })) as any + await vi.advanceTimersByTimeAsync(1) + expect(connect).toHaveBeenNthCalledWith(2, { totalAttempt: 2, attempt: 2 }) + expect(recoveredSocket.send).toHaveBeenCalledTimes(1) - websocket.readyState = 0 + const request = getSentRequest(recoveredSocket) + await recoveredSocket.receive(await createResponseMessage({ id: request.message.id, body: { json: 'recovered' } })) - const promise = expect(orpc.ping('input')).resolves.toEqual('pong') + await expect(promise).resolves.toEqual('recovered') + }) - await new Promise(resolve => setTimeout(resolve, 10)) - expect(websocket.send).toHaveBeenCalledTimes(0) + it('reconnects on the next call after a socket closes', async () => { + const firstSocket = createWs() + const secondSocket = createWs() + const connect = vi.fn() + .mockImplementationOnce(() => firstSocket) + .mockImplementationOnce(() => secondSocket) + const orpc = createORPCClient(new RPCLink({ + connect, + reconnect: { enabled: true }, + })) as any - websocket.readyState = 1 - websocket.emit('open') - await vi.waitFor(() => expect(websocket.send).toHaveBeenCalledTimes(1)) + const firstCall = orpc.ping('first') - const [id] = (await decodeRequestMessage(websocket.send.mock.calls[0]![0])) - websocket.emit('message', { data: await encodeResponseMessage(id, MessageType.RESPONSE, { body: { json: 'pong' }, status: 200, headers: {} }) }) + await vi.waitFor(() => expect(firstSocket.send).toHaveBeenCalledTimes(1)) + const firstRequest = getSentRequest(firstSocket) + await firstSocket.receive(await createResponseMessage({ id: firstRequest.message.id, body: { json: 'pong-1' } })) + await expect(firstCall).resolves.toEqual('pong-1') - await promise - }) + await firstSocket.close({ code: 4001, reason: 'server restart' }) - it('rejects without sending when a reconnect attempt fails', async () => { - const websocket = createWebSocket(0) - const orpc = createORPCClient(new RPCLink({ - websocket, - })) as any + const secondCall = orpc.ping('second') - const promise = expect(orpc.ping('input')).rejects.toThrow(/aborted|not open/) + await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(2)) + expect(connect).toHaveBeenNthCalledWith(1, { totalAttempt: 1, attempt: 1 }) + expect(connect).toHaveBeenNthCalledWith(2, { totalAttempt: 2, attempt: 1 }) + await vi.waitFor(() => expect(secondSocket.send).toHaveBeenCalledTimes(1)) - await new Promise(resolve => setTimeout(resolve, 10)) - expect(websocket.send).toHaveBeenCalledTimes(0) + const secondRequest = getSentRequest(secondSocket) + await secondSocket.receive(await createResponseMessage({ id: secondRequest.message.id, body: { json: 'pong-2' } })) + await expect(secondCall).resolves.toEqual('pong-2') + }) - websocket.readyState = 3 - websocket.emit('close') + it('can proactively reconnect on close before the next call arrives', async () => { + const firstSocket = createWs() + const secondSocket = createWs() + const connect = vi.fn() + .mockImplementationOnce(() => firstSocket) + .mockImplementationOnce(() => secondSocket) + const orpc = createORPCClient(new RPCLink({ + connect, + reconnect: { + enabled: true, + onClose: { + enabled: true, + delay: 0, + }, + }, + })) as any + + const firstCall = orpc.ping('first') + + await vi.waitFor(() => expect(firstSocket.send).toHaveBeenCalledTimes(1)) + const firstRequest = getSentRequest(firstSocket) + await firstSocket.receive(await createResponseMessage({ id: firstRequest.message.id })) + await expect(firstCall).resolves.toEqual('pong') + + await firstSocket.close({ code: 1001, reason: 'going away' }) + + await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(2)) + + const secondCall = orpc.ping('second') + + await vi.waitFor(() => expect(secondSocket.send).toHaveBeenCalledTimes(1)) + const secondRequest = getSentRequest(secondSocket) + await secondSocket.receive(await createResponseMessage({ id: secondRequest.message.id, body: { json: 'pong-2' } })) + + await expect(secondCall).resolves.toEqual('pong-2') + expect(connect).toHaveBeenCalledTimes(2) + }) - await promise + it('reconnect on close before the next call arrives ignore background errors', async ({ onTestFinished }) => { + const unhandledRejectionHandler = vi.fn() + process.on('unhandledRejection', unhandledRejectionHandler) - websocket.readyState = 1 - websocket.emit('open') - await new Promise(resolve => setTimeout(resolve, 10)) - expect(websocket.send).toHaveBeenCalledTimes(0) + onTestFinished(() => { + process.off('unhandledRejection', unhandledRejectionHandler) }) + + const firstSocket = createWs() + const connect = vi.fn() + .mockImplementationOnce(() => firstSocket) + .mockRejectedValueOnce(new Error('TEST')) + + const orpc = createORPCClient(new RPCLink({ + connect, + reconnect: { + enabled: true, + maxAttempt: 1, + onClose: { + enabled: true, + delay: 0, + }, + }, + })) as any + + const firstCall = orpc.ping('first') + + await vi.waitFor(() => expect(firstSocket.send).toHaveBeenCalledTimes(1)) + const firstRequest = getSentRequest(firstSocket) + await firstSocket.receive(await createResponseMessage({ id: firstRequest.message.id })) + await expect(firstCall).resolves.toEqual('pong') + + await firstSocket.close({ code: 1001, reason: 'going away' }) + + await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(2)) + expect(unhandledRejectionHandler).toHaveBeenCalledTimes(0) // no background error }) }) diff --git a/packages/client/src/adapters/websocket/rpc-link.ts b/packages/client/src/adapters/websocket/rpc-link.ts index 90a0ea3e8..ef43c2d86 100644 --- a/packages/client/src/adapters/websocket/rpc-link.ts +++ b/packages/client/src/adapters/websocket/rpc-link.ts @@ -1,22 +1,17 @@ import type { ClientContext } from '../../types' -import type { StandardRPCLinkOptions } from '../standard' -import type { LinkWebsocketClientOptions } from './link-client' -import { StandardRPCLink } from '../standard' -import { LinkWebsocketClient } from './link-client' +import type { RPCLinkCodecOptions, StandardLinkOptions } from '../standard' +import type { WebsocketLinkTransportOptions } from './transport' +import { RPCLinkCodec, StandardLink } from '../standard' +import { WebsocketLinkTransport } from './transport' export interface RPCLinkOptions - extends Omit, 'url' | 'method' | 'fallbackMethod' | 'maxUrlLength'>, LinkWebsocketClientOptions {} + extends StandardLinkOptions, WebsocketLinkTransportOptions, RPCLinkCodecOptions { +} -/** - * The RPC Link communicates with the server using the RPC protocol over WebSocket. - * - * @see {@link https://orpc.dev/docs/client/rpc-link RPC Link Docs} - * @see {@link https://orpc.dev/docs/adapters/websocket WebSocket Adapter Docs} - */ -export class RPCLink extends StandardRPCLink { +export class RPCLink extends StandardLink { constructor(options: RPCLinkOptions) { - const linkClient = new LinkWebsocketClient(options) - - super(linkClient, { ...options, url: 'http://orpc' }) + const codec = new RPCLinkCodec(options) + const transport = new WebsocketLinkTransport(options) + super(codec, transport, options) } } diff --git a/packages/client/src/adapters/websocket/transport.ts b/packages/client/src/adapters/websocket/transport.ts new file mode 100644 index 000000000..d59cdf5ff --- /dev/null +++ b/packages/client/src/adapters/websocket/transport.ts @@ -0,0 +1,234 @@ +import type { Promisable } from '@orpc/shared' +import type { StandardLazyResponse, StandardRequest } from '@standardserver/core' +import type { DecodePeerMessageOptions, EncodePeerMessageOptions } from '@standardserver/peer' +import type { ClientContext, ClientOptions } from '../../types' +import type { StandardLinkTransport } from '../standard' +import { AbortError, promiseWithResolvers, runWithSignal, sleep, toStringOrBytes } from '@orpc/shared' +import { ClientPeer, decodePeerMessage, encodePeerMessage, isServerPeerSendMessage } from '@standardserver/peer' + +/** + * Some env maybe not available WebSocket global, like node 20 + */ +const WEBSOCKET_CONNECTING = 0 satisfies WebSocket['CONNECTING'] +const WEBSOCKET_OPEN = 1 satisfies WebSocket['OPEN'] + +export type WebSocketLike = Pick + +export interface WebsocketLinkTransportAttemptInfo { + /** + * Total number of connection attempts for this transport's lifetime. + * Starts at 1 on the first attempt, increments on every subsequent + * attempt, and never resets. + */ + totalAttempt: number + + /** + * Attempt number within the current (re)connect cycle. + * Starts at 1, increments on each consecutive failure, and resets to 1 + * once a connection succeeds. Use this for backoff calculations. + */ + attempt: number +} + +export interface WebsocketLinkTransportReconnectOptions { + /** + * Whether to automatically reconnect when the connection is lost. + * + * @default false + */ + enabled: boolean + + /** + * Delay before a (re)connect attempt, in milliseconds. + * + * @default info => info.attempt === 1 ? 0 : 2_000 + */ + delay?: undefined | ((info: WebsocketLinkTransportAttemptInfo) => number) + + /** + * Maximum number of consecutive failed attempts before giving up. + * When exceeded, `getConnectedPeer` throws instead of retrying. + * Should greater than 1 + * + * @default Infinity + */ + maxAttempt?: undefined | number + + /** + * Whether to proactively reconnect right after the socket closes, + * rather than waiting for the next call to trigger reconnection. + * Reduces latency for the next request. + * + * @default { enabled: false } + */ + onClose?: undefined | { + /** + * Whether to proactively reconnect right after the socket closes, + * rather than waiting for the next call to trigger reconnection. + * Reduces latency for the next request. + * + * @default false + */ + enabled: boolean + + /** + * Delay before reconnecting after the socket closes, in milliseconds. + * + * @default 0 + */ + delay?: number + } +} + +export interface WebsocketLinkTransportOptions<_T extends ClientContext> { + /** + * Returns a WebSocket instance for peer communication. + * Can be async for lazy resolution. + */ + connect: (info: WebsocketLinkTransportAttemptInfo) => Promisable + + /** + * Whether to connect immediately on initialization, instead of waiting + * for the first call. Reduces latency for the first request. + * + * @default false + */ + connectOnInit?: undefined | boolean + + /** + * Reconnection behavior when the connection is lost. + * + * @default { enabled: false } + */ + reconnect?: undefined | WebsocketLinkTransportReconnectOptions + + /** + * Options for encoding peer messages. such as `prefix` for distinguishing messages on the same channel.. + */ + encodePeerMessage?: EncodePeerMessageOptions | undefined + + /** + * Options for decoding peer messages. such as `prefix` for distinguishing messages on the same channel.. + */ + decodePeerMessage?: DecodePeerMessageOptions | undefined +} + +export class WebsocketLinkTransport implements StandardLinkTransport { + private readonly connect: WebsocketLinkTransportOptions['connect'] + private readonly reconnectEnabled: boolean + private readonly reconnectDelay: (info: WebsocketLinkTransportAttemptInfo) => number + private readonly reconnectMaxAttempt: number + private readonly reconnectOnCloseEnabled: boolean + private readonly reconnectOnCloseDelay: number + private readonly encodePeerMessageOptions: WebsocketLinkTransportOptions['encodePeerMessage'] + private readonly decodePeerMessageOptions: WebsocketLinkTransportOptions['decodePeerMessage'] + + constructor(options: WebsocketLinkTransportOptions) { + this.connect = options.connect + this.reconnectEnabled = options.reconnect?.enabled ?? false + this.reconnectDelay = options.reconnect?.delay ?? (info => info.attempt === 1 ? 0 : 2_000) + this.reconnectMaxAttempt = options.reconnect?.maxAttempt ?? Infinity + this.reconnectOnCloseEnabled = this.reconnectEnabled && (options.reconnect?.onClose?.enabled ?? false) + this.reconnectOnCloseDelay = options.reconnect?.onClose?.delay ?? 0 + + this.encodePeerMessageOptions = options.encodePeerMessage + this.decodePeerMessageOptions = options.decodePeerMessage + + if (options.connectOnInit) { + this.getConnectedPeer().catch(() => {}) + } + } + + async send(standardRequest: StandardRequest, _path: string[], _options: ClientOptions): Promise { + /** + * Because `this.getConnectedPeer` can delay requests due to connect/reconnect operations + * so we need manually handle signal to ensure request lifecycle is correct. + */ + const peer = await runWithSignal( + standardRequest.signal, + () => this.getConnectedPeer(), + ) + + return peer.request(standardRequest) + } + + private totalAttempt = 0 + private attempt = 0 + private current: undefined | Promise + private async getConnectedPeer(): Promise { + const current = this.current + const resolved = await current + + if (resolved && (!this.reconnectEnabled || resolved.websocket.readyState === WEBSOCKET_OPEN)) { + this.attempt = 0 + return resolved.peer + } + + // Race condition: another call has already established the current connection state. + if (current !== this.current) { + return this.getConnectedPeer() + } + + if (this.attempt >= this.reconnectMaxAttempt) { + throw new AbortError(`WebSocket reconnect failed after ${this.attempt} attempt(s)`) + } + + this.current = (async () => { + this.totalAttempt += 1 + this.attempt += 1 + + const info: WebsocketLinkTransportAttemptInfo = { totalAttempt: this.totalAttempt, attempt: this.attempt } + + await sleep(this.reconnectDelay(info)) + const websocket = await this.connect(info) + + const peer = new ClientPeer(async (message) => { + const encoded = await encodePeerMessage(message, this.encodePeerMessageOptions) + // WebSocket throws on non-open state, so no manual readyState check needed + return websocket.send(encoded) + }) + + let connectingResolvers: undefined | { promise: Promise, resolve: () => void } + if (websocket.readyState === WEBSOCKET_CONNECTING) { + connectingResolvers = promiseWithResolvers() + websocket.addEventListener('open', () => { + connectingResolvers?.resolve() + }) + } + + websocket.addEventListener('message', async (event: MessageEvent) => { + const message = await toStringOrBytes(event.data) + const result = decodePeerMessage(message, this.decodePeerMessageOptions) + if (result.matched && isServerPeerSendMessage(result.message)) { + await peer.message(result.message) + } + }) + + websocket.addEventListener('close', async (event) => { + connectingResolvers?.resolve() + + if (this.reconnectOnCloseEnabled) { + sleep(this.reconnectOnCloseDelay) + .then(() => this.getConnectedPeer()) + .catch(() => {}) + } + + const reason = new AbortError(`WebSocket closed (code ${event.code}: ${event.reason})`) + await peer.close(reason) + }) + + await connectingResolvers?.promise + connectingResolvers = undefined // no more needed + + return { websocket, peer } + })().catch((error) => { + // Connection failures must be thrown if reconnect is not enabled + // Resolving to `undefined` would cause subsequent calls to reconnect again, + if (!this.reconnectEnabled) { + throw error + } + }) + + return this.getConnectedPeer() + } +} diff --git a/packages/client/src/client-safe.test-d.ts b/packages/client/src/client-safe.test-d.ts index 5e4db71ea..fe3c050fb 100644 --- a/packages/client/src/client-safe.test-d.ts +++ b/packages/client/src/client-safe.test-d.ts @@ -15,12 +15,12 @@ it('SafeClient', async () => { const pingResult = await safeClient.ping('test') expectTypeOf(pingResult.error).toEqualTypeOf | null>() expectTypeOf(pingResult.data).toEqualTypeOf() - expectTypeOf(pingResult.isDefined).toEqualTypeOf() + expectTypeOf(pingResult.inferableError).toEqualTypeOf | null>() expectTypeOf(pingResult.isSuccess).toEqualTypeOf() const pongResult = await safeClient.nested.pong({ id: 123 }) expectTypeOf(pongResult.error).toEqualTypeOf() expectTypeOf(pongResult.data).toEqualTypeOf<{ result: string } | undefined>() - expectTypeOf(pongResult.isDefined).toEqualTypeOf() + expectTypeOf(pongResult.inferableError).toEqualTypeOf() expectTypeOf(pongResult.isSuccess).toEqualTypeOf() }) diff --git a/packages/client/src/client-safe.ts b/packages/client/src/client-safe.ts index b9b0ecd78..6df468ed6 100644 --- a/packages/client/src/client-safe.ts +++ b/packages/client/src/client-safe.ts @@ -1,13 +1,13 @@ -import type { Client, ClientRest, NestedClient } from './types' +import type { AnyNestedClient, Client, ClientRest } from './types' import type { SafeResult } from './utils' -import { isTypescriptObject } from '@orpc/shared' +import { getOrBind, isTypescriptObject } from '@orpc/shared' import { safe } from './utils' -export type SafeClient> +export type SafeClient = T extends Client ? (...rest: ClientRest) => Promise> : { - [K in keyof T]: T[K] extends NestedClient ? SafeClient : never + [K in keyof T]: T[K] extends AnyNestedClient ? SafeClient : never } /** @@ -16,25 +16,22 @@ export type SafeClient> * @example * ```ts * const safeClient = createSafeClient(client) - * const { error, data, isDefined } = await safeClient.doSomething({ id: '123' }) + * const { error, data, inferrableError, isSuccess } = await safeClient.doSomething({ id: '123' }) + * // or const [error, data, inferrableError, isSuccess] = await safeClient.doSomething({ id: '123' }) * ``` * * @see {@link https://orpc.dev/docs/client/error-handling#using-createsafeclient Safe Client Docs} */ -export function createSafeClient>(client: T): SafeClient { +export function createSafeClient(client: T): SafeClient { const proxy = new Proxy((...args: any[]) => safe((client as any)(...args)), { - get(_, prop, receiver) { - const value = Reflect.get(client, prop, receiver) - - if (typeof prop !== 'string') { - return value - } + get(_, prop) { + const value = getOrBind(client, prop) if (!isTypescriptObject(value)) { return value } - return createSafeClient(value as NestedClient) + return createSafeClient(value as AnyNestedClient) }, }) diff --git a/packages/client/src/client.test-d.ts b/packages/client/src/client.test-d.ts index 191909159..5fcc6cd4a 100644 --- a/packages/client/src/client.test-d.ts +++ b/packages/client/src/client.test-d.ts @@ -1,24 +1,95 @@ -import type { ContractRouterClient } from '@orpc/contract' +import type { RouterContractClient } from '@orpc/contract' import type { RouterClient } from '@orpc/server' -import type { router as contract } from '../../contract/tests/shared' -import type { router } from '../../server/tests/shared' +import type { PromiseWithError } from '@orpc/shared' import type { ClientLink } from './types' +import { ORPCError, os, type } from '@orpc/server' import { createORPCClient } from './client' -it('createORPCClient require match context between client and link', () => { - const _1: RouterClient = createORPCClient({} as ClientLink<{ cache: string }>) - const _11: RouterClient = createORPCClient({} as ClientLink<{ cache?: string }>) - const _111: RouterClient = createORPCClient({} as ClientLink<{ cache?: string, tags?: string[] }>) - const _1111: RouterClient = createORPCClient({} as ClientLink<{ cache?: string }>) - // @ts-expect-error -- cache is required - const _11111: RouterClient = createORPCClient({} as ClientLink<{ cache: string }>) - // @ts-expect-error -- expect cache is optional - const _2: RouterClient = createORPCClient({} as ClientLink<{ cache: string }>) - // @ts-expect-error -- expect cache is number - const _3: RouterClient = createORPCClient({} as ClientLink<{ cache?: string }>) - - const _4: ContractRouterClient = createORPCClient({} as ClientLink<{ cache?: string }>) - // @ts-expect-error -- cache is required - const _44: ContractRouterClient = createORPCClient({} as ClientLink<{ cache: string }>) - const _444: ContractRouterClient = createORPCClient({} as ClientLink<{ cache: string }>) +const router = { + ping: os.input(type()).handler(() => 'pong'), + nested: { + pong: os.input(type()).handler(() => new ORPCError('TEST', { data: 'string' })), + }, +} + +describe('createORPCClient', () => { + it('require match context between client and link', () => { + const _1: RouterClient = createORPCClient({} as ClientLink<{ cache: string }>) + const _11: RouterClient = createORPCClient({} as ClientLink<{ cache?: string }>) + const _111: RouterClient = createORPCClient({} as ClientLink<{ cache?: string, tags?: string[] }>) + const _1111: RouterClient = createORPCClient({} as ClientLink<{ cache?: string }>) + + // @ts-expect-error -- cache is required + const _11111: RouterClient = createORPCClient({} as ClientLink<{ cache: string }>) + + // @ts-expect-error -- expect cache is optional + const _2: RouterClient = createORPCClient({} as ClientLink<{ cache: string }>) + + // @ts-expect-error -- expect cache is number + const _3: RouterClient = createORPCClient({} as ClientLink<{ cache?: string }>) + + const _4: RouterContractClient = createORPCClient({} as ClientLink<{ cache?: string }>) + + // @ts-expect-error -- cache is required + const _44: RouterContractClient = createORPCClient({} as ClientLink<{ cache: string }>) + + const _444: RouterContractClient = createORPCClient({} as ClientLink<{ cache: string }>) + }) + + it('interceptors infer correct types', () => { + const _client: RouterClient = createORPCClient({} as ClientLink<{ cache: string }>, { + interceptors: [ + ({ input, context, next }) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ cache: string }>() + + const result = next() + + expectTypeOf(result).toEqualTypeOf>>() + + return result + }, + ], + }) + }) + + it('scoped expose correct client structure and infer correct types', () => { + const _client: RouterClient = createORPCClient({} as ClientLink<{ cache: string }>, { + scoped: { + ping: { + interceptors: [ + ({ input, context, next }) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ cache: string }>() + + const result = next() + + expectTypeOf(result).toEqualTypeOf>() + + return result + }, + ], + }, + nested: { + pong: { + interceptors: [ + ({ input, context, next }) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ cache: string }>() + + const result = next() + + expectTypeOf(result).toEqualTypeOf>>() + + return result + }, + ], + }, + }, + + // @ts-expect-error - non exists + nonExist: {}, + }, + }) + }) }) diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index bf97a961e..cb9cd7a6c 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -14,13 +14,13 @@ describe('createORPCClient', () => { const client = createORPCClient(mockedLink) as any expect(await client.ping({ value: 'hello' })).toEqual('__mocked__') - expect(mockedLink.call).toBeCalledTimes(1) - expect(mockedLink.call).toBeCalledWith(['ping'], { value: 'hello' }, { context: {} }) + expect(mockedLink.call).toHaveBeenCalledTimes(1) + expect(mockedLink.call).toHaveBeenCalledWith(['ping'], { value: 'hello' }, { context: {} }) vi.clearAllMocks() expect(await client.nested.pong({ value: 'hello' })).toEqual('__mocked__') - expect(mockedLink.call).toBeCalledTimes(1) - expect(mockedLink.call).toBeCalledWith(['nested', 'pong'], { value: 'hello' }, { context: {} }) + expect(mockedLink.call).toHaveBeenCalledTimes(1) + expect(mockedLink.call).toHaveBeenCalledWith(['nested', 'pong'], { value: 'hello' }, { context: {} }) }) it('works with signal', async () => { @@ -29,36 +29,162 @@ describe('createORPCClient', () => { const client = createORPCClient(mockedLink) as any expect(await client.ping({ value: 'hello' }, { signal })).toEqual('__mocked__') - expect(mockedLink.call).toBeCalledTimes(1) - expect(mockedLink.call).toBeCalledWith(['ping'], { value: 'hello' }, { signal, context: {} }) + expect(mockedLink.call).toHaveBeenCalledTimes(1) + expect(mockedLink.call).toHaveBeenCalledWith(['ping'], { value: 'hello' }, { signal, context: {} }) }) it('works with context', async () => { const client = createORPCClient(mockedLink) as any expect(await client.ping({ value: 'hello' }, { context: { userId: '123' } })).toEqual('__mocked__') - expect(mockedLink.call).toBeCalledTimes(1) - expect(mockedLink.call).toBeCalledWith(['ping'], { value: 'hello' }, { context: { userId: '123' } }) + expect(mockedLink.call).toHaveBeenCalledTimes(1) + expect(mockedLink.call).toHaveBeenCalledWith(['ping'], { value: 'hello' }, { context: { userId: '123' } }) }) - it('not recursive on symbol', async () => { - const client = createORPCClient(mockedLink) as any - expect(client[Symbol('test')]).toBeUndefined() + it('works with base path', async () => { + const client = createORPCClient(mockedLink, { path: ['base'] }) as any + + expect(await client.ping({ value: 'hello' })).toEqual('__mocked__') + expect(mockedLink.call).toHaveBeenCalledTimes(1) + expect(mockedLink.call).toHaveBeenCalledWith(['base', 'ping'], { value: 'hello' }, { context: {} }) }) - it('prevent native await', async () => { - const client = createORPCClient(mockedLink) as any + it('works with interceptors', async () => { + const controller = new AbortController() + const signal = controller.signal + const order: string[] = [] + + const firstInterceptor = vi.fn(async ({ path, input, context, signal, next }) => { + order.push('first:before') + + expect(path).toEqual(['ping']) + expect(input).toEqual({ value: 'hello' }) + expect(context).toEqual({ requestId: 'request_1' }) + expect(signal).toBe(controller.signal) + + const result = await next({ + path, + input, + context: { ...context, userId: '123' }, + signal, + }) + + order.push('first:after') + + return result + }) + + const secondInterceptor = vi.fn(async ({ path, input, context, signal, next }) => { + order.push('second:before') + + expect(path).toEqual(['ping']) + expect(input).toEqual({ value: 'hello' }) + expect(context).toEqual({ requestId: 'request_1', userId: '123' }) + expect(signal).toBe(controller.signal) + + const result = await next({ + path, + input: { value: 'intercepted' }, + context: { ...context, traceId: 'trace_1' }, + signal, + }) + + order.push('second:after') + + return result + }) - const client2 = await client - expect(await client2.then({ value: 'client2' })).toEqual('__mocked__') - expect(mockedLink.call).toHaveBeenNthCalledWith(1, ['then'], { value: 'client2' }, { context: {} }) + const client = createORPCClient(mockedLink, { + interceptors: [firstInterceptor, secondInterceptor], + }) as any - const client3 = await client2.then - expect(await client3.something({ value: 'client3' })).toEqual('__mocked__') - expect(mockedLink.call).toHaveBeenNthCalledWith(2, ['then', 'something'], { value: 'client3' }, { context: {} }) + expect(await client.ping({ value: 'hello' }, { context: { requestId: 'request_1' }, signal })).toEqual('__mocked__') + expect(order).toEqual(['first:before', 'second:before', 'second:after', 'first:after']) - const client4 = await client3.something - expect(await client4.then({ value: 'client4' })).toEqual('__mocked__') - expect(mockedLink.call).toHaveBeenNthCalledWith(3, ['then', 'something', 'then'], { value: 'client4' }, { context: {} }) + expect(firstInterceptor).toHaveBeenCalledTimes(1) + expect(secondInterceptor).toHaveBeenCalledTimes(1) + + expect(mockedLink.call).toHaveBeenCalledTimes(1) + expect(mockedLink.call).toHaveBeenCalledWith( + ['ping'], + { value: 'intercepted' }, + { context: { requestId: 'request_1', userId: '123', traceId: 'trace_1' }, signal }, + ) + }) + + it('works with scoped', async () => { + const rootInterceptor = vi.fn(({ path, input, context, next }) => next({ + path, + input, + context: { ...context, rootPath: path.join('.') }, + })) + + const pingScopedInterceptor = vi.fn(({ path, input, context, next }) => { + expect(path).toEqual(['ping']) + expect(input).toEqual({ value: 'hello' }) + expect(context).toEqual({ requestId: 'request_1', rootPath: 'ping' }) + + return next({ + path, + input: { value: 'ping scoped' }, + context: { ...context, procedure: 'ping' }, + }) + }) + + const pongScopedInterceptor = vi.fn(({ path, input, context, next }) => { + expect(path).toEqual(['nested', 'pong']) + expect(input).toEqual({ value: 'world' }) + expect(context).toEqual({ requestId: 'request_2', rootPath: 'nested.pong' }) + + return next({ + path, + input: { value: 'pong scoped' }, + context: { ...context, procedure: 'nested.pong' }, + }) + }) + + const client = createORPCClient(mockedLink, { + interceptors: [rootInterceptor], + scoped: { + ping: { + interceptors: [pingScopedInterceptor], + }, + nested: { + pong: { + interceptors: [pongScopedInterceptor], + }, + }, + }, + } as any) as any + + expect(await client.ping({ value: 'hello' }, { context: { requestId: 'request_1' } })).toEqual('__mocked__') + expect(await client.nested.pong({ value: 'world' }, { context: { requestId: 'request_2' } })).toEqual('__mocked__') + + expect(rootInterceptor).toHaveBeenCalledTimes(2) + expect(pingScopedInterceptor).toHaveBeenCalledTimes(1) + expect(pongScopedInterceptor).toHaveBeenCalledTimes(1) + + expect(mockedLink.call).toHaveBeenNthCalledWith( + 1, + ['ping'], + { value: 'ping scoped' }, + { context: { requestId: 'request_1', rootPath: 'ping', procedure: 'ping' } }, + ) + + expect(mockedLink.call).toHaveBeenNthCalledWith( + 2, + ['nested', 'pong'], + { value: 'pong scoped' }, + { context: { requestId: 'request_2', rootPath: 'nested.pong', procedure: 'nested.pong' } }, + ) + }) + + it('not recursive on symbol and unwrap keys', async () => { + const client = createORPCClient(mockedLink) as any + expect(client[Symbol('test')]).toBeUndefined() + expect(client.then).toBeUndefined() + expect(await client).toBe(client) + expect(client.bind).toBe(client.bind) + expect(client.toString).toBe(client.toString) }) }) diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 863140aba..e2dde3221 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -1,43 +1,86 @@ -import type { Client, ClientLink, FriendlyClientOptions, InferClientContext, NestedClient } from './types' -import { preventNativeAwait } from '@orpc/shared' -import { resolveFriendlyClientOptions } from './utils' +import type { Interceptor, PromiseWithError } from '@orpc/shared' +import type { AnyNestedClient, Client, ClientContext, ClientLink, ClientOptions, InferClientContext, InferClientError } from './types' +import { getOrBind, intercept, toArray } from '@orpc/shared' +import { RECURSIVE_CLIENT_UNWRAP_KEYS } from './consts' +import { resolveClientRest } from './utils' + +export interface ORPCClientInterceptorOptions extends ClientOptions { + path: string[] + input: TInput +} + +export type ORPCClientInterceptor + = Interceptor, PromiseWithError> + +export interface ORPCClientScopedOptions { -export interface createORPCClientOptions { + /** + * Interceptors that wrap the entire client call lifecycle. + */ + interceptors?: ORPCClientInterceptor[] +} + +export type ORPCClientScoped + = T extends Client + ? ORPCClientScopedOptions + : { + [K in keyof T]?: T[K] extends AnyNestedClient ? ORPCClientScoped : never + } + +export interface ORPCClientOptions { /** * Use as base path for all procedure, useful when you only want to call a subset of the procedure. */ - path?: readonly string[] + path?: string[] + + /** + * Interceptors that wrap the entire client call lifecycle, applied to every procedure call. + */ + interceptors?: ORPCClientInterceptor, unknown, unknown, InferClientError>[] + + /** + * Per-procedure options following the shape of the router. + * Allows fine-grained configuration (e.g. additional interceptors) for individual procedures + * without affecting the rest of the router. + */ + scoped?: ORPCClientScoped } -/** - * Create a oRPC client-side client from a link. - * - * @see {@link https://orpc.dev/docs/client/client-side Client-side Client Docs} - */ -export function createORPCClient>( +export function createORPCClient( link: ClientLink>, - options: createORPCClientOptions = {}, + { path = [], ...options }: NoInfer> = {}, ): T { - const path = options.path ?? [] + const procedureClient: Client, unknown, unknown, InferClientError> = (...rest) => { + const [input, callOptions] = resolveClientRest(rest) + const interceptors = [ + ...toArray(options.interceptors), + ...toArray(options.scoped?.interceptors) as ORPCClientInterceptor, unknown, unknown, InferClientError>[], + ] - const procedureClient: Client, unknown, unknown, Error> = async ( - ...[input, options = {} as FriendlyClientOptions>] - ) => { - return await link.call(path, input, resolveFriendlyClientOptions(options)) + return intercept( + interceptors, + { ...callOptions, input, path }, + ({ path, input, ...callOptions }) => link.call(path, input, callOptions), + ) } const recursive = new Proxy(procedureClient, { get(target, key) { - if (typeof key !== 'string') { - return Reflect.get(target, key) + if (typeof key !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(key)) { + return getOrBind(target, key) } + const scoped = options.scoped === undefined + ? undefined + : (options.scoped as Record)[key] as ORPCClientOptions['scoped'] + return createORPCClient(link, { ...options, path: [...path, key], + scoped, }) }, }) - return preventNativeAwait(recursive) as any + return recursive as any } diff --git a/packages/client/src/consts.ts b/packages/client/src/consts.ts index b7fc45800..ff802749d 100644 --- a/packages/client/src/consts.ts +++ b/packages/client/src/consts.ts @@ -1,2 +1,33 @@ -export const ORPC_CLIENT_PACKAGE_NAME = '__ORPC_CLIENT_PACKAGE_NAME_PLACEHOLDER__' -export const ORPC_CLIENT_PACKAGE_VERSION = '__ORPC_CLIENT_PACKAGE_VERSION_PLACEHOLDER__' +/** + * Property names that should resolve to the underlying value instead of + * continuing recursive proxy traversal. + * + * These properties are commonly accessed automatically by JavaScript runtimes, + * language features, or third-party libraries. Returning another recursive + * proxy for them can cause unexpected behavior, compatibility issues, or + * infinite proxy chains. + */ +export const RECURSIVE_CLIENT_UNWRAP_KEYS = new Set([ + /** + * Prevents the client from being treated as a thenable when users + * accidentally write `await client`. + */ + 'then', + /** + * Commonly used by libraries to bind functions to a specific `this` + * context. + */ + 'bind', + /** + * Commonly accessed during primitive conversion, inspection, and logging. + */ + 'valueOf', + /** + * Commonly accessed during string conversion, inspection, and logging. + */ + 'toString', + /** + * Commonly accessed by serializers such as `JSON.stringify`. + */ + 'toJSON', +]) diff --git a/packages/client/src/dynamic-link.test-d.ts b/packages/client/src/dynamic-link.test-d.ts index d467fb912..ecc72a0d4 100644 --- a/packages/client/src/dynamic-link.test-d.ts +++ b/packages/client/src/dynamic-link.test-d.ts @@ -10,7 +10,7 @@ describe('dynamicLink', () => { }) }) - it('required return a another link', () => { + it('required return a valid link', () => { void new DynamicLink(() => ({} as ClientLink)) void new DynamicLink<{ batch?: boolean }>(() => ({} as ClientLink<{ batch?: boolean }>)) // @ts-expect-error - context is mismatch diff --git a/packages/client/src/dynamic-link.ts b/packages/client/src/dynamic-link.ts index 3fc8c70ec..3c803befd 100644 --- a/packages/client/src/dynamic-link.ts +++ b/packages/client/src/dynamic-link.ts @@ -11,13 +11,13 @@ export class DynamicLink implements Client constructor( private readonly linkResolver: ( options: ClientOptions, - path: readonly string[], + path: string[], input: unknown, ) => Promisable>, ) { } - async call(path: readonly string[], input: unknown, options: ClientOptions): Promise { + async call(path: string[], input: unknown, options: ClientOptions): Promise { const resolvedLink = await this.linkResolver(options, path, input) const output = await resolvedLink.call(path, input, options) diff --git a/packages/client/src/error-utils.test-d.ts b/packages/client/src/error-utils.test-d.ts new file mode 100644 index 000000000..1b0d7902a --- /dev/null +++ b/packages/client/src/error-utils.test-d.ts @@ -0,0 +1,43 @@ +import type { ORPCError } from './error' +import { isInferableError } from './error-utils' + +describe('isInferableError', () => { + it('normal', () => { + const error = { } as ORPCError<'BAD_REQUEST', { id: number }> | ORPCError<'CONFLICT', unknown> | Error + + if (isInferableError(error)) { + expectTypeOf().toEqualTypeOf | ORPCError<'CONFLICT', unknown>>() + + if (error.code === 'BAD_REQUEST') { + expectTypeOf().toEqualTypeOf() + } + } + else { + expectTypeOf().toEqualTypeOf() + } + }) + + it('with any types', () => { + const error: any = {} + + if (isInferableError(error)) { + expectTypeOf().toEqualTypeOf() + } + else { + // @ts-expect-error FIX: should be any + expectTypeOf().toEqualTypeOf() + } + }) + + it('with unknown type', () => { + const error: unknown = {} + + if (isInferableError(error)) { + // @ts-expect-error FIX: should be unknown or any + expectTypeOf().toEqualTypeOf() + } + else { + expectTypeOf().toEqualTypeOf() + } + }) +}) diff --git a/packages/client/src/error-utils.test.ts b/packages/client/src/error-utils.test.ts new file mode 100644 index 000000000..309430460 --- /dev/null +++ b/packages/client/src/error-utils.test.ts @@ -0,0 +1,237 @@ +import type { Writable } from '@orpc/shared' +import { ORPCError } from './error' +import { + cloneORPCError, + createORPCErrorFromJson, + isInferableError, + isORPCErrorJson, + toORPCError, +} from './error-utils' + +it('isInferableError', () => { + const inferableError = new ORPCError('BAD_REQUEST') + ;(inferableError.inferable as Writable) = true as any + expect(isInferableError(inferableError)).toBe(true) + const definedError = new ORPCError('BAD_REQUEST') + ;(definedError.defined as Writable) = true as any + ;(definedError.inferable as Writable) = true as any + expect(isInferableError(definedError)).toBe(true) + + expect(isInferableError(new ORPCError('BAD_REQUEST'))).toBe(false) + expect(isInferableError(new Error('Regular error'))).toBe(false) + expect(isInferableError({ code: 'ERROR', inferable: true })).toBe(false) + expect(isInferableError(null)).toBe(false) + expect(isInferableError(undefined)).toBe(false) +}) + +describe('toORPCError', () => { + it('returns same error if already ORPCError', () => { + const error = new ORPCError('BAD_REQUEST', { message: 'Bad request' }) + const result = toORPCError(error) + expect(result).toBe(error) + }) + + it('converts regular Error to ORPCError', () => { + const originalError = new Error('Something went wrong') + + const result = toORPCError(originalError) + + expect(result).toBeInstanceOf(ORPCError) + expect(result.code).toBe('INTERNAL_SERVER_ERROR') + expect(result.message).toBe('Internal Server Error') + expect(result.cause).toBe(originalError) + }) + + it('converts string to ORPCError', () => { + const result = toORPCError('Error string') + + expect(result).toBeInstanceOf(ORPCError) + expect(result.code).toBe('INTERNAL_SERVER_ERROR') + expect(result.message).toBe('Internal Server Error') + expect(result.cause).toBe('Error string') + }) +}) + +describe('isORPCErrorJson', () => { + const error = new ORPCError('BAD_REQUEST', { message: 'Bad request', cause: 'cause', data: 'data' }) + ;(error as any).inferable = true as any + + it('returns true for valid ORPC error JSON', () => { + expect(isORPCErrorJson(error.toJSON())).toBe(true) + }) + + it('returns true for valid ORPC error JSON without data', () => { + const json = error.toJSON() + + // @ts-expect-error this is expected + delete json.data + + expect(isORPCErrorJson(json)).toBe(true) + }) + + it('returns false for object missing defined field', () => { + const json = error.toJSON() + // @ts-expect-error this is expected + delete json.defined + + expect(isORPCErrorJson(json)).toBe(false) + }) + + it('returns false for object missing inferable field', () => { + const json = error.toJSON() + // @ts-expect-error this is expected + delete json.inferable + + expect(isORPCErrorJson(json)).toBe(false) + }) + + it('returns false for object missing code field', () => { + const json = error.toJSON() + // @ts-expect-error this is expected + delete json.code + + expect(isORPCErrorJson(json)).toBe(false) + }) + + it('returns false for object missing message field', () => { + const json = error.toJSON() + // @ts-expect-error this is expected + delete json.message + + expect(isORPCErrorJson(json)).toBe(false) + }) + + it('returns false for object with invalid defined type', () => { + const json = error.toJSON() + // @ts-expect-error this is expected + json.defined = 'true' + + expect(isORPCErrorJson(json)).toBe(false) + }) + + it('returns false for object with invalid inferable type', () => { + const json = error.toJSON() + // @ts-expect-error this is expected + json.inferable = 'true' + + expect(isORPCErrorJson(json)).toBe(false) + }) + + it('returns false for object with invalid message type', () => { + const json = error.toJSON() + // @ts-expect-error this is expected + json.message = 400 + + expect(isORPCErrorJson(json)).toBe(false) + }) + + it('returns false for object with extra keys', () => { + const json = error.toJSON() + // @ts-expect-error this is expected + json.extraKey = 'extra' + + expect(isORPCErrorJson(json)).toBe(false) + }) + + it('returns false for non-object values', () => { + expect(isORPCErrorJson(null)).toBe(false) + expect(isORPCErrorJson(undefined)).toBe(false) + expect(isORPCErrorJson('string')).toBe(false) + expect(isORPCErrorJson(123)).toBe(false) + expect(isORPCErrorJson(true)).toBe(false) + expect(isORPCErrorJson([])).toBe(false) + }) +}) + +describe('createORPCErrorFromJson', () => { + const error = new ORPCError('BAD_REQUEST', { message: 'Bad request', cause: 'cause', data: 'data' }) + ;(error as any).defined = true as any + + it('creates ORPCError from valid JSON', () => { + const json = error.toJSON() + + const createdError = createORPCErrorFromJson(json) + + expect(createdError).toBeInstanceOf(ORPCError) + expect(createdError.code).toBe(error.code) + expect(createdError.message).toBe(error.message) + expect(createdError.data).toEqual(error.data) + expect(createdError.defined).toBe(error.defined) + expect(createdError.inferable).toBe(error.inferable) + }) + + it('creates ORPCError from JSON without data', () => { + const json = error.toJSON() + // @ts-expect-error this is expected + delete json.data + + const createdError = createORPCErrorFromJson(json) + + expect(createdError).toBeInstanceOf(ORPCError) + expect(createdError.data).toBeUndefined() + }) + + it('accepts additional error options', () => { + const cause = new Error('Original cause') + const createdError = createORPCErrorFromJson(error.toJSON(), { cause }) + + expect(createdError).toBeInstanceOf(ORPCError) + expect(createdError.cause).toBe(cause) + }) +}) + +describe('cloneORPCError', () => { + it('creates a clone of ORPCError', () => { + const original = new ORPCError('BAD_REQUEST', { + message: 'Bad request', + data: { field: 'value' }, + }) + + const cloned = cloneORPCError(original) + + expect(cloned).toBeInstanceOf(ORPCError) + expect(cloned).not.toBe(original) + expect(cloned.code).toBe(original.code) + expect(cloned.message).toBe(original.message) + expect(cloned.data).toEqual(original.data) + expect(cloned.defined).toBe(false) + expect(cloned.inferable).toBe(false) + }) + + it('preserves cause and stack trace', () => { + const cause = new Error('Original cause') + const original = new ORPCError('INTERNAL_SERVER_ERROR', { cause }) + + const cloned = cloneORPCError(original) + + expect(cloned).toBeInstanceOf(ORPCError) + expect(cloned.cause).toBe(cause) + expect(cloned.stack).toBe(original.stack) + }) + + it('preserves defined and inferable flags', () => { + const original = new ORPCError('CUSTOM_ERROR') + ;(original.defined as any) = true + ;(original.inferable as any) = true + + const cloned = cloneORPCError(original) + + expect(cloned).toBeInstanceOf(ORPCError) + expect(cloned.defined).toBe(true) + expect(cloned.inferable).toBe(true) + }) + + it('creates independent copy', () => { + const original = new ORPCError('BAD_REQUEST', { + data: 1, + }) + + const cloned = cloneORPCError(original) + + // Modifying cloned data doesn't affect original + cloned.data = 2 + + expect(original.data).toBe(1) + expect(cloned.data).toBe(2) + }) +}) diff --git a/packages/client/src/error-utils.ts b/packages/client/src/error-utils.ts new file mode 100644 index 000000000..011a8b168 --- /dev/null +++ b/packages/client/src/error-utils.ts @@ -0,0 +1,64 @@ +import type { Writable } from '@orpc/shared' +import type { AnyORPCError, ORPCErrorCode, ORPCErrorJSON } from './error' +import { isPlainObject } from '@orpc/shared' +import { ORPCError } from './error' + +export function isInferableError(error: T): error is Extract { + return error instanceof ORPCError && error.inferable +} + +export function toORPCError(error: T): Extract | ORPCError<'INTERNAL_SERVER_ERROR', undefined> { + return error instanceof ORPCError + ? error + : new ORPCError('INTERNAL_SERVER_ERROR', { cause: error }) +} + +export function isORPCErrorJson(json: unknown): json is ORPCErrorJSON { + if (!isPlainObject(json)) { + return false + } + + const validKeys = ['defined', 'inferable', 'code', 'message', 'data'] + if (Object.keys(json).some(k => !validKeys.includes(k))) { + return false + } + + return 'defined' in json + && typeof json.defined === 'boolean' + && 'inferable' in json + && typeof json.inferable === 'boolean' + && 'code' in json + && typeof json.code === 'string' + && 'message' in json + && typeof json.message === 'string' +} + +export function createORPCErrorFromJson( + json: ORPCErrorJSON, + options: ErrorOptions = {}, +): ORPCError { + const error = new ORPCError(json.code, { + ...json, + ...options, + }) + + ;(error.defined as Writable) = json.defined + ;(error.inferable as Writable) = json.inferable + + return error +} + +export function cloneORPCError(error: ORPCError): ORPCError { + const cloned = new ORPCError(error.code, { + ...error, + message: error.message, + data: error.data, + cause: error.cause, + }) + + cloned.stack = error.stack + ;(cloned.defined as Writable) = error.defined + ;(cloned.inferable as Writable) = error.inferable + + return cloned +} diff --git a/packages/client/src/error.test-d.ts b/packages/client/src/error.test-d.ts index 2c7b52c47..5886cd7b8 100644 --- a/packages/client/src/error.test-d.ts +++ b/packages/client/src/error.test-d.ts @@ -1,11 +1,24 @@ -import type { ORPCError } from './error' -import { isDefinedError } from './error' +import { ORPCError } from './error' -it('isDefinedError', () => { - const orpcError = {} as ORPCError<'CODE', { value: string }> | ORPCError<'BASE', { value: number }> - const error = {} as Error | typeof orpcError +describe('ORPCError', () => { + it('constructor', () => { + const _error11: ORPCError<'CODE', undefined | 'optional'> = new ORPCError('CODE') + const _error12: ORPCError<'CODE', undefined | 'optional'> = new ORPCError('CODE', { data: 'optional' }) + // @ts-expect-error - data is invalid + const _error13: ORPCError<'CODE', undefined | 'optional'> = new ORPCError('CODE', { data: 'invalid' }) - if (isDefinedError(error)) { - expectTypeOf(error).toEqualTypeOf(orpcError) - } + // @ts-expect-error - data is required + const _error21: ORPCError<'CODE', 'required'> = new ORPCError('CODE') + const _error22: ORPCError<'CODE', 'required'> = new ORPCError('CODE', { data: 'required' }) + // @ts-expect-error - data is invalid + const _error23: ORPCError<'CODE', 'required'> = new ORPCError('CODE', { data: 'invalid' }) + }) + + it('not allow write .defined and .inferable properties', () => { + const error = new ORPCError('CODE') + // @ts-expect-error - not allow write + error.defined = true as any + // @ts-expect-error - not allow write + error.inferable = true as any + }) }) diff --git a/packages/client/src/error.test.ts b/packages/client/src/error.test.ts index eef7f19fe..7fe77f2d1 100644 --- a/packages/client/src/error.test.ts +++ b/packages/client/src/error.test.ts @@ -1,32 +1,51 @@ import { NullProtoObj } from '@orpc/shared' -import { createORPCErrorFromJson, fallbackORPCErrorMessage, fallbackORPCErrorStatus, isDefinedError, isORPCErrorJson, isORPCErrorStatus, ORPCError, toORPCError } from './error' - -it('fallbackORPCErrorStatus', () => { - expect(fallbackORPCErrorStatus('BAD_GATEWAY', 500)).toBe(500) - expect(fallbackORPCErrorStatus('BAD_GATEWAY', undefined)).toBe(502) - expect(fallbackORPCErrorStatus('ANYTHING', 405)).toBe(405) - expect(fallbackORPCErrorStatus('ANYTHING', undefined)).toBe(500) -}) - -it('fallbackORPCErrorMessage', () => { - expect(fallbackORPCErrorMessage('BAD_GATEWAY', 'message')).toBe('message') - expect(fallbackORPCErrorMessage('BAD_GATEWAY', undefined)).toBe('Bad Gateway') - expect(fallbackORPCErrorMessage('ANYTHING', 'message')).toBe('message') - expect(fallbackORPCErrorMessage('ANYTHING', undefined)).toBe('ANYTHING') -}) +import { ORPCError } from './error' describe('oRPCError', () => { it('works', () => { - const error = new ORPCError('BAD_GATEWAY', { defined: true, status: 500, message: 'message', data: 'data', cause: 'cause' }) - expect(error.defined).toBe(true) + const error = new ORPCError('BAD_GATEWAY', { + message: 'message', + data: 'data', + cause: 'cause', + }) + expect(error.defined).toBe(false) + expect(error.inferable).toBe(false) expect(error.code).toBe('BAD_GATEWAY') - expect(error.status).toBe(500) expect(error.message).toBe('message') expect(error.data).toBe('data') expect(error.cause).toBe('cause') expect(Object.getPrototypeOf(error).constructor.name).toBe('ORPCError') }) + it('can fallback message', () => { + const error = new ORPCError('BAD_GATEWAY') + expect(error.message).toBe('Bad Gateway') + }) + + it('can force write .defined or .inferable', () => { + const error = new ORPCError('BAD_GATEWAY') + + expect(error.defined).toBe(false) + expect(error.inferable).toBe(false) + + ;(error.defined as any) = true + ;(error.inferable as any) = true + + expect(error.defined).toBe(true) + expect(error.inferable).toBe(true) + }) + + it('.toJSON', () => { + const error = new ORPCError('BAD_GATEWAY', { message: 'message', data: 'data', cause: 'cause' }) + expect(error.toJSON()).toEqual({ + defined: false, + inferable: false, + code: 'BAD_GATEWAY', + message: 'message', + data: 'data', + }) + }) + it('instanceof should behave as normal', () => { class ExtendedORPCError extends ORPCError {} class NotRelated {} @@ -61,100 +80,4 @@ describe('oRPCError', () => { expect(notRelated instanceof NotRelated).toBe(true) expect(nullProtoObj instanceof NotRelated).toBe(false) }) - - it('default defined=false', () => { - const error = new ORPCError('BAD_GATEWAY') - expect(error.defined).toBe(false) - }) - - it('fallback status', () => { - const error = new ORPCError('BAD_GATEWAY') - expect(error.status).toBe(502) - }) - - it('fallback message', () => { - const error = new ORPCError('BAD_GATEWAY') - expect(error.message).toBe('Bad Gateway') - }) - - it('oRPCError throw when invalid status', () => { - expect(() => new ORPCError('BAD_GATEWAY', { status: 200 })).toThrowError() - expect(() => new ORPCError('BAD_GATEWAY', { status: 399 })).toThrowError() - - expect(() => new ORPCError('BAD_GATEWAY', { status: 400 })).not.toThrowError() - expect(() => new ORPCError('BAD_GATEWAY', { status: 199 })).not.toThrowError() - }) - - it('toJSON', () => { - const error = new ORPCError('BAD_GATEWAY', { status: 500, message: 'message', data: 'data', cause: 'cause' }) - expect(error.toJSON()).toEqual({ - defined: false, - code: 'BAD_GATEWAY', - status: 500, - message: 'message', - data: 'data', - }) - }) -}) - -it('isDefinedError', () => { - expect(isDefinedError(new ORPCError('BAD_GATEWAY'))).toBe(false) - expect(isDefinedError(new ORPCError('BAD_GATEWAY', { defined: true }))).toBe(true) - expect(isDefinedError({ defined: true, code: 'BAD_GATEWAY' })).toBe(false) -}) - -it('toORPCError', () => { - const orpcError = new ORPCError('BAD_GATEWAY') - expect(toORPCError(orpcError)).toBe(orpcError) - - const error = new Error('error') - expect(toORPCError(error)).toSatisfy((value: any) => { - expect(value).toBeInstanceOf(ORPCError) - expect(value.code).toEqual('INTERNAL_SERVER_ERROR') - expect(value.status).toBe(500) - expect(value.defined).toBe(false) - expect(value.message).toBe('Internal server error') - expect(value.data).toBe(undefined) - expect(value.cause).toBe(error) - - return true - }) -}) - -it('isORPCErrorStatus', () => { - expect(isORPCErrorStatus(200)).toBe(false) - expect(isORPCErrorStatus(399)).toBe(false) - - expect(isORPCErrorStatus(400)).toBe(true) - expect(isORPCErrorStatus(499)).toBe(true) - expect(isORPCErrorStatus(199)).toBe(true) -}) - -it('createORPCErrorFromJson', () => { - const error = createORPCErrorFromJson({ - defined: true, - code: 'BAD_GATEWAY', - status: 500, - message: 'message', - data: 'data', - }, { - cause: 'cause', - }) - expect(error.defined).toBe(true) - expect(error.code).toBe('BAD_GATEWAY') - expect(error.status).toBe(500) - expect(error.message).toBe('message') - expect(error.data).toBe('data') - expect(error.cause).toBe('cause') -}) - -it('isValidJSON', () => { - const error = new ORPCError('BAD_GATEWAY', { status: 500, message: 'message', data: 'data', cause: 'cause' }) - expect(isORPCErrorJson(error.toJSON())).toBe(true) - expect(isORPCErrorJson(`error`)).toBe(false) - expect(isORPCErrorJson({})).toBe(false) - expect(isORPCErrorJson({ defined: true })).toBe(false) - expect(isORPCErrorJson({ defined: true, code: 'BAD_GATEWAY', status: 500, message: 'message', data: 'data' })).toBe(true) - expect(isORPCErrorJson({ defined: true, code: 'BAD_GATEWAY', status: 200, message: 'message', data: 'data' })).toBe(false) - expect(isORPCErrorJson({ defined: true, code: 'BAD_GATEWAY', status: 500, message: 'message', data: 'data', extra: true })).toBe(false) }) diff --git a/packages/client/src/error.ts b/packages/client/src/error.ts index f4e1db9ad..4b29070f4 100644 --- a/packages/client/src/error.ts +++ b/packages/client/src/error.ts @@ -1,112 +1,44 @@ -import type { MaybeOptionalOptions } from '@orpc/shared' -import { getConstructor, isObject, resolveMaybeOptionalOptions } from '@orpc/shared' -import { ORPC_CLIENT_PACKAGE_NAME, ORPC_CLIENT_PACKAGE_VERSION } from './consts' - -export const COMMON_ORPC_ERROR_DEFS = { - BAD_REQUEST: { - status: 400, - message: 'Bad Request', - }, - UNAUTHORIZED: { - status: 401, - message: 'Unauthorized', - }, - FORBIDDEN: { - status: 403, - message: 'Forbidden', - }, - NOT_FOUND: { - status: 404, - message: 'Not Found', - }, - METHOD_NOT_SUPPORTED: { - status: 405, - message: 'Method Not Supported', - }, - NOT_ACCEPTABLE: { - status: 406, - message: 'Not Acceptable', - }, - TIMEOUT: { - status: 408, - message: 'Request Timeout', - }, - CONFLICT: { - status: 409, - message: 'Conflict', - }, - PRECONDITION_FAILED: { - status: 412, - message: 'Precondition Failed', - }, - PAYLOAD_TOO_LARGE: { - status: 413, - message: 'Payload Too Large', - }, - UNSUPPORTED_MEDIA_TYPE: { - status: 415, - message: 'Unsupported Media Type', - }, - UNPROCESSABLE_CONTENT: { - status: 422, - message: 'Unprocessable Content', - }, - TOO_MANY_REQUESTS: { - status: 429, - message: 'Too Many Requests', - }, - CLIENT_CLOSED_REQUEST: { - status: 499, - message: 'Client Closed Request', - }, - - INTERNAL_SERVER_ERROR: { - status: 500, - message: 'Internal Server Error', - }, - NOT_IMPLEMENTED: { - status: 501, - message: 'Not Implemented', - }, - BAD_GATEWAY: { - status: 502, - message: 'Bad Gateway', - }, - SERVICE_UNAVAILABLE: { - status: 503, - message: 'Service Unavailable', - }, - GATEWAY_TIMEOUT: { - status: 504, - message: 'Gateway Timeout', - }, -} as const - -export type CommonORPCErrorCode = keyof typeof COMMON_ORPC_ERROR_DEFS - -export type ORPCErrorCode = CommonORPCErrorCode | (string & {}) - -export function fallbackORPCErrorStatus(code: ORPCErrorCode, status: number | undefined): number { - return status ?? (COMMON_ORPC_ERROR_DEFS as any)[code]?.status ?? 500 +import type { MaybeOptionalOptions, Registry } from '@orpc/shared' +import { getConstructor, resolveMaybeOptionalOptions } from '@orpc/shared' + +export const COMMON_ERROR_STATUS_MAP = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + PAYMENT_REQUIRED: 402, + FORBIDDEN: 403, + NOT_FOUND: 404, + METHOD_NOT_SUPPORTED: 405, + NOT_ACCEPTABLE: 406, + TIMEOUT: 408, + CONFLICT: 409, + GONE: 410, + PRECONDITION_FAILED: 412, + PAYLOAD_TOO_LARGE: 413, + UNSUPPORTED_MEDIA_TYPE: 415, + UNPROCESSABLE_CONTENT: 422, + PRECONDITION_REQUIRED: 428, + TOO_MANY_REQUESTS: 429, + CLIENT_CLOSED_REQUEST: 499, + INTERNAL_SERVER_ERROR: 500, + NOT_IMPLEMENTED: 501, + BAD_GATEWAY: 502, + SERVICE_UNAVAILABLE: 503, + GATEWAY_TIMEOUT: 504, } -export function fallbackORPCErrorMessage(code: ORPCErrorCode, message: string | undefined): string { - return message || (COMMON_ORPC_ERROR_DEFS as any)[code]?.message || code -} +export type ORPCErrorCode + = Registry extends { ORPCErrorCode: infer T extends string } + ? T + : (keyof typeof COMMON_ERROR_STATUS_MAP) | (string & {}) export type ORPCErrorOptions = & ErrorOptions - & { defined?: boolean, status?: number, message?: string } + & { message?: string } & (undefined extends TData ? { data?: TData } : { data: TData }) -let globalORPCErrorConstructors: WeakSet +let ORPCErrorConstructors: WeakSet export class ORPCError extends Error { - readonly defined: boolean - readonly code: TCode - readonly status: number - readonly data: TData - /** * Placed inside a static block (rather than at module level) to ensure this * registration is treated as part of the class definition by bundlers. @@ -127,37 +59,49 @@ export class ORPCError extends Error { /** * Store all ORPCError constructors * for workaround of instanceof check in case multiple dependency graphs exist - * - * @info `Symbol.for` is global symbol registry and shared across different dependency graphs */ - const GLOBAL_ORPC_ERROR_CONSTRUCTORS_SYMBOL = Symbol.for(`__${ORPC_CLIENT_PACKAGE_NAME}@${ORPC_CLIENT_PACKAGE_VERSION}/error/ORPC_ERROR_CONSTRUCTORS__`) - void ((globalThis as any)[GLOBAL_ORPC_ERROR_CONSTRUCTORS_SYMBOL] ??= new WeakSet()) - globalORPCErrorConstructors = (globalThis as any)[GLOBAL_ORPC_ERROR_CONSTRUCTORS_SYMBOL] - globalORPCErrorConstructors.add(ORPCError) + const ORPC_ERROR_CONSTRUCTORS_SYMBOL = Symbol.for('ORPC_ERROR_CONSTRUCTORS') + void ((globalThis as any)[ORPC_ERROR_CONSTRUCTORS_SYMBOL] ??= new WeakSet()) + ORPCErrorConstructors = (globalThis as any)[ORPC_ERROR_CONSTRUCTORS_SYMBOL] + ORPCErrorConstructors.add(ORPCError) } - constructor(code: TCode, ...rest: MaybeOptionalOptions>) { - const options = resolveMaybeOptionalOptions(rest) + /** + * @info + * The `__branch` property is used for type branding, helping TypeScript distinguish + * an `ORPCError` instance from plain objects with a similar structure. + */ + override readonly name = 'ORPCError' as 'ORPCError' & { __branch: 'ORPCError' } - if (options.status !== undefined && !isORPCErrorStatus(options.status)) { - throw new Error('[ORPCError] Invalid error status code.') - } + /** + * Indicates whether the error matches a definition in the procedure's `.errors` map. + */ + readonly defined: boolean = false - const message = fallbackORPCErrorMessage(code, options.message) + /** + * Indicates whether the error's type is inferable at the TypeScript level. + * This is typically true when the error is explicitly defined or returned within a handler. + */ + readonly inferable: boolean = false + + code: TCode + data: TData + + constructor(code: TCode, ...rest: MaybeOptionalOptions>) { + const options = resolveMaybeOptionalOptions(rest) + const message = options.message ?? code.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ') super(message, options) this.code = code - this.status = fallbackORPCErrorStatus(code, options.status) - this.defined = options.defined ?? false this.data = options.data as TData // data only optional when TData is undefinable so can safely cast here } toJSON(): ORPCErrorJSON { return { defined: this.defined, + inferable: this.inferable, code: this.code, - status: this.status, message: this.message, data: this.data, } @@ -176,12 +120,14 @@ export class ORPCError extends Error { * @todo Remove this and related code if Next.js resolves the multiple dependency graph issue. */ static override[Symbol.hasInstance](instance: unknown): boolean { - // not applicable to extended classes - if (globalORPCErrorConstructors.has(this)) { - const constructor = getConstructor(instance) - if (constructor && globalORPCErrorConstructors.has(constructor)) { - return true - } + if (!ORPCErrorConstructors.has(this)) { + // not applicable to extended classes + return super[Symbol.hasInstance](instance) + } + + const constructor = getConstructor(instance) + if (constructor && ORPCErrorConstructors.has(constructor)) { + return true } // fallback to default instanceof check @@ -189,52 +135,16 @@ export class ORPCError extends Error { } } -export type ORPCErrorJSON = Pick, 'defined' | 'code' | 'status' | 'message' | 'data'> - -export function isDefinedError(error: T): error is Extract> { - return error instanceof ORPCError && error.defined -} - -export function toORPCError(error: unknown): ORPCError { - return error instanceof ORPCError - ? error - : new ORPCError('INTERNAL_SERVER_ERROR', { - message: 'Internal server error', - cause: error, - }) -} - -export function isORPCErrorStatus(status: number): boolean { - return status < 200 || status >= 400 -} - -export function isORPCErrorJson(json: unknown): json is ORPCErrorJSON { - if (!isObject(json)) { - return false - } - - const validKeys = ['defined', 'code', 'status', 'message', 'data'] - if (Object.keys(json).some(k => !validKeys.includes(k))) { - return false - } - - return 'defined' in json - && typeof json.defined === 'boolean' - && 'code' in json - && typeof json.code === 'string' - && 'status' in json - && typeof json.status === 'number' - && isORPCErrorStatus(json.status) - && 'message' in json - && typeof json.message === 'string' +export interface ORPCErrorJSON extends Pick, 'code' | 'message' | 'data'> { + /** + * remove readonly + */ + defined: boolean + /** + * remove readonly + */ + inferable: boolean } -export function createORPCErrorFromJson( - json: ORPCErrorJSON, - options: ErrorOptions = {}, -): ORPCError { - return new ORPCError(json.code, { - ...options, - ...json, - }) -} +export type AnyORPCError = ORPCError +export type AnyORPCErrorJSON = ORPCErrorJSON diff --git a/packages/client/src/event-iterator.test.ts b/packages/client/src/event-iterator.test.ts index 2fdff9a21..0f1eaf820 100644 --- a/packages/client/src/event-iterator.test.ts +++ b/packages/client/src/event-iterator.test.ts @@ -1,222 +1,115 @@ -import { getEventMeta, withEventMeta } from '@orpc/standard-server' -import { mapEventIterator } from './event-iterator' +import { getEventMeta, withEventMeta } from '@standardserver/core' +import { wrapEventIteratorPreservingMeta } from './event-iterator' -describe('mapEventIterator', () => { - it('on success', async () => { - let finished = false +describe('wrapEventIteratorPreservingMeta', () => { + it('preserves metadata when mapping yielded and returned values', async () => { + const event = withEventMeta({ order: 2 }, { id: 'id-2' }) + const returned = withEventMeta({ order: 3 }, { retry: 4000 }) const iterator = (async function* () { - try { - yield 1 - yield { order: 2 } - yield withEventMeta({ order: 3 }, { id: 'id-3' }) - return withEventMeta({ order: 4 }, { retry: 4000 }) - } - finally { - finished = true - } + yield 1 + yield event + return returned })() - const map = vi.fn(async v => ({ mapped: v })) - - const mapped = mapEventIterator(iterator, { - error: map, - value: map, - }) - - await expect(mapped.next()).resolves.toSatisfy(({ done, value }) => { - expect(done).toBe(false) - expect(value).toEqual({ mapped: 1 }) - expect(getEventMeta(value)).toEqual(undefined) - - return true - }) - - expect(map).toHaveBeenCalledTimes(1) - expect(map).toHaveBeenLastCalledWith(1, false) - - await expect(mapped.next()).resolves.toSatisfy(({ done, value }) => { - expect(done).toBe(false) - expect(value).toEqual({ mapped: { order: 2 } }) - expect(getEventMeta(value)).toEqual(undefined) - - return true - }) - - expect(map).toHaveBeenCalledTimes(2) - expect(map).toHaveBeenLastCalledWith({ order: 2 }, false) - - await expect(mapped.next()).resolves.toSatisfy(({ done, value }) => { - expect(done).toBe(false) - expect(value).toEqual({ mapped: { order: 3 } }) - expect(getEventMeta(value)).toEqual({ id: 'id-3' }) - - return true - }) - - expect(map).toHaveBeenCalledTimes(3) - expect(map).toHaveBeenLastCalledWith({ order: 3 }, false) - - await expect(mapped.next()).resolves.toSatisfy(({ done, value }) => { - expect(done).toBe(true) - expect(value).toEqual({ mapped: { order: 4 } }) - expect(getEventMeta(value)).toEqual({ retry: 4000 }) - - return true - }) - - expect(map).toHaveBeenCalledTimes(4) - expect(map).toHaveBeenLastCalledWith({ order: 4 }, true) - - expect(finished).toBe(true) - }) - - it('on error', async () => { - let finished = false - const error = withEventMeta(new Error('TEST'), { id: 'error-1' }) - - const iterator = (async function* () { - try { - throw error - } - finally { - finished = true + const mapResult = vi.fn(async (result) => { + return { + ...result, + value: { mapped: result.value }, } - })() - - const map = vi.fn(async v => ({ mapped: v })) + }) - const mapped = mapEventIterator(iterator, { - error: map, - value: map, + const mapped = wrapEventIteratorPreservingMeta(iterator, { + mapResult, }) - await expect(mapped.next()).rejects.toSatisfy((e) => { - expect(e).toEqual({ mapped: error }) - expect(getEventMeta(e)).toEqual({ id: 'error-1' }) + const first = await mapped.next() + expect(first).toEqual({ done: false, value: { mapped: 1 } }) + expect(getEventMeta(first.value)).toEqual(undefined) - return true - }) + const second = await mapped.next() + expect(second).toEqual({ done: false, value: { mapped: { order: 2 } } }) + expect(getEventMeta(second.value)).toEqual({ id: 'id-2' }) - expect(map).toHaveBeenCalledTimes(1) - expect(map).toHaveBeenLastCalledWith(error) + const third = await mapped.next() + expect(third).toEqual({ done: true, value: { mapped: { order: 3 } } }) + expect(getEventMeta(third.value)).toEqual({ retry: 4000 }) - expect(finished).toBe(true) + expect(mapResult).toHaveBeenNthCalledWith(1, { done: false, value: 1 }) + expect(mapResult).toHaveBeenNthCalledWith(2, { done: false, value: event }) + expect(mapResult).toHaveBeenNthCalledWith(3, { done: true, value: returned }) }) - it('cancel original when .return is called', async () => { - let finished = false + it('returns original results unchanged when the mapper keeps the same value', async () => { + const event = withEventMeta({ order: 1 }, { id: 'id-1' }) + const returned = withEventMeta({ order: 2 }, { retry: 2000 }) const iterator = (async function* () { - try { - yield 1 - yield 2 - } - finally { - finished = true - } + yield event + return returned })() - const map = vi.fn(async v => ({ mapped: v })) + const mapResult = vi.fn(async result => result) - const mapped = mapEventIterator(iterator, { - error: map, - value: map, + const mapped = wrapEventIteratorPreservingMeta(iterator, { + mapResult, }) - await mapped.next() - await mapped.return({} as any) + const first = await mapped.next() + expect(first).toEqual({ done: false, value: event }) + expect(first.value).toBe(event) + expect(getEventMeta(first.value)).toEqual({ id: 'id-1' }) - expect(map).toHaveBeenCalledTimes(1) - expect(finished).toBe(true) + const second = await mapped.next() + expect(second).toEqual({ done: true, value: returned }) + expect(second.value).toBe(returned) + expect(getEventMeta(second.value)).toEqual({ retry: 2000 }) }) - it('cancel original when .throw is called', async () => { - let finished = false + it('preserves metadata when mapping errors', async () => { + const error = withEventMeta(new Error('TEST'), { id: 'error-1' }) + const onError = vi.fn() + const mapError = vi.fn(async cause => ({ mapped: cause })) const iterator = (async function* () { - try { - yield 1 - yield 2 - } - finally { - finished = true - } + throw error })() - const map = vi.fn(async v => ({ mapped: v })) - - const mapped = mapEventIterator(iterator, { - error: map, - value: map, + const mapped = wrapEventIteratorPreservingMeta(iterator, { + onError, + mapError, }) - await mapped.next() - await expect(mapped.throw(new Error('TEST'))).rejects.toThrow() - expect(finished).toBe(true) - }) - - it('cancel original when error is thrown in value map', async () => { - let finished = false + await expect(mapped.next()).rejects.toSatisfy((cause) => { + expect(cause).toEqual({ mapped: error }) + expect(getEventMeta(cause)).toEqual({ id: 'error-1' }) - const iterator = (async function* () { - try { - yield 1 - yield 2 - } - finally { - finished = true - } - })() - - const map = vi.fn(async (v) => { - if (v === 2) { - throw new Error('TEST') - } - return { mapped: v } - }) - - const mapped = mapEventIterator(iterator, { - value: map, - error: async error => error, + return true }) - await expect(mapped.next()).resolves.toEqual({ done: false, value: { mapped: 1 } }) - await expect(mapped.next()).rejects.toThrow('TEST') - - expect(finished).toBe(true) + expect(mapError).toHaveBeenCalledTimes(1) + expect(mapError).toHaveBeenCalledWith(error) + expect(onError).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledWith(error) }) - it('cancel original + throw on cleanup', async () => { - const error = new Error('TEST') - - const iterator = (async function* () { - try { - yield 1 - yield 2 - } - finally { - // eslint-disable-next-line no-unsafe-finally - throw error - } - })() - - const map = vi.fn(async v => ({ mapped: v })) + it('does not reattach metadata when mapped errors stay the same or become non-objects', async () => { + const error = withEventMeta(new Error('TEST'), { id: 'error-1' }) - const mapped = mapEventIterator(iterator, { - error: map, - value: map, + const sameError = wrapEventIteratorPreservingMeta((async function* () { + throw error + })(), { + mapError: async cause => cause, }) - await expect(mapped.next()).resolves.toEqual({ done: false, value: { mapped: 1 } }) - await expect(mapped.return()).rejects.toSatisfy((e) => { - expect(e).toEqual({ mapped: error }) + await expect(sameError.next()).rejects.toBe(error) - return true + const primitiveError = wrapEventIteratorPreservingMeta((async function* () { + throw error + })(), { + mapError: async () => 'mapped-error', }) - expect(map).toHaveBeenCalledTimes(2) - expect(map).toHaveBeenNthCalledWith(1, 1, false) - expect(map).toHaveBeenNthCalledWith(2, error) + await expect(primitiveError.next()).rejects.toBe('mapped-error') }) }) diff --git a/packages/client/src/event-iterator.ts b/packages/client/src/event-iterator.ts index 5bd6fd3e8..b066e1859 100644 --- a/packages/client/src/event-iterator.ts +++ b/packages/client/src/event-iterator.ts @@ -1,52 +1,36 @@ -import { AsyncIteratorClass, isTypescriptObject } from '@orpc/shared' -import { getEventMeta, withEventMeta } from '@orpc/standard-server' +import type { AsyncIteratorClass, WrapAsyncIteratorOptions } from '@orpc/shared' +import { isTypescriptObject, wrapAsyncIterator } from '@orpc/shared' +import { getEventMeta, withEventMeta } from '@standardserver/core' -export function mapEventIterator( - iterator: AsyncIterator, - maps: { - value: (value: NoInfer, done: boolean | undefined) => Promise - error: (error: unknown) => Promise - }, -): AsyncIteratorClass { - const mapError = async (error: unknown) => { - let mappedError = await maps.error(error) +export function wrapEventIteratorPreservingMeta( + iterator: AsyncIterator, + { mapResult, mapError, ...rest }: WrapAsyncIteratorOptions, +): AsyncIteratorClass { + return wrapAsyncIterator(iterator, { + ...rest, + mapResult: mapResult && (async (result) => { + const mapped = await mapResult(result) - if (mappedError !== error) { - const meta = getEventMeta(error) - if (meta && isTypescriptObject(mappedError)) { - mappedError = withEventMeta(mappedError, meta) + if (mapped.value !== result.value) { + const meta = getEventMeta(result.value) + if (meta && isTypescriptObject(mapped.value)) { + return { done: mapped.done, value: withEventMeta(mapped.value, meta) } as any + } } - } - return mappedError - } + return mapped + }), + mapError: mapError && (async (error) => { + const mapped = await mapError(error) - return new AsyncIteratorClass(async () => { - const { done, value } = await (async () => { - try { - return await iterator.next() + if (mapped !== error) { + const meta = getEventMeta(error) + if (meta && isTypescriptObject(mapped)) { + return withEventMeta(mapped, meta) + } } - catch (error) { - throw await mapError(error) - } - })() - - let mappedValue = await maps.value(value, done) - - if (mappedValue !== value) { - const meta = getEventMeta(value) - if (meta && isTypescriptObject(mappedValue)) { - mappedValue = withEventMeta(mappedValue, meta) - } - } - return { done, value: mappedValue } - }, async () => { - try { - await iterator.return?.() - } - catch (error) { - throw await mapError(error) - } + return mapped + }), }) } diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts new file mode 100644 index 000000000..ca4343872 --- /dev/null +++ b/packages/client/src/index.test.ts @@ -0,0 +1,9 @@ +it('exports createORPCClient, ORPCError, DynamicLink, RPCJsonSerializer, RPCSerializer', async () => { + await expect(import('./index')).resolves.toEqual(expect.objectContaining({ + createORPCClient: expect.any(Function), + ORPCError: expect.any(Function), + DynamicLink: expect.any(Function), + RPCJsonSerializer: expect.any(Function), + RPCSerializer: expect.any(Function), + })) +}) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 8440019d3..e7010c4b4 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,23 +1,32 @@ +import { isInferableError } from './error-utils' + export * from './client' export * from './client-safe' export * from './consts' export * from './dynamic-link' export * from './error' +export * from './error-utils' export * from './event-iterator' +export * from './rpc-json-serializer' +export * from './rpc-serializer' export * from './types' export * from './utils' +export type { Registry, ThrowableError } from '@orpc/shared' export { AsyncIteratorClass, asyncIteratorToStream as eventIteratorToStream, asyncIteratorToUnproxiedDataStream as eventIteratorToUnproxiedDataStream, - EventPublisher, onError, onFinish, onStart, onSuccess, streamToAsyncIteratorClass as streamToEventIterator, } from '@orpc/shared' -export type { EventPublisherOptions, EventPublisherSubscribeIteratorOptions, Registry, ThrowableError } from '@orpc/shared' -export { ErrorEvent, getEventMeta, withEventMeta } from '@orpc/standard-server' -export type { EventMeta } from '@orpc/standard-server' +export type { AsyncCleanupFn, AsyncIteratorClassNextFn } from '@orpc/shared' +export { ErrorEvent, getEventMeta, unwrapEvent, withEventMeta } from '@standardserver/core' + +/** + * @deprecated Use `isInferableError` instead. + */ +export const isDefinedError = isInferableError diff --git a/packages/client/src/plugins/batch.test.ts b/packages/client/src/plugins/batch.test.ts index 8b9b4247c..d50b42266 100644 --- a/packages/client/src/plugins/batch.test.ts +++ b/packages/client/src/plugins/batch.test.ts @@ -1,542 +1,894 @@ -import type { StandardRequest } from '@orpc/standard-server' -import type { RouterClient } from '../../../server/src/router-client' -import { isAsyncIteratorObject } from '@orpc/shared' -import { toBatchResponse } from '@orpc/standard-server/batch' -import * as StandardBatchModule from '@orpc/standard-server/batch' -import { RPCHandler } from '../../../server/src/adapters/fetch/rpc-handler' -import { os } from '../../../server/src/builder' -import { BatchHandlerPlugin } from '../../../server/src/plugins/batch' -import { RPCLink } from '../adapters/fetch' +import type { StandardLazyResponse, StandardRequest } from '@standardserver/core' +import type { StandardLinkCodec, StandardLinkTransport } from '../adapters/standard' +import { sleep } from '@orpc/shared' +import { encodePeerMessage } from '@standardserver/peer' import { StandardLink } from '../adapters/standard' -import { createORPCClient } from '../client' -import { ORPCError } from '../error' import { BatchLinkPlugin } from './batch' -const toBatchRequestSpy = vi.spyOn(StandardBatchModule, 'toBatchRequest') +interface TestContext { + tag?: string +} + +function makeCodec(): StandardLinkCodec { + return { + encodeInput: vi.fn(async (input, path, { signal }) => { + return { + method: 'POST', + url: `/${path.join('/')}` as `/${string}`, + headers: { 'content-type': 'application/json' }, + body: input, + signal, + } satisfies StandardRequest + }), + decodeResponse: vi.fn(async (response) => { + const body = await response.resolveBody() + return { kind: 'output' as const, output: body } + }), + } +} + +function extractBatchMessagesFromRequest(request: StandardRequest): any[] { + if (Array.isArray(request.body)) { + return request.body + } + + // GET batch requests encode the message list in the `data` query param. + const match = request.url.match(/[?&]data=([^&#]*)/) + return match ? JSON.parse(decodeURIComponent(match[1]!)) : [] +} + +function makeBufferedBatchResponseFromRequest(request: StandardRequest, resultFn?: (id: unknown, index: number) => unknown): StandardLazyResponse { + const messages = extractBatchMessagesFromRequest(request) + + return { + status: 207, + headers: {}, + resolveBody: async () => messages.map((msg: any, i: number) => ({ + kind: 'response', + id: msg.id, + json: { status: 200, headers: { 'x-index': `${i}` }, body: resultFn ? resultFn(msg.id, i) : `result-${i}` }, + binary: undefined, + })), + } +} + +function makeTransport(): StandardLinkTransport { + return { + send: vi.fn['send']>(async (request) => { + if (request.headers['orpc-batch']) { + return makeBufferedBatchResponseFromRequest(request) + } + + return { + status: 200, + headers: {}, + resolveBody: async () => 'not-batched', + } + }), + } +} + +async function toLengthPrefixedBytes(messages: any[]): Promise> { + const chunks: Uint8Array[] = [] + + for (const message of messages) { + const encoded = await encodePeerMessage(message) + const bytes = typeof encoded === 'string' ? new TextEncoder().encode(encoded) : encoded + const header = new ArrayBuffer(4) + new DataView(header).setUint32(0, bytes.byteLength, false) + + chunks.push(new Uint8Array(header), bytes) + } + + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0) + const output = new Uint8Array(total) + let offset = 0 + + for (const chunk of chunks) { + output.set(chunk, offset) + offset += chunk.length + } + + return output +} beforeEach(() => { vi.clearAllMocks() + vi.useRealTimers() }) describe('batchLinkPlugin', () => { - const signal = AbortSignal.timeout(1000) + const defaultGroup = { + condition: () => true, + context: () => ({}), + } - const clientCall = vi.fn(async (request) => { - const response = await toBatchResponse({ - status: 200, - headers: {}, - body: (async function* () { - yield { index: 0, status: 200, headers: { 'x-custom': '1' }, body: 'yielded1' } - yield { index: 1, status: 201, headers: { 'x-custom': '2' }, body: 'yielded2' } - })(), + describe('request filtering and pass-through', () => { + it('passes through requests when filter returns false', async () => { + const codec = makeCodec() + const transport = makeTransport() + const filter = vi.fn(() => false) + const condition = vi.fn(() => true) + + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ + groups: [{ condition, context: () => ({}) }], + filter, + })], + }) + + await Promise.all([ + expect(link.call(['ping'], {}, { context: {} })).resolves.toBe('not-batched'), + expect(link.call(['ping'], {}, { context: {} })).resolves.toBe('not-batched'), + ]) + expect(filter).toHaveBeenCalledTimes(2) + expect(condition).not.toHaveBeenCalled() + expect(transport.send).toHaveBeenCalledTimes(2) + expect(vi.mocked(transport.send).mock.calls[0]![0].headers['orpc-batch']).toBeUndefined() + expect(vi.mocked(transport.send).mock.calls[1]![0].headers['orpc-batch']).toBeUndefined() }) - return { ...response, body: () => Promise.resolve(response.body) } - }) + it('passes through requests when no group matches', async () => { + const codec = makeCodec() + const transport = makeTransport() - const groupCondition = vi.fn(() => true) - - const encode = vi.fn(async (path, input, { signal }): Promise => ({ - url: new URL(`http://localhost/prefix/${path.join('/')}`), - method: path[0] as any, - headers: { - bearer: '123', - path, - }, - body: input, - signal, - })) - - const decode = vi.fn(async (response): Promise => response.body()) - - const link = new StandardLink({ encode, decode }, { call: clientCall }, { - plugins: [new BatchLinkPlugin({ - groups: [{ - condition: groupCondition, - context: { group: true } as any, - input: '__group__', - path: ['__group__'], - }], - })], - }) + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ + groups: [{ condition: () => false, context: () => ({}) }], + })], + }) - it.each(['POST', 'GET'])('batch request with %s method', async (method) => { - const [output1, output2] = await Promise.all([ - link.call([method, 'foo'], '__foo__', { context: { foo: true }, signal }), - link.call([method, 'bar'], '__bar__', { context: { bar: true } }), - ]) - - expect(output1).toEqual('yielded1') - expect(output2).toEqual('yielded2') - - expect(encode).toHaveBeenCalledTimes(2) - - const request1 = await encode.mock.results[0]!.value - const request2 = await encode.mock.results[1]!.value - - expect(toBatchRequestSpy).toHaveBeenCalledTimes(1) - expect(toBatchRequestSpy).toHaveBeenCalledWith({ - url: new URL(`http://localhost/prefix/${method}/foo/__batch__`), - method, - headers: { - bearer: '123', - }, - requests: [ - { - ...request1, - headers: { - ...request1.headers, - bearer: undefined, - }, - }, - { - ...request2, - headers: { - ...request2.headers, - bearer: undefined, + await Promise.all([ + expect(link.call(['ping'], {}, { context: {} })).resolves.toBe('not-batched'), + expect(link.call(['ping'], {}, { context: {} })).resolves.toBe('not-batched'), + ]) + + expect(transport.send).toHaveBeenCalledTimes(2) + }) + + it('passes through a single request without batching', async () => { + const codec = makeCodec() + const transport = makeTransport() + + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ + groups: [defaultGroup], + })], + }) + + await expect(link.call(['ping'], {}, { context: {} })).resolves.toBe('not-batched') + expect(transport.send).toHaveBeenCalledTimes(1) + }) + + it('skips batching for requests with Blob body', async () => { + const codec = makeCodec() + const transport = makeTransport() + + vi.mocked(codec.encodeInput).mockResolvedValueOnce({ + method: 'POST', + url: '/upload', + headers: {}, + body: new Blob(['data']), + }) + + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ + groups: [defaultGroup], + })], + }) + + await Promise.all([ + expect(link.call(['upload'], {}, { context: {} })).resolves.toBe('not-batched'), + expect(link.call(['upload'], {}, { context: {} })).resolves.toBe('not-batched'), + ]) + + expect(transport.send).toHaveBeenCalledTimes(2) + }) + + it('skips batching for requests with ReadableStream body', async () => { + const codec = makeCodec() + const transport = makeTransport() + + vi.mocked(codec.encodeInput).mockResolvedValueOnce({ + method: 'POST', + url: '/stream-upload', + headers: {}, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])) + controller.close() }, - }, - ], + }), + }) + + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ + groups: [defaultGroup], + })], + }) + + await Promise.all([ + expect(link.call(['upload-stream'], {}, { context: {} })).resolves.toBe('not-batched'), + expect(link.call(['upload-stream'], {}, { context: {} })).resolves.toBe('not-batched'), + ]) + + expect(transport.send).toHaveBeenCalledTimes(2) }) - expect(clientCall).toHaveBeenCalledTimes(1) - expect(clientCall).toHaveBeenCalledWith( - { - ...toBatchRequestSpy.mock.results[0]!.value, - headers: { - ...toBatchRequestSpy.mock.results[0]!.value.headers, - 'x-orpc-batch': 'streaming', - }, - }, - { context: { group: true }, signal: toBatchRequestSpy.mock.results[0]!.value.signal }, - ['__group__'], - '__group__', - ) - }) + it('skips batching for requests with async iterator body', async () => { + const codec = makeCodec() + const transport = makeTransport() + + async function* makeBody() { + yield 'chunk' + } + + vi.mocked(codec.encodeInput).mockResolvedValueOnce({ + method: 'POST', + url: '/iterator-upload', + headers: {}, + body: makeBody(), + }) - it.each(['POST', 'GET'])('batch on buffered mode with %s method', async (method) => { - const clientCall = vi.fn(async (request) => { - const response = await toBatchResponse({ + vi.mocked(transport.send).mockResolvedValueOnce({ status: 200, headers: {}, - body: (async function* () { - yield { index: 0, status: 200, headers: { 'x-custom': '1' }, body: 'yielded1' } - yield { index: 1, status: 201, headers: { 'x-custom': '2' }, body: 'yielded2' } - })(), - mode: 'buffered', + resolveBody: async () => 'iterator-response', }) - return { ...response, body: () => Promise.resolve(response.body) } + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ + groups: [defaultGroup], + })], + }) + + const result = await link.call(['upload-iterator'], {}, { context: {} }) + expect(result).toBe('iterator-response') + expect(transport.send).toHaveBeenCalledTimes(1) + const sentRequest = vi.mocked(transport.send).mock.calls[0]![0] + expect(sentRequest.headers['orpc-batch']).toBeUndefined() }) - const link = new StandardLink({ encode, decode }, { call: clientCall }, { - plugins: [new BatchLinkPlugin({ - mode: 'buffered', - groups: [{ - condition: groupCondition, - context: { group: true } as any, - input: '__group__', - path: ['__group__'], - }], - })], + it('skips batching when requests are already aborted', async () => { + const codec = makeCodec() + const transport = makeTransport() + const requestController = new AbortController() + requestController.abort() + + vi.mocked(codec.encodeInput).mockResolvedValueOnce({ + method: 'POST', + url: '/encoded-aborted', + headers: {}, + body: undefined, + signal: requestController.signal, + }) + + vi.mocked(transport.send).mockResolvedValueOnce({ + status: 200, + headers: {}, + resolveBody: async () => 'encoded-aborted-response', + }) + + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ + groups: [defaultGroup], + })], + }) + + await Promise.all([ + link.call(['ping'], {}, { context: {} }), + link.call(['ping'], {}, { context: {} }), + ]) + + expect(transport.send).toHaveBeenCalledTimes(2) // no batching happen }) + }) - const [output1, output2] = await Promise.all([ - link.call([method, 'foo'], '__foo__', { context: { foo: true }, signal }), - link.call([method, 'bar'], '__bar__', { context: { bar: true } }), - ]) - - expect(output1).toEqual('yielded1') - expect(output2).toEqual('yielded2') - - expect(encode).toHaveBeenCalledTimes(2) - - const request1 = await encode.mock.results[0]!.value - const request2 = await encode.mock.results[1]!.value - - expect(toBatchRequestSpy).toHaveBeenCalledTimes(1) - expect(toBatchRequestSpy).toHaveBeenCalledWith({ - url: new URL(`http://localhost/prefix/${method}/foo/__batch__`), - method, - headers: { - bearer: '123', - }, - requests: [ - { - ...request1, - headers: { - ...request1.headers, - bearer: undefined, - }, - }, - { - ...request2, - headers: { - ...request2.headers, - bearer: undefined, - }, - }, - ], + describe('batching and grouping behavior', () => { + it('batches multiple concurrent requests', async () => { + const codec = makeCodec() + const transport = makeTransport() + + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ + groups: [defaultGroup], + mode: 'buffered', + })], + }) + + await Promise.all([ + expect(link.call(['ping'], { n: 1 }, { context: {} })).resolves.toBe('result-0'), + expect(link.call(['ping'], { n: 2 }, { context: {} })).resolves.toBe('result-1'), + ]) + + expect(transport.send).toHaveBeenCalledTimes(1) + const sentRequest = vi.mocked(transport.send).mock.calls[0]![0] + expect(sentRequest.headers['orpc-batch']).toBe('buffered') }) - expect(clientCall).toHaveBeenCalledTimes(1) - expect(clientCall).toHaveBeenCalledWith( - { - ...toBatchRequestSpy.mock.results[0]!.value, - headers: { - ...toBatchRequestSpy.mock.results[0]!.value.headers, - 'x-orpc-batch': 'buffered', - }, - }, - { context: { group: true }, signal: toBatchRequestSpy.mock.results[0]!.value.signal }, - ['__group__'], - '__group__', - ) - }) + it('splits batches when exceeding maxSize', async () => { + const codec = makeCodec() + const transport = makeTransport() - it('not batch on single request', async () => { - const [output1] = await Promise.all([ - link.call(['POST', 'foo'], '__foo__', { context: { foo: true }, signal }), - ]) + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ + groups: [defaultGroup], + mode: 'buffered', + maxSize: 2, + })], + }) - expect(output1).toSatisfy(isAsyncIteratorObject) + // 4 concurrent requests with maxSize 2 should split into 2 batches of 2 + await Promise.all([ + link.call(['a'], {}, { context: {} }), + link.call(['b'], {}, { context: {} }), + link.call(['c'], {}, { context: {} }), + link.call(['d'], {}, { context: {} }), + ]) - expect(toBatchRequestSpy).toHaveBeenCalledTimes(0) - expect(clientCall).toHaveBeenCalledTimes(1) + expect(transport.send).toHaveBeenCalledTimes(2) + }) - const request = await encode.mock.results[0]!.value + it('deduplicates common headers in batch requests', async () => { + const codec = makeCodec() + const transport = makeTransport() - expect(clientCall).toHaveBeenCalledWith( - request, - { context: { foo: true }, signal }, - ['POST', 'foo'], - '__foo__', - ) - }) + let callIndex = 0 + vi.mocked(codec.encodeInput).mockImplementation(async () => { + callIndex++ + return { + method: 'POST', + url: `/test-${callIndex}` as `/${string}`, + headers: { + 'authorization': 'Bearer token123', + 'x-unique': `value-${callIndex}`, + }, + body: undefined, + } + }) + + vi.mocked(transport.send).mockImplementation(async (request) => { + return makeBufferedBatchResponseFromRequest(request) + }) - it('not batch on aborted request', async () => { - const controller = new AbortController() - controller.abort() + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ + groups: [defaultGroup], + })], + }) - await Promise.all([ - link.call(['POST', 'foo'], '__foo__', { context: { foo: true }, signal }), - link.call(['POST', 'bar'], '__bar__', { context: { bar: true }, signal: controller.signal }), - ]) + await Promise.all([ + link.call(['a'], {}, { context: {} }), + link.call(['b'], {}, { context: {} }), + ]) - expect(toBatchRequestSpy).toHaveBeenCalledTimes(0) - expect(clientCall).toHaveBeenCalledTimes(2) - }) + expect(transport.send).toHaveBeenCalledTimes(1) - it.each([new FormData(), (async function* () {})()])('not batch on un-supported body', async (body) => { - encode.mockResolvedValueOnce({ - body, - headers: { - 'x-custom': '1', - }, - method: 'POST', - signal, - url: new URL(`http://some.url/prefix/foo`), + const sentRequest = vi.mocked(transport.send).mock.calls[0]![0] + expect(sentRequest.headers.authorization).toBe('Bearer token123') + expect(sentRequest.headers['x-unique']).toBeUndefined() }) - const [output1] = await Promise.all([ - link.call(['POST', 'foo'], '__foo__', { context: { foo: true }, signal }), - ]) + it('low-priority merge batch response headers into subresponse', async () => { + const codec = makeCodec() + const transport = makeTransport() - expect(output1).toSatisfy(isAsyncIteratorObject) + vi.mocked(transport.send).mockImplementation(async (request) => { + const response = makeBufferedBatchResponseFromRequest(request) + return { + ...response, + headers: { + ...response.headers, + 'x-from-batch-response': 'true', + 'x-index': 'low-priority', + }, + } + }) - expect(toBatchRequestSpy).toHaveBeenCalledTimes(0) - expect(clientCall).toHaveBeenCalledTimes(1) + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ + groups: [defaultGroup], + })], + }) - const request = await encode.mock.results[0]!.value + await Promise.all([ + link.call(['a'], {}, { context: {} }), + link.call(['b'], {}, { context: {} }), + ]) - expect(clientCall).toHaveBeenCalledWith( - request, - { context: { foo: true }, signal }, - ['POST', 'foo'], - '__foo__', - ) - }) + expect(codec.decodeResponse).toHaveBeenCalledTimes(2) + const subResponse1 = vi.mocked(codec.decodeResponse).mock.calls[0]![0] + const subResponse2 = vi.mocked(codec.decodeResponse).mock.calls[1]![0] - it('not batch when no group is matched', async () => { - groupCondition.mockReturnValueOnce(false) + expect(subResponse1.headers['x-from-batch-response']).toEqual('true') + expect(subResponse1.headers['x-index']).toEqual('0') - const [output1, output2] = await Promise.all([ - link.call(['POST', 'foo'], '__foo__', { context: { foo: true }, signal }), - link.call(['POST', 'bar'], '__bar__', { context: { bar: true } }), - ]) + expect(subResponse2.headers['x-from-batch-response']).toEqual('true') + expect(subResponse2.headers['x-index']).toEqual('1') + }) - expect(output1).toSatisfy(isAsyncIteratorObject) - expect(output2).toSatisfy(isAsyncIteratorObject) + it('separates GET and POST requests into distinct batches', async () => { + const codec = makeCodec() + const transport = makeTransport() + + let callIndex = 0 + vi.mocked(codec.encodeInput).mockImplementation(async () => { + callIndex++ + const method = callIndex <= 2 ? 'GET' : 'POST' + return { + method, + url: `/test-${callIndex}` as `/${string}`, + headers: {}, + body: undefined, + } + }) - expect(toBatchRequestSpy).toHaveBeenCalledTimes(0) - expect(clientCall).toHaveBeenCalledTimes(2) + vi.mocked(transport.send).mockImplementation(async (request) => { + if (request.headers['orpc-batch']) { + return makeBufferedBatchResponseFromRequest(request) + } + return { status: 200, headers: {}, resolveBody: async () => 'direct' } + }) - const request1 = await encode.mock.results[0]!.value + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ + groups: [defaultGroup], + mode: 'buffered', + })], + }) - expect(clientCall).toHaveBeenNthCalledWith( - 1, - request1, - { context: { foo: true }, signal }, - ['POST', 'foo'], - '__foo__', - ) + await Promise.all([ + link.call(['get1'], {}, { context: {} }), + link.call(['get2'], {}, { context: {} }), + link.call(['post1'], {}, { context: {} }), + link.call(['post2'], {}, { context: {} }), + ]) - const request2 = await encode.mock.results[1]!.value + // Should have at least 2 batch calls: one for GET, one for POST + expect(transport.send).toHaveBeenCalledTimes(2) - expect(clientCall).toHaveBeenNthCalledWith( - 2, - request2, - { context: { bar: true } }, - ['POST', 'bar'], - '__bar__', - ) - }) + const sentGetRequest = vi.mocked(transport.send).mock.calls.find(([request]) => request.method === 'GET')![0] + expect(sentGetRequest).toBeDefined() + expect(sentGetRequest.headers['orpc-batch']).toBe('buffered') - it('throw on invalid batch response', async () => { - clientCall.mockResolvedValueOnce({ - body: async () => 'invalid', - headers: {}, - status: 404, + const sentPostRequest = vi.mocked(transport.send).mock.calls.find(([request]) => request.method === 'POST')![0] + expect(sentPostRequest).toBeDefined() + expect(sentPostRequest.headers['orpc-batch']).toBe('buffered') }) - await expect( - Promise.all([ - link.call(['POST', 'foo'], '__foo__', { context: { foo: true }, signal }), - link.call(['POST', 'bar'], '__bar__', { context: { bar: true } }), - ]), - ).rejects.toThrow('Invalid batch response') + it('aborts grouped batch request when all sub-requests are aborted', async () => { + const codec = makeCodec() + const transport = makeTransport() - expect(clientCall).toBeCalledTimes(1) - expect(toBatchRequestSpy).toBeCalledTimes(1) - }) + vi.mocked(transport.send).mockImplementation(async (request) => { + await sleep(50) + request.signal?.throwIfAborted() + + return { + status: 207, + headers: {}, + resolveBody: async () => [], + } + }) + + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ groups: [defaultGroup], mode: 'streaming' })], + }) + + const controller1 = new AbortController() + const controller2 = new AbortController() - it('separate GET and non-GET requests', async () => { - const [output11, output12, output21, output22] = await Promise.all([ - link.call(['GET', 'foo'], '__foo__', { context: { foo: true }, signal }), - link.call(['POST', 'foo'], '__foo__', { context: { foo: true }, signal }), - link.call(['GET', 'bar'], '__bar__', { context: { bar: true } }), - link.call(['POST', 'bar'], '__bar__', { context: { bar: true } }), - ]) + const promise = Promise.all([ + expect(link.call(['a'], {}, { context: {}, signal: controller1.signal })).rejects.toThrow('aborted'), + expect(link.call(['b'], {}, { context: {}, signal: controller2.signal })).rejects.toThrow('aborted'), + ]) - expect(output11).toEqual('yielded1') - expect(output21).toEqual('yielded2') - expect(output12).toEqual('yielded1') - expect(output22).toEqual('yielded2') + await sleep(10) + expect(vi.mocked(transport.send)).toHaveBeenCalledTimes(1) - expect(toBatchRequestSpy).toHaveBeenCalledTimes(2) + controller1.abort() + await sleep(10) + expect(vi.mocked(transport.send).mock.calls[0]![0].signal?.aborted).toBe(false) + + controller2.abort() + await sleep(10) + expect(vi.mocked(transport.send).mock.calls[0]![0].signal?.aborted).toBe(true) + + await promise + }) }) - it('split in half when exeeding max batch size', async () => { - const link = new StandardLink({ encode, decode }, { call: clientCall }, { - plugins: [new BatchLinkPlugin({ - groups: [{ - condition: groupCondition, - context: { group: true } as any, - input: '__group__', - path: ['__group__'], - }], - maxSize: 2, - })], + describe('batch response decoding', () => { + it('decodes length-prefixed blob batch responses', async () => { + const codec = makeCodec() + const transport = makeTransport() + + vi.mocked(transport.send).mockImplementation(async (request) => { + if (!request.headers['orpc-batch']) { + return { status: 200, headers: {}, resolveBody: async () => 'direct' } + } + + const rawMessages = Array.isArray(request.body) ? request.body : [] + const responseMessages = rawMessages.map((msg: any, i: number) => ({ + kind: 'response', + id: msg.id, + json: { status: 200, headers: {}, body: `blob-${i}` }, + })) + + const bytes = await toLengthPrefixedBytes(responseMessages) + + return { + status: 207, + headers: {}, + resolveBody: async () => new Blob([bytes], { type: 'application/octet-stream' }), + } + }) + + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ groups: [defaultGroup], mode: 'buffered' })], + }) + + await Promise.all([ + expect(link.call(['a'], {}, { context: {} })).resolves.toBe('blob-0'), + expect(link.call(['b'], {}, { context: {} })).resolves.toBe('blob-1'), + ]) }) - const [output11, output12, output21, output22] = await Promise.all([ - link.call(['POST', 'foo'], '__foo__', { context: { foo: true }, signal }), - link.call(['POST', 'foo'], '__foo__', { context: { foo: true }, signal }), - link.call(['POST', 'bar'], '__bar__', { context: { bar: true } }), - link.call(['POST', 'bar'], '__bar__', { context: { bar: true } }), - ]) + it('decodes length-prefixed stream batch responses', async () => { + const codec = makeCodec() + const transport = makeTransport() + + vi.mocked(transport.send).mockImplementation(async (request) => { + if (!request.headers['orpc-batch']) { + return { status: 200, headers: {}, resolveBody: async () => 'direct' } + } + + const rawMessages = Array.isArray(request.body) ? request.body : [] + const responseMessages = rawMessages.map((msg: any, i: number) => ({ + kind: 'response', + id: msg.id, + json: { status: 200, headers: {}, body: `stream-${i}` }, + })) + + const bytes = await toLengthPrefixedBytes(responseMessages) + const splitAt = Math.max(1, Math.floor(bytes.length / 2)) + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(bytes.subarray(0, splitAt)) + controller.enqueue(bytes.subarray(splitAt)) + controller.close() + }, + }) - expect(output11).toEqual('yielded1') - expect(output21).toEqual('yielded1') - expect(output12).toEqual('yielded2') - expect(output22).toEqual('yielded2') + return { + status: 207, + headers: {}, + resolveBody: async () => stream, + } + }) - expect(toBatchRequestSpy).toHaveBeenCalledTimes(2) - }) + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ groups: [defaultGroup], mode: 'streaming' })], + }) - it('split in half when url exceeds max url length', async () => { - const link = new StandardLink({ encode, decode }, { call: clientCall }, { - plugins: [new BatchLinkPlugin({ - groups: [{ - condition: groupCondition, - context: { group: true } as any, - input: '__group__', - path: ['__group__'], - }], - maxUrlLength: 500, - })], + await Promise.all([ + expect(link.call(['a'], {}, { context: {} })).resolves.toBe('stream-0'), + expect(link.call(['b'], {}, { context: {} })).resolves.toBe('stream-1'), + ]) }) - const [output11, output12, output21, output22] = await Promise.all([ - link.call(['GET', 'foo'], '__foo__', { context: { foo: true }, signal }), - link.call(['GET', 'foo'], '__foo__', { context: { foo: true }, signal }), - link.call(['GET', 'bar'], '__bar__', { context: { bar: true } }), - link.call(['GET', 'bar'], '__bar__', { context: { bar: true } }), - ]) + it('decodes streamed responses when length header and payload arrive in separate chunks', async () => { + const codec = makeCodec() + const transport = makeTransport() + + vi.mocked(transport.send).mockImplementation(async (request) => { + if (!request.headers['orpc-batch']) { + return { status: 200, headers: {}, resolveBody: async () => 'direct' } + } + + const rawMessages = Array.isArray(request.body) ? request.body : [] + const responseMessages = rawMessages.map((msg: any, i: number) => ({ + kind: 'response', + id: msg.id, + json: { status: 200, headers: {}, body: `split-${i}` }, + })) + + const bytes = await toLengthPrefixedBytes(responseMessages) + + const stream = new ReadableStream({ + start(controller) { + // Send only length header first, then payload bytes. + controller.enqueue(bytes.subarray(0, 4)) + controller.enqueue(bytes.subarray(4)) + controller.close() + }, + }) - expect(output11).toEqual('yielded1') - expect(output21).toEqual('yielded1') - expect(output12).toEqual('yielded2') - expect(output22).toEqual('yielded2') + return { + status: 207, + headers: {}, + resolveBody: async () => stream, + } + }) - expect(toBatchRequestSpy).toHaveBeenCalledTimes(3) - }) + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ groups: [defaultGroup], mode: 'streaming' })], + }) - it('silence remove x-orpc-batch=1 header', async () => { - encode.mockResolvedValueOnce({ - body: async () => 'something', - headers: { - 'x-custom': '1', - 'x-orpc-batch': '1', - }, - method: 'POST', - signal, - url: new URL(`http://some.url/prefix/foo`), + await Promise.all([ + expect(link.call(['x'], {}, { context: {} })).resolves.toBe('split-0'), + expect(link.call(['y'], {}, { context: {} })).resolves.toBe('split-1'), + ]) }) - await link.call(['POST', 'foo'], '__foo__', { context: {} }) + it('rejects on malformed array batch responses with invalid messages', async () => { + const codec = makeCodec() + const transport = makeTransport() - expect(clientCall).toHaveBeenCalledTimes(1) + vi.mocked(transport.send).mockImplementation(async () => { + return { + status: 207, + headers: {}, + resolveBody: async () => ['INVALID', 'INVALID'], + } + }) - const request = clientCall.mock.calls[0]![0] + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ groups: [defaultGroup], mode: 'buffered' })], + }) - expect(request.headers).toEqual({ - 'x-custom': '1', + await Promise.all([ + expect(link.call(['a'], {}, { context: {} })).rejects.toThrow('Invalid batch response format'), + expect(link.call(['b'], {}, { context: {} })).rejects.toThrow('Invalid batch response format'), + ]) }) - }) - it('can exclude a request from the batch', async () => { - const exclude = vi.fn(({ request }) => request.url.pathname.endsWith('bar1')) - - const link = new StandardLink({ encode, decode }, { call: clientCall }, { - plugins: [new BatchLinkPlugin({ - groups: [{ - condition: groupCondition, - context: { group: true } as any, - input: '__group__', - path: ['__group__'], - }], - exclude, - })], + it('rejects on malformed blob batch responses with incomplete headers', async () => { + const codec = makeCodec() + const transport = makeTransport() + + vi.mocked(transport.send).mockImplementation(async () => { + return { + status: 207, + headers: {}, + resolveBody: async () => new Blob([new Uint8Array([1, 2, 3])]), + } + }) + + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ groups: [defaultGroup], mode: 'buffered' })], + }) + + await Promise.all([ + expect(link.call(['a'], {}, { context: {} })).rejects.toThrow('Invalid batch response: incomplete length header.'), + expect(link.call(['b'], {}, { context: {} })).rejects.toThrow('Invalid batch response: incomplete length header.'), + ]) }) - const [output1, output2, output3] = await Promise.all([ - link.call(['POST', 'foo'], '__foo__', { context: { foo: true }, signal }), - link.call(['POST', 'bar1'], '__bar1__', { context: { bar: true } }), - link.call(['POST', 'bar2'], '__bar2__', { context: { bar: true } }), - ]) + it('rejects on malformed blob batch responses with incomplete messages', async () => { + const codec = makeCodec() + const transport = makeTransport() - expect(output1).toEqual('yielded1') - expect(output3).toEqual('yielded2') - expect(output2).toSatisfy(isAsyncIteratorObject) + vi.mocked(transport.send).mockImplementation(async () => { + return { + status: 207, + headers: {}, + resolveBody: async () => new Blob(['MALFORMED']), + } + }) - expect(toBatchRequestSpy).toHaveBeenCalledTimes(1) - expect(clientCall).toHaveBeenCalledTimes(2) + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ groups: [defaultGroup], mode: 'buffered' })], + }) - expect(exclude).toHaveBeenCalledTimes(3) - }) + await Promise.all([ + expect(link.call(['a'], {}, { context: {} })).rejects.toThrow('Invalid batch response: incomplete message.'), + expect(link.call(['b'], {}, { context: {} })).rejects.toThrow('Invalid batch response: incomplete message.'), + ]) + }) - it('should throw error when the responses is missing', async () => { - const first = link.call(['GET', 'foo'], '__foo__', { context: { foo: true }, signal }) - const second = link.call(['GET', 'foo'], '__foo__', { context: { foo: true }, signal }) + it('rejects on malformed blob batch responses with invalid messages', async () => { + const codec = makeCodec() + const transport = makeTransport() - await expect( - link.call(['GET', 'bar'], '__bar__', { context: { bar: true } }), - ).rejects.toThrow('Something went wrong make batch response not contains enough responses. This can be a bug please report it.') + vi.mocked(transport.send).mockImplementation(async () => { + const bytes = await toLengthPrefixedBytes(['INVALID', 'INVALID']) - expect(toBatchRequestSpy).toHaveBeenCalledTimes(1) + return { + status: 207, + headers: {}, + resolveBody: async () => new Blob([bytes], { type: 'application/octet-stream' }), + } + }) - expect(await first).toEqual('yielded1') - expect(await second).toEqual('yielded2') - }) + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ groups: [defaultGroup], mode: 'buffered' })], + }) - it('should throw individual request abort reasons when requests are aborted during batch processing', async () => { - clientCall.mockImplementationOnce(async ({ signal }) => { - await new Promise(r => setTimeout(r, 100)) - throw signal.reason + await Promise.all([ + expect(link.call(['a'], {}, { context: {} })).rejects.toThrow('Invalid batch response: invalid message.'), + expect(link.call(['b'], {}, { context: {} })).rejects.toThrow('Invalid batch response: invalid message.'), + ]) }) - const controller1 = new AbortController() - const controller2 = new AbortController() + it('rejects on malformed streamed batch responses with incomplete headers', async () => { + const codec = makeCodec() + const transport = makeTransport() + + vi.mocked(transport.send).mockImplementation(async () => { + return { + status: 207, + headers: {}, + resolveBody: async () => new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])) + controller.close() + }, + }), + } + }) + + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ groups: [defaultGroup], mode: 'buffered' })], + }) - const promise = Promise.all([ - expect( - link.call(['POST', 'foo'], '__foo__', { context: { foo: true }, signal: controller1.signal }), - ).rejects.toSatisfy(err => err === controller1.signal.reason), - expect( - link.call(['POST', 'bar'], '__bar__', { context: { bar: true }, signal: controller2.signal }), - ).rejects.toSatisfy(err => err === controller2.signal.reason), - ]) + await Promise.all([ + expect(link.call(['a'], {}, { context: {} })).rejects.toThrow('Batch response is incomplete.'), + expect(link.call(['b'], {}, { context: {} })).rejects.toThrow('Batch response is incomplete.'), + ]) + }) - await new Promise(resolve => setTimeout(resolve, 1)) - controller1.abort() - controller2.abort() + it('rejects on malformed streamed batch responses with incomplete messages', async () => { + const codec = makeCodec() + const transport = makeTransport() + + vi.mocked(transport.send).mockImplementation(async () => { + return { + status: 207, + headers: {}, + resolveBody: async () => new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('MALFORMED')) + controller.close() + }, + }), + } + }) - await promise + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ groups: [defaultGroup], mode: 'buffered' })], + }) - expect(toBatchRequestSpy).toHaveBeenCalledTimes(1) - expect(clientCall).toHaveBeenCalledTimes(1) - }) -}) + await Promise.all([ + expect(link.call(['a'], {}, { context: {} })).rejects.toThrow('Batch response is incomplete.'), + expect(link.call(['b'], {}, { context: {} })).rejects.toThrow('Batch response is incomplete.'), + ]) + }) -describe('batchLinkPlugin + batchHandlerPlugin', () => { - const router = { - success: os.handler(({ input }) => ({ output: input })), - error: os.handler(({ input }) => { - throw new ORPCError('TEST', { data: input }) - }), - } + it('rejects on malformed streamed batch responses with invalid messages', async () => { + const codec = makeCodec() + const transport = makeTransport() + + vi.mocked(transport.send).mockImplementation(async () => { + const bytes = await toLengthPrefixedBytes(['INVALID', 'INVALID']) + + return { + status: 207, + headers: {}, + resolveBody: async () => new ReadableStream({ + start(controller) { + controller.enqueue(bytes) + controller.close() + }, + }), + } + }) - const handler = new RPCHandler(router, { - plugins: [ - new BatchHandlerPlugin(), - ], + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ groups: [defaultGroup], mode: 'buffered' })], + }) + + await Promise.all([ + expect(link.call(['a'], {}, { context: {} })).rejects.toThrow('Invalid batch response: invalid message.'), + expect(link.call(['b'], {}, { context: {} })).rejects.toThrow('Invalid batch response: invalid message.'), + ]) + }) }) - const fetch = vi.fn(async (request) => { - const { response } = await handler.handle(request, { - prefix: '/prefix', + describe('method GET batch URL handling', () => { + it('splits GET batches when URL exceeds maxUrlLength', async () => { + const codec = makeCodec() + const transport = makeTransport() + + vi.mocked(codec.encodeInput).mockImplementation(async (_input, path) => ({ + method: 'GET', + url: `/${path.join('/')}` as `/${string}`, + headers: {}, + body: undefined, + })) + + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ + groups: [defaultGroup], + mode: 'buffered', + maxUrlLength: 1, + })], + }) + + await Promise.all([ + expect(link.call(['get-a'], {}, { context: {} })).resolves.toBe('not-batched'), + expect(link.call(['get-b'], {}, { context: {} })).resolves.toBe('not-batched'), + ]) }) - return response ?? Promise.reject(new Error('No response')) - }) + it('appends batch data to existing query params and preserves hash', async () => { + const codec = makeCodec() + const transport = makeTransport() - const link = new RPCLink({ - url: 'http://localhost/prefix', - fetch, - plugins: [ - new BatchLinkPlugin({ - groups: [{ - condition: () => true, - context: {}, - }], - }), - ], - }) + vi.mocked(codec.encodeInput).mockImplementation(async (_input, path) => ({ + method: 'GET', + url: `/${path.join('/')}` as `/${string}`, + headers: {}, + body: undefined, + })) - const client: RouterClient = createORPCClient(link) + vi.mocked(transport.send).mockImplementation(async (request) => { + return makeBufferedBatchResponseFromRequest(request) + }) - it('on success', async () => { - const [output1, output2] = await Promise.all([ - client.success('success1'), - client.success('success2'), - ]) + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ + groups: [defaultGroup], + mode: 'buffered', + url: () => '/custom/__batch__?existing=1#anchor', + })], + }) - expect(output1).toEqual({ output: 'success1' }) - expect(output2).toEqual({ output: 'success2' }) + await Promise.all([ + expect(link.call(['q1'], {}, { context: {} })).resolves.toBe('result-0'), + expect(link.call(['q2'], {}, { context: {} })).resolves.toBe('result-1'), + ]) - expect(fetch).toHaveBeenCalledTimes(1) - }) + expect(transport.send).toHaveBeenCalledTimes(1) + const sentRequest = vi.mocked(transport.send).mock.calls[0]![0] + expect(sentRequest.url).toContain('/custom/__batch__?existing=1&data=') + expect(sentRequest.url).toContain('#anchor') + }) + + it('appends batch data to existing query params without hash', async () => { + const codec = makeCodec() + const transport = makeTransport() + + vi.mocked(codec.encodeInput).mockImplementation(async (_input, path) => ({ + method: 'GET', + url: `/${path.join('/')}` as `/${string}`, + headers: {}, + body: undefined, + })) - it('on error', async () => { - await expect( - Promise.all([ - client.error('success1'), - client.error('success2'), - ]), - ).rejects.toThrow('TEST') + vi.mocked(transport.send).mockImplementation(async (request) => { + return makeBufferedBatchResponseFromRequest(request) + }) + + const link = new StandardLink(codec, transport, { + plugins: [new BatchLinkPlugin({ + groups: [defaultGroup], + mode: 'buffered', + url: () => '/custom-no-hash/__batch__?existing=1', + })], + }) - expect(fetch).toHaveBeenCalledTimes(1) + await Promise.all([ + expect(link.call(['q3'], {}, { context: {} })).resolves.toBe('result-0'), + expect(link.call(['q4'], {}, { context: {} })).resolves.toBe('result-1'), + ]) + + expect(transport.send).toHaveBeenCalledTimes(1) + const sentRequest = vi.mocked(transport.send).mock.calls[0]![0] + expect(sentRequest.url).toContain('/custom-no-hash/__batch__?existing=1&data=') + expect(sentRequest.url).not.toContain('#') + }) }) }) diff --git a/packages/client/src/plugins/batch.ts b/packages/client/src/plugins/batch.ts index 7167b83c5..a0150013b 100644 --- a/packages/client/src/plugins/batch.ts +++ b/packages/client/src/plugins/batch.ts @@ -1,310 +1,425 @@ import type { InterceptorOptions, Promisable, Value } from '@orpc/shared' -import type { StandardHeaders, StandardLazyResponse, StandardRequest } from '@orpc/standard-server' -import type { BatchResponseMode } from '@orpc/standard-server/batch' -import type { StandardLinkClientInterceptorOptions, StandardLinkOptions, StandardLinkPlugin } from '../adapters/standard' +import type { StandardHeaders, StandardLazyResponse, StandardRequest, StandardUrl } from '@standardserver/core' +import type { ClientPeerSendMessage } from '@standardserver/peer' +import type { StandardLinkOptions, StandardLinkPlugin, StandardLinkTransportInterceptor, StandardLinkTransportInterceptorOptions } from '../adapters/standard' import type { ClientContext } from '../types' -import { defer, isAsyncIteratorObject, splitInHalf, toArray, value } from '@orpc/shared' -import { parseBatchResponse, toBatchRequest } from '@orpc/standard-server/batch' +import { defer, isAsyncIteratorObject, loadBytes, splitInHalf, stringifyJSON, toArray, value } from '@orpc/shared' +import { parseStandardUrl } from '@standardserver/core' +import { ClientPeer, decodePeerMessage, isServerPeerSendMessage } from '@standardserver/peer' + +export type BatchLinkPluginMode = 'streaming' | 'buffered' export interface BatchLinkPluginGroup { - condition(options: StandardLinkClientInterceptorOptions): boolean - context: T - path?: readonly string[] - input?: unknown + /** + * Determines whether a request should be included in this batch group. + * Requests will be evaluated against each group's condition in order, + * and included in the first group whose condition returns true. + * If no group's condition returns true, the request will not be batched. + */ + condition: Value]> + + /** + * The client context applied to requests in this batch group for the remainder of the link chain. + */ + context: Value, ...StandardLinkTransportInterceptorOptions[]]]> + + /** + * The path segments applied to requests in this batch group for the remainder of the link chain. + * + * @default [] + */ + path?: Value, ...StandardLinkTransportInterceptorOptions[]]]> } +export class BatchLinkPluginError extends TypeError {} + export interface BatchLinkPluginOptions { - groups: readonly [BatchLinkPluginGroup, ...BatchLinkPluginGroup[]] + groups: [BatchLinkPluginGroup, ...BatchLinkPluginGroup[]] + + /** + * Filters requests to batch. + * + * @default () => true + */ + filter?: Value]> /** * The maximum number of requests in the batch. * * @default 10 */ - maxSize?: Value, [readonly [StandardLinkClientInterceptorOptions, ...StandardLinkClientInterceptorOptions[]]]> + maxSize?: Value, [subOptionsList: [StandardLinkTransportInterceptorOptions, ...StandardLinkTransportInterceptorOptions[]]]> /** * The batch response mode. * * @default 'streaming' */ - mode?: Value, ...StandardLinkClientInterceptorOptions[]]]> + mode?: Value, ...StandardLinkTransportInterceptorOptions[]]]> /** - * Defines the URL to use for the batch request. + * URL for the batch request. * - * @default the URL of the first request in the batch + '/__batch__' + * @default URL of the first subrequest in the batch + '/__batch__' */ - url?: Value, [readonly [StandardLinkClientInterceptorOptions, ...StandardLinkClientInterceptorOptions[]]]> + url?: Value, [subOptionsList: [StandardLinkTransportInterceptorOptions, ...StandardLinkTransportInterceptorOptions[]]]> /** - * The maximum length of the URL. + * The maximum length of the URL that runtime supports, + * if exceeded, the batch will be split into smaller batches and sent sequentially. + * + * This only applies to GET batch requests where the batch data is sent via URL query parameter. * * @default 2083 */ - maxUrlLength?: Value, [readonly [StandardLinkClientInterceptorOptions, ...StandardLinkClientInterceptorOptions[]]]> + maxUrlLength?: Value, [subOptionsList: [StandardLinkTransportInterceptorOptions, ...StandardLinkTransportInterceptorOptions[]]]> /** - * Defines the HTTP headers to use for the batch request. + * Headers used for the batch request. * - * @default The same headers of all requests in the batch + * @default Common headers among all subrequests in the batch. */ - headers?: Value, [readonly [StandardLinkClientInterceptorOptions, ...StandardLinkClientInterceptorOptions[]]]> + headers?: Value, [subOptionsList: [StandardLinkTransportInterceptorOptions, ...StandardLinkTransportInterceptorOptions[]]]> /** - * Map the batch request items before sending them. + * Map each subrequest in the batch before it is sent. * - * @default Removes headers that are duplicated in the batch headers. + * @default Removes headers that are duplicated with the batch headers */ - mapRequestItem?: (options: StandardLinkClientInterceptorOptions & { batchUrl: URL, batchHeaders: StandardHeaders }) => StandardRequest + mapSubrequest?: (subOptions: StandardLinkTransportInterceptorOptions, partialBatchRequest: Pick) => StandardRequest /** - * Exclude a request from the batch. + * Maps each subresponse before returning the final response. * - * @default () => false + * @default Low-priority merges headers from the batch response into each subresponse. */ - exclude?: (options: StandardLinkClientInterceptorOptions) => boolean + mapSubresponse?: (subResponse: StandardLazyResponse, batchResponse: StandardLazyResponse, subOptions: StandardLinkTransportInterceptorOptions) => StandardLazyResponse } -/** - * The Batch Requests Plugin allows you to combine multiple requests and responses into a single batch, - * reducing the overhead of sending each one separately. - * - * @see {@link https://orpc.dev/docs/plugins/batch-requests Batch Requests Plugin Docs} - */ export class BatchLinkPlugin implements StandardLinkPlugin { - private readonly groups: Exclude['groups'], undefined> + name = '~batch' + + private readonly groups: BatchLinkPluginOptions['groups'] + private readonly filter: Exclude['filter'], undefined> private readonly maxSize: Exclude['maxSize'], undefined> + private readonly mode: Exclude['mode'], undefined> private readonly batchUrl: Exclude['url'], undefined> private readonly maxUrlLength: Exclude['maxUrlLength'], undefined> private readonly batchHeaders: Exclude['headers'], undefined> - private readonly mapRequestItem: Exclude['mapRequestItem'], undefined> - private readonly exclude: Exclude['exclude'], undefined> - private readonly mode: Exclude['mode'], undefined> + private readonly mapSubrequest: Exclude['mapSubrequest'], undefined> + private readonly mapSubresponse: Exclude['mapSubresponse'], undefined> - private pending: Map< + private readonly queue: Map< BatchLinkPluginGroup, [ - options: InterceptorOptions, Promise>, - resolve: (response: StandardLazyResponse) => void, - reject: (e: unknown) => void, + options: InterceptorOptions, Promise>, + resolve: (response: StandardLazyResponse) => void, + reject: (e: unknown) => void, ][] - > - - order = 5_000_000 + > = new Map() constructor(options: NoInfer>) { this.groups = options.groups - this.pending = new Map() - + this.filter = options.filter ?? (() => true) this.maxSize = options.maxSize ?? 10 - this.maxUrlLength = options.maxUrlLength ?? 2083 - this.mode = options.mode ?? 'streaming' - this.batchUrl = options.url ?? (([options]) => `${options.request.url.origin}${options.request.url.pathname}/__batch__`) - - this.batchHeaders = options.headers ?? (([options, ...rest]) => { - const headers: StandardHeaders = {} - - for (const [key, value] of Object.entries(options.request.headers)) { - if (rest.every(item => item.request.headers[key] === value)) { - headers[key] = value + this.batchUrl = options.url ?? ((options) => { + const [pathname] = parseStandardUrl(options[0].request.url) + return `${pathname}/__batch__` + }) + this.maxUrlLength = options.maxUrlLength ?? 2083 + this.batchHeaders = options.headers ?? (async (options) => { + const headersList = options.map(o => o.request.headers) + const commonHeaders: StandardHeaders = {} + for (const headers of headersList) { + for (const [key, value] of Object.entries(headers)) { + if (headersList.every(h => h[key] === value)) { + commonHeaders[key] = value + } } } - return headers + return commonHeaders }) - - this.mapRequestItem = options.mapRequestItem ?? (({ request, batchHeaders }) => { - const headers: StandardHeaders = {} - - for (const [key, value] of Object.entries(request.headers)) { - if (batchHeaders[key] !== value) { - headers[key] = value + this.mapSubrequest = options.mapSubrequest ?? (({ request }, { headers }) => { + const subHeaders = { ...request.headers } + for (const [key, value] of Object.entries(headers)) { + if (subHeaders[key] === value) { + subHeaders[key] = undefined } } return { - method: request.method, - url: request.url, - headers, - body: request.body, - signal: request.signal, + ...request, + headers: subHeaders, } }) - - this.exclude = options.exclude ?? (() => false) - } - - init(options: StandardLinkOptions): void { - options.clientInterceptors ??= [] - - options.clientInterceptors.push((options) => { - if (options.request.headers['x-orpc-batch'] !== '1') { - return options.next() - } - - return options.next({ - ...options, - request: { - ...options.request, - headers: { - ...options.request.headers, - 'x-orpc-batch': undefined, - }, + this.mapSubresponse = (subResponse, batchResponse) => { + return { + ...subResponse, + headers: { + ...batchResponse.headers, // low-priority + ...subResponse.headers, }, - }) - }) + } + } + } - options.clientInterceptors.push((options) => { + init(options: StandardLinkOptions): StandardLinkOptions { + const transportInterceptor: StandardLinkTransportInterceptor = async (interceptorOptions) => { + /** + * Only apply batching to requests with undefined or JSON-serializable bodies. + * Other body types are not suitable for batching. + */ if ( - this.exclude(options) - || options.request.body instanceof Blob - || options.request.body instanceof FormData - || isAsyncIteratorObject(options.request.body) - || options.request.signal?.aborted + interceptorOptions.request.body instanceof Blob + || interceptorOptions.request.body instanceof ReadableStream + || isAsyncIteratorObject(interceptorOptions.request.body) + || interceptorOptions.request.signal?.aborted + || !value(this.filter, interceptorOptions) ) { - return options.next() + return interceptorOptions.next() } - const group = this.groups.find(group => group.condition(options)) + const group = this.groups.find(group => value(group.condition, interceptorOptions)) if (!group) { - return options.next() + return interceptorOptions.next() } return new Promise((resolve, reject) => { - this.#enqueueRequest(group, options, resolve, reject) - defer(() => this.#processPendingBatches()) - }) - }) - } + const queue = this.queue.get(group) ?? [] + if (!this.queue.has(group)) { + this.queue.set(group, queue) + } - #enqueueRequest( - group: BatchLinkPluginGroup, - options: InterceptorOptions, Promise>, - resolve: (response: StandardLazyResponse) => void, - reject: (e: unknown) => void, - ): void { - const items = this.pending.get(group) - - if (items) { - items.push([options, resolve, reject]) + queue.push([interceptorOptions, resolve, reject]) + defer(() => this.processPendingBatches()) + }) } - else { - this.pending.set(group, [[options, resolve, reject]]) + + return { + ...options, + transportInterceptors: [...toArray(options.transportInterceptors), transportInterceptor], } } - async #processPendingBatches(): Promise { - const pending = this.pending - this.pending = new Map() + private async processPendingBatches(): Promise { + const pending = new Map(this.queue) + this.queue.clear() for (const [group, items] of pending) { const getItems = items.filter(([options]) => options.request.method === 'GET') const restItems = items.filter(([options]) => options.request.method !== 'GET') - this.#executeBatch('GET', group, getItems) - this.#executeBatch('POST', group, restItems) + this.executeBatch('GET', group, getItems) + this.executeBatch('POST', group, restItems) } } - async #executeBatch( + private async executeBatch( method: 'GET' | 'POST', group: BatchLinkPluginGroup, - groupItems: typeof this.pending extends Map ? U : never, + groupItems: typeof this.queue extends Map ? U : never, ): Promise { if (!groupItems.length) { return } - const batchItems = groupItems as [typeof groupItems[number], ...typeof groupItems[number][]] + if (groupItems.length === 1) { + const [options, resolve, reject] = groupItems[0]! + options.next().then(resolve).catch(reject) + return + } + + const subOptionsList = groupItems.map(([options]) => options) as [ + InterceptorOptions, Promise>, + ...InterceptorOptions, Promise>[], + ] + + const maxSize = await value(this.maxSize, subOptionsList) + if (groupItems.length > maxSize) { + const [first, second] = splitInHalf(groupItems) + + await Promise.all([ + this.executeBatch(method, group, first), + this.executeBatch(method, group, second), + ]) - if (batchItems.length === 1) { - batchItems[0][0].next().then(batchItems[0][1]).catch(batchItems[0][2]) return } - try { - const options = batchItems.map(([options]) => options) as [ - InterceptorOptions, Promise>, - ...InterceptorOptions, Promise>[], - ] + const url = await value(this.batchUrl, subOptionsList) + const headers = await value(this.batchHeaders, subOptionsList) + const mode = value(this.mode, subOptionsList) + let suppressErrorFromCurrentBatch = false + + const controller = new AbortController() + const pendingMessages: ClientPeerSendMessage[] = [] + let batchResponse: StandardLazyResponse - const maxSize = await value(this.maxSize, options) + const peer = new ClientPeer(async (message) => { + pendingMessages.push(message) - if (batchItems.length > maxSize) { - const [first, second] = splitInHalf(batchItems) - this.#executeBatch(method, group, first) - this.#executeBatch(method, group, second) - return + if (message.kind === 'cancel' && pendingMessages.filter(m => m.kind === 'cancel').length === groupItems.length) { + controller.abort() } - const batchUrl = new URL(await value(this.batchUrl, options)) - const batchHeaders = await value(this.batchHeaders, options) - const mappedItems = batchItems.map(([options]) => this.mapRequestItem({ ...options, batchUrl, batchHeaders })) + if (message.kind === 'request' && pendingMessages.filter(m => m.kind === 'request').length === groupItems.length) { + // DON'T await this to avoid blocking the peer's message sending process. + ;(async () => { + try { + const request: StandardRequest = { + url, + method, + headers: { ...headers, 'orpc-batch': mode }, + signal: controller.signal, + } - const batchRequest = toBatchRequest({ - method, - url: batchUrl, - headers: batchHeaders, - requests: mappedItems, - }) + if (method === 'GET') { + const [pathname, search, hash] = parseStandardUrl(url) + const dataParam = `data=${encodeURIComponent(stringifyJSON(pendingMessages))}` + const newUrl: StandardUrl = search + ? `${pathname}${search}&${dataParam}${hash ?? ''}` + : `${pathname}?${dataParam}${hash ?? ''}` - const maxUrlLength = await value(this.maxUrlLength, options) + const maxUrlLength = await value(this.maxUrlLength, subOptionsList) + if (newUrl.length > maxUrlLength) { + const [first, second] = splitInHalf(groupItems) + suppressErrorFromCurrentBatch = true - if (batchRequest.url.toString().length > maxUrlLength) { - const [first, second] = splitInHalf(batchItems) - this.#executeBatch(method, group, first) - this.#executeBatch(method, group, second) - return - } + await Promise.all([ + this.executeBatch(method, group, first), + this.executeBatch(method, group, second), + peer.close(), + ]) - const mode = value(this.mode, options) + return + } - try { - const lazyResponse = await options[0].next({ - request: { ...batchRequest, headers: { ...batchRequest.headers, 'x-orpc-batch': mode } }, - signal: batchRequest.signal, - context: group.context, - input: group.input, - path: toArray(group.path), - }) + request.url = newUrl + } + else { + request.body = pendingMessages + } - const parsed = parseBatchResponse({ ...lazyResponse, body: await lazyResponse.body() }) + batchResponse = await groupItems[0]![0]!.next({ + ...subOptionsList[0], + context: value(group.context, subOptionsList) as T, + path: value(group.path, subOptionsList) ?? [], + request, + signal: controller.signal, + }) - for await (const item of parsed) { - batchItems[item.index]?.[1]({ ...item, body: () => Promise.resolve(item.body) }) - } - } - catch (err) { - /** - * Throw individual request abort reasons when requests are aborted during batch processing. - * This allows users to check for aborted requests by comparing `error === signal.reason`. - */ - if (batchRequest.signal?.aborted && batchRequest.signal.reason === err) { - for (const [{ signal }, , reject] of batchItems) { - if (signal?.aborted) { - reject(signal.reason) + const body = await batchResponse.resolveBody() + + if (Array.isArray(body) && body.every(v => isServerPeerSendMessage(v))) { + for (const message of body) { + await peer.message(message) + } } + else if (body instanceof Blob) { + await decodeLengthPrefixedBlob(body, peer) + } + else if (body instanceof ReadableStream) { + await decodeLengthPrefixedStream(body, peer) + } + else { + throw new BatchLinkPluginError('Invalid batch response format.') + } + + await peer.close(new BatchLinkPluginError('Batch response is incomplete.')) + } + catch (error) { + await peer.close(error) + } + })() + } + }) + + groupItems.forEach(([subOptions, resolve, reject]) => { + peer + .request(this.mapSubrequest(subOptions, { url, headers })) + .then(subResponse => resolve(this.mapSubresponse(subResponse, batchResponse, subOptions))) + .catch((error) => { + if (!suppressErrorFromCurrentBatch) { + reject(error) } + }) + }) + } +} + +async function decodeLengthPrefixedBlob(blob: Blob, peer: ClientPeer): Promise { + const buffer = await loadBytes(blob) + let offset = 0 + + while (offset < buffer.length) { + if (offset + 4 > buffer.length) { + throw new BatchLinkPluginError('Invalid batch response: incomplete length header.') + } + + const view = new DataView(buffer.buffer, buffer.byteOffset + offset, 4) + const length = view.getUint32(0, false) + offset += 4 + + if (offset + length > buffer.length) { + throw new BatchLinkPluginError('Invalid batch response: incomplete message.') + } + + const messageBytes = buffer.subarray(offset, offset + length) + offset += length + + const result = decodePeerMessage(messageBytes) + if (!result.matched || !isServerPeerSendMessage(result.message)) { + throw new BatchLinkPluginError('Invalid batch response: invalid message.') + } + + await peer.message(result.message) + } +} + +async function decodeLengthPrefixedStream(stream: ReadableStream, peer: ClientPeer): Promise { + const reader = stream.getReader() + let buffer = new Uint8Array(0) + + try { + while (true) { + const { done, value: chunk } = await reader.read() + + if (chunk) { + const newBuffer = new Uint8Array(buffer.length + chunk.length) + newBuffer.set(buffer) + newBuffer.set(chunk, buffer.length) + buffer = newBuffer + } + + while (buffer.length >= 4) { + const view = new DataView(buffer.buffer, buffer.byteOffset, 4) + const length = view.getUint32(0, false) + + if (buffer.length < 4 + length) { + break } - throw err + const messageBytes = buffer.subarray(4, 4 + length) + buffer = buffer.subarray(4 + length) + + const result = decodePeerMessage(messageBytes) + + if (!result.matched || !isServerPeerSendMessage(result.message)) { + throw new BatchLinkPluginError('Invalid batch response: invalid message.') + } + + await peer.message(result.message) } - /** - * JS ignore the second resolve or reject so we don't need to check if has been resolved - */ - throw new Error('Something went wrong make batch response not contains enough responses. This can be a bug please report it.') - } - catch (error) { - /** - * JS ignore the second resolve or reject so we don't need to check if has been resolved - */ - for (const [, , reject] of batchItems) { - reject(error) + if (done) { + break } } } + finally { + reader.releaseLock() + } } diff --git a/packages/client/src/plugins/dedupe-requests.test.ts b/packages/client/src/plugins/dedupe-requests.test.ts deleted file mode 100644 index c5a3cb312..000000000 --- a/packages/client/src/plugins/dedupe-requests.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import type { StandardLazyResponse, StandardRequest } from '@orpc/standard-server' -import * as StandardServerModule from '@orpc/standard-server' -import * as StandardServerBatchModule from '@orpc/standard-server/batch' -import { StandardLink } from '../adapters/standard' -import { DedupeRequestsPlugin } from './dedupe-requests' - -const toBatchSignalSpy = vi.spyOn(StandardServerBatchModule, 'toBatchAbortSignal') -const replicateStandardLazyResponseSpy = vi.spyOn(StandardServerModule, 'replicateStandardLazyResponse') - -beforeEach(() => { - vi.resetAllMocks() -}) - -describe('dedupeRequestsPlugin', () => { - const signal1 = AbortSignal.timeout(1000) - const signal2 = AbortSignal.timeout(1000) - - const clientCall = vi.fn(async (request) => { - return { - status: 200, - headers: { - 'x-custom': '1', - }, - body: async () => ({ value: '__body__' }), - } satisfies StandardLazyResponse - }) - - const groupCondition = vi.fn(() => true) - - const encode = vi.fn(async (path, input, { signal }): Promise => ({ - url: new URL(`http://localhost/prefix/${path.slice(1).join('/')}`), - method: path[0] as any, - headers: { - bearer: '123', - path, - }, - body: input, - signal, - })) - - const decode = vi.fn(async (response): Promise => response.body()) - const filter = vi.fn(() => true) - - const link = new StandardLink({ encode, decode }, { call: clientCall }, { - plugins: [new DedupeRequestsPlugin({ - groups: [{ - condition: groupCondition, - context: { group: true } as any, - }], - filter, - })], - }) - - it.each(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])('dedupe requests with %s method', async (method) => { - const [output1, output2] = await Promise.all([ - link.call([method, 'foo'], '__foo__', { context: { foo1: true }, signal: signal1 }), - link.call([method, 'foo'], '__foo__', { context: { foo2: true }, signal: signal2 }), - ]) - - expect(output1).toEqual({ value: '__body__' }) - expect(output2).toEqual({ value: '__body__' }) - expect(output1).toBe(output2) - - expect(encode).toHaveBeenCalledTimes(2) - - expect(clientCall).toHaveBeenCalledTimes(1) - expect(clientCall).toHaveBeenCalledWith( - { - ...await encode.mock.results[0]!.value, - signal: expect.toSatisfy(signal => signal === toBatchSignalSpy.mock.results[0]!.value), - }, - { - context: { group: true }, - signal: expect.toSatisfy(signal => signal === toBatchSignalSpy.mock.results[0]!.value), - next: expect.any(Function), - }, - [ - method, - 'foo', - ], - '__foo__', - ) - - expect(toBatchSignalSpy).toHaveBeenCalledTimes(1) - expect(toBatchSignalSpy).toHaveBeenCalledWith([ - signal1, - signal2, - ]) - - expect(replicateStandardLazyResponseSpy).toHaveBeenCalledTimes(1) - expect(replicateStandardLazyResponseSpy).toHaveBeenCalledWith(await clientCall.mock.results[0]!.value, 2) - - expect(groupCondition).toHaveBeenCalledTimes(2) - expect(groupCondition).toHaveBeenNthCalledWith(1, expect.objectContaining({ - path: [method, 'foo'], - request: await encode.mock.results[0]!.value, - context: { foo1: true }, - })) - expect(groupCondition).toHaveBeenNthCalledWith(2, expect.objectContaining({ - path: [method, 'foo'], - request: await encode.mock.results[1]!.value, - context: { foo2: true }, - })) - }) - - it('dedupe requests and request throw error', async () => { - clientCall.mockRejectedValue(new Error('__error__')) - - const promise1 = link.call(['GET', 'foo'], '__foo__', { context: { foo1: true }, signal: signal1 }) - const promise2 = link.call(['GET', 'foo'], '__foo__', { context: { foo2: true }, signal: signal2 }) - - await expect(promise1).rejects.toThrow('__error__') - await expect(promise2).rejects.toThrow('__error__') - - expect(clientCall).toHaveBeenCalledTimes(1) - }) - - it.each([ - ['blob', new Blob(['test'], { type: 'text/plain' })], - ['formdata', new FormData()], - ['url-search-params', new URLSearchParams()], - ['event-iterator', (async function* () { }())], - ['different in body', { value: 1 }, { value: 2 }], - ])('not dedupe requests with %s body', async (name, body1, body2 = body1 as any) => { - const [output1, output2] = await Promise.all([ - link.call(['GET', 'foo'], body1, { context: { foo: true }, signal: signal1 }), - link.call(['GET', 'foo'], body2, { context: { foo: true }, signal: signal2 }), - ]) - - expect(output1).not.toBe(output2) - expect(encode).toHaveBeenCalledTimes(2) - expect(clientCall).toHaveBeenCalledTimes(2) - }) - - it('not dedupe if filter returns false', async () => { - filter.mockReturnValueOnce(false) - - const [output1, output2] = await Promise.all([ - link.call(['GET', 'foo'], '__foo__', { context: { foo1: true }, signal: signal1 }), - link.call(['GET', 'foo'], '__foo__', { context: { foo2: true }, signal: signal2 }), - ]) - - expect(output1).not.toBe(output2) - expect(encode).toHaveBeenCalledTimes(2) - expect(clientCall).toHaveBeenCalledTimes(2) - }) - - it('not dedupe if not group matches', async () => { - groupCondition.mockReturnValueOnce(false) - - const [output1, output2] = await Promise.all([ - link.call(['GET', 'foo'], '__foo__', { context: { foo1: true }, signal: signal1 }), - link.call(['GET', 'foo'], '__foo__', { context: { foo2: true }, signal: signal2 }), - ]) - - expect(output1).not.toBe(output2) - expect(encode).toHaveBeenCalledTimes(2) - expect(clientCall).toHaveBeenCalledTimes(2) - }) - - it('not dedupe if method is different', async () => { - const [output1, output2] = await Promise.all([ - link.call(['GET', 'foo'], '__foo__', { context: { foo1: true }, signal: signal1 }), - link.call(['POST', 'foo'], '__foo__', { context: { foo2: true }, signal: signal2 }), - ]) - - expect(output1).not.toBe(output2) - expect(encode).toHaveBeenCalledTimes(2) - expect(clientCall).toHaveBeenCalledTimes(2) - }) - - it('not dedupe if url is different', async () => { - const [output1, output2] = await Promise.all([ - link.call(['GET', 'foo1'], '__foo__', { context: { foo1: true }, signal: signal1 }), - link.call(['GET', 'foo2'], '__foo__', { context: { foo2: true }, signal: signal2 }), - ]) - - expect(output1).not.toBe(output2) - expect(encode).toHaveBeenCalledTimes(2) - expect(clientCall).toHaveBeenCalledTimes(2) - }) - - it('partial dedupe requests', async () => { - const [output1, output2, output3] = await Promise.all([ - link.call(['GET', 'foo'], '__foo__', { context: { foo1: true }, signal: signal1 }), - link.call(['GET', 'foo'], '__foo__', { context: { foo2: true }, signal: signal2 }), - link.call(['POST', 'foo'], '__foo__', { context: { foo2: true }, signal: signal2 }), - ]) - - expect(output1).toBe(output2) - expect(output2).not.toBe(output3) - expect(encode).toHaveBeenCalledTimes(3) - expect(clientCall).toHaveBeenCalledTimes(2) - }) -}) diff --git a/packages/client/src/plugins/dedupe-requests.ts b/packages/client/src/plugins/dedupe-requests.ts deleted file mode 100644 index 86cc0a4fa..000000000 --- a/packages/client/src/plugins/dedupe-requests.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { InterceptorOptions } from '@orpc/shared' -import type { StandardLazyResponse, StandardRequest } from '@orpc/standard-server' -import type { StandardLinkClientInterceptorOptions, StandardLinkOptions, StandardLinkPlugin } from '../adapters/standard' -import type { ClientContext } from '../types' -import { defer, isAsyncIteratorObject, stringifyJSON } from '@orpc/shared' -import { replicateStandardLazyResponse } from '@orpc/standard-server' -import { toBatchAbortSignal } from '@orpc/standard-server/batch' - -type RequestResolver = (response: StandardLazyResponse) => void -type RequestRejector = (e: unknown) => void - -export interface DedupeRequestsPluginGroup { - condition(options: StandardLinkClientInterceptorOptions): boolean - /** - * The context used for the rest of the request lifecycle. - */ - context: T -} - -export interface DedupeRequestsPluginOptions { - /** - * To enable deduplication, a request must match at least one defined group. - * Requests that fall into the same group are considered for deduplication together. - */ - groups: readonly [DedupeRequestsPluginGroup, ...DedupeRequestsPluginGroup[]] - - /** - * Filters requests to dedupe - * - * @default (({ request }) => request.method === 'GET') - */ - filter?: (options: StandardLinkClientInterceptorOptions) => boolean -} - -/** - * Prevents duplicate requests by deduplicating similar ones to reduce server load. - * - * @see {@link https://orpc.dev/docs/plugins/dedupe-requests Dedupe Requests Plugin} - */ -export class DedupeRequestsPlugin implements StandardLinkPlugin { - readonly #groups: Exclude['groups'], undefined> - readonly #filter: Exclude['filter'], undefined> - - order = 4_000_000 // make sure execute before batch plugin - - readonly #queue: Map< - DedupeRequestsPluginGroup, - { - options: InterceptorOptions, Promise> - signals: (AbortSignal | undefined)[] - resolves: RequestResolver[] - rejects: RequestRejector[] - }[] - > = new Map() - - constructor(options: NoInfer>) { - this.#groups = options.groups - this.#filter = options.filter ?? (({ request }) => request.method === 'GET') - } - - init(options: StandardLinkOptions): void { - options.clientInterceptors ??= [] - - options.clientInterceptors.push((options) => { - if ( - options.request.body instanceof Blob - || options.request.body instanceof FormData - || options.request.body instanceof URLSearchParams - || isAsyncIteratorObject(options.request.body) - || !this.#filter(options) - ) { - return options.next() - } - - const group = this.#groups.find(group => group.condition(options)) - - if (!group) { - return options.next() - } - - return new Promise((resolve, reject) => { - this.#enqueue(group, options, resolve, reject) - defer(() => this.#dequeue()) - }) - }) - } - - #enqueue( - group: DedupeRequestsPluginGroup, - options: InterceptorOptions, Promise>, - resolve: RequestResolver, - reject: RequestRejector, - ): void { - let queue = this.#queue.get(group) - - if (!queue) { - this.#queue.set(group, queue = []) - } - - const matched = queue.find((item) => { - const requestString1 = stringifyJSON({ - body: item.options.request.body, - headers: item.options.request.headers, - method: item.options.request.method, - url: item.options.request.url, - } satisfies Omit) - - const requestString2 = stringifyJSON({ - body: options.request.body, - headers: options.request.headers, - method: options.request.method, - url: options.request.url, - } satisfies Omit) - - return requestString1 === requestString2 - }) - - if (matched) { - matched.signals.push(options.request.signal) - matched.resolves.push(resolve) - matched.rejects.push(reject) - } - else { - queue.push({ - options, - signals: [options.request.signal], - resolves: [resolve], - rejects: [reject], - }) - } - } - - async #dequeue(): Promise { - const promises: Promise[] = [] - - for (const [group, items] of this.#queue) { - for (const { options, signals, resolves, rejects } of items) { - promises.push( - this.#execute(group, options, signals, resolves, rejects), - ) - } - } - - this.#queue.clear() - await Promise.all(promises) - } - - async #execute( - group: DedupeRequestsPluginGroup, - options: InterceptorOptions, Promise>, - signals: (AbortSignal | undefined)[], - resolves: RequestResolver[], - rejects: RequestRejector[], - ): Promise { - try { - const dedupedRequest: StandardRequest = { - ...options.request, - signal: toBatchAbortSignal(signals), - } - - const response = await options.next({ - ...options, - request: dedupedRequest, - signal: dedupedRequest.signal, - context: group.context, - }) - - const replicatedResponses = replicateStandardLazyResponse(response, resolves.length) - - for (const resolve of resolves) { - resolve(replicatedResponses.shift()!) - } - } - catch (error) { - for (const reject of rejects) { - reject(error) - } - } - } -} diff --git a/packages/client/src/plugins/dedupe.test.ts b/packages/client/src/plugins/dedupe.test.ts new file mode 100644 index 000000000..a8b36e4d6 --- /dev/null +++ b/packages/client/src/plugins/dedupe.test.ts @@ -0,0 +1,424 @@ +import type { StandardLazyResponse, StandardRequest } from '@standardserver/core' +import type { StandardLinkCodec, StandardLinkTransport } from '../adapters/standard' +import * as SharedExperimentalV2Module from '@orpc/shared' +import { StandardLink } from '../adapters/standard' +import { DedupeLinkPlugin } from './dedupe' + +interface TestContext { + group?: boolean + tag?: string +} + +function makeCodec(): StandardLinkCodec { + return { + encodeInput: vi.fn(async (input, path, { signal }) => ({ + method: path[0] as StandardRequest['method'], + url: `/${path.slice(1).join('/')}` as `/${string}`, + headers: { + authorization: 'bearer 123', + path: path.join('/'), + }, + body: input, + signal, + } satisfies StandardRequest)), + decodeResponse: vi.fn(async (response) => { + const body = await response.resolveBody() + return { kind: 'output' as const, output: body } + }), + } +} + +function makeTransport( + resolveBody: StandardLazyResponse['resolveBody'] = vi.fn(async () => ({ value: '__body__' })), +): StandardLinkTransport { + return { + send: vi.fn(async () => ({ + status: 200, + headers: { + 'x-custom': '1', + }, + resolveBody, + } satisfies StandardLazyResponse)), + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('dedupeLinkPlugin', () => { + const allAbortSignalSpy = vi.spyOn(SharedExperimentalV2Module, 'allAbortSignal') + + it('dedupes identical requests and reuses the resolved body', async () => { + const signal1 = AbortSignal.timeout(1000) + const signal2 = AbortSignal.timeout(1000) + const codec = makeCodec() + const resolveBody = vi.fn(async () => ({ value: '__body__' })) + const transport = makeTransport(resolveBody) + const groupCondition = vi.fn(() => true) + + const link = new StandardLink(codec, transport, { + plugins: [new DedupeLinkPlugin({ + groups: [{ + condition: groupCondition, + context: { group: true }, + }], + })], + }) + + const [output1, output2] = await Promise.all([ + link.call(['GET', 'planet'], { value: 1 }, { context: { tag: 'first' }, signal: signal1 }), + link.call(['GET', 'planet'], { value: 1 }, { context: { tag: 'second' }, signal: signal2 }), + ]) + + expect(output1).toEqual({ value: '__body__' }) + expect(output2).toEqual({ value: '__body__' }) + expect(output1).toBe(output2) + + expect(codec.encodeInput).toHaveBeenCalledTimes(2) + expect(transport.send).toHaveBeenCalledTimes(1) + expect(resolveBody).toHaveBeenCalledTimes(1) + + const [request, path, callOptions] = vi.mocked(transport.send).mock.calls[0]! + + expect(request).toEqual({ + ...await vi.mocked(codec.encodeInput).mock.results[0]!.value, + signal: allAbortSignalSpy.mock.results[0]!.value, + }) + expect(path).toEqual(['GET', 'planet']) + expect(callOptions).toMatchObject({ + context: { group: true }, + signal: allAbortSignalSpy.mock.results[0]!.value, + }) + expect((callOptions as any).next).toEqual(expect.any(Function)) + + expect(allAbortSignalSpy).toHaveBeenCalledTimes(1) + expect(allAbortSignalSpy).toHaveBeenCalledWith([signal1, signal2]) + + expect(groupCondition).toHaveBeenCalledTimes(2) + expect(groupCondition).toHaveBeenNthCalledWith(1, expect.objectContaining({ + path: ['GET', 'planet'], + request: await vi.mocked(codec.encodeInput).mock.results[0]!.value, + context: { tag: 'first' }, + })) + expect(groupCondition).toHaveBeenNthCalledWith(2, expect.objectContaining({ + path: ['GET', 'planet'], + request: await vi.mocked(codec.encodeInput).mock.results[1]!.value, + context: { tag: 'second' }, + })) + }) + + it('computes group context from all deduped matching options', async () => { + const codec = makeCodec() + const transport = makeTransport() + const context = vi.fn((items: [ + { context: TestContext }, + ...{ context: TestContext }[], + ]) => ({ + group: true, + tag: items.map(item => item.context.tag).join(','), + })) + + const link = new StandardLink(codec, transport, { + plugins: [new DedupeLinkPlugin({ + groups: [{ + condition: () => true, + context, + }], + })], + }) + + await Promise.all([ + link.call(['GET', 'planet'], { value: 1 }, { context: { tag: 'first' } }), + link.call(['GET', 'planet'], { value: 1 }, { context: { tag: 'second' } }), + ]) + + expect(context).toHaveBeenCalledTimes(1) + expect(context).toHaveBeenCalledWith([ + expect.objectContaining({ context: { tag: 'first' } }), + expect.objectContaining({ context: { tag: 'second' } }), + ]) + + const [, , callOptions] = vi.mocked(transport.send).mock.calls[0]! + + expect(callOptions).toMatchObject({ + context: { group: true, tag: 'first,second' }, + }) + }) + + it('passes through single matching requests without applying dedupe context', async () => { + const signal = AbortSignal.timeout(1000) + const codec = makeCodec() + const transport = makeTransport() + const context = vi.fn(() => ({ + group: true, + tag: 'deduped', + })) + + const link = new StandardLink(codec, transport, { + plugins: [new DedupeLinkPlugin({ + groups: [{ + condition: () => true, + context, + }], + })], + }) + + await link.call(['GET', 'planet'], { value: 1 }, { context: { tag: 'single' }, signal }) + + expect(context).not.toHaveBeenCalled() + expect(transport.send).toHaveBeenCalledTimes(1) + + const [request, path, callOptions] = vi.mocked(transport.send).mock.calls[0]! + + expect(request).toEqual(await vi.mocked(codec.encodeInput).mock.results[0]!.value) + expect(path).toEqual(['GET', 'planet']) + expect(callOptions).toMatchObject({ + context: { tag: 'single' }, + signal, + }) + expect((callOptions as any).next).toEqual(expect.any(Function)) + }) + + it('replicates async iterator response bodies for deduped requests', async () => { + const codec = makeCodec() + const iteratorFactory = vi.fn(async function* () { + yield 'first' + yield 'second' + }) + const resolveBody = vi.fn(async () => iteratorFactory()) + const transport = makeTransport(resolveBody) + + const link = new StandardLink(codec, transport, { + plugins: [new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + })], + }) + + const [output1, output2] = await Promise.all([ + link.call(['GET', 'planet'], { value: 1 }, { context: {} }), + link.call(['GET', 'planet'], { value: 1 }, { context: {} }), + ]) + + await expect(readAllAsync(output1 as AsyncIterable)).resolves.toEqual(['first', 'second']) + await expect(readAllAsync(output2 as AsyncIterable)).resolves.toEqual(['first', 'second']) + expect(resolveBody).toHaveBeenCalledTimes(1) + }) + + it('replicates readable stream response bodies for deduped requests', async () => { + const codec = makeCodec() + const resolveBody = vi.fn(async () => new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2])) + controller.enqueue(new Uint8Array([3, 4])) + controller.close() + }, + })) + const transport = makeTransport(resolveBody) + + const link = new StandardLink(codec, transport, { + plugins: [new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + })], + }) + + const [output1, output2] = await Promise.all([ + link.call(['GET', 'planet'], { value: 1 }, { context: {} }), + link.call(['GET', 'planet'], { value: 1 }, { context: {} }), + ]) + + await expect(readAllStream(output1 as ReadableStream)).resolves.toEqual([ + new Uint8Array([1, 2]), + new Uint8Array([3, 4]), + ]) + await expect(readAllStream(output2 as ReadableStream)).resolves.toEqual([ + new Uint8Array([1, 2]), + new Uint8Array([3, 4]), + ]) + expect(resolveBody).toHaveBeenCalledTimes(1) + }) + + it('reuses the resolved body for repeated reads of the same replicated response', async () => { + const codec: StandardLinkCodec = { + ...makeCodec(), + decodeResponse: vi.fn(async (response) => { + const firstBody = await response.resolveBody() + const secondBody = await response.resolveBody() + + expect(secondBody).toBe(firstBody) + + return { + kind: 'output' as const, + output: secondBody, + } + }), + } + const resolveBody = vi.fn(async () => new ReadableStream({ start(controller) { + controller.enqueue(new Uint8Array([1, 2])) + controller.enqueue(new Uint8Array([3, 4])) + controller.close() + } })) + const transport = makeTransport(resolveBody) + + const link = new StandardLink(codec, transport, { + plugins: [new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + })], + }) + + const [output1, output2] = await Promise.all([ + link.call(['GET', 'planet'], { value: 1 }, { context: {} }), + link.call(['GET', 'planet'], { value: 1 }, { context: {} }), + ]) + + await expect(readAllStream(output1 as ReadableStream)).resolves.toEqual([ + new Uint8Array([1, 2]), + new Uint8Array([3, 4]), + ]) + await expect(readAllStream(output2 as ReadableStream)).resolves.toEqual([ + new Uint8Array([1, 2]), + new Uint8Array([3, 4]), + ]) + expect(resolveBody).toHaveBeenCalledTimes(1) + }) + + it('dedupes non-GET requests when filter allows them', async () => { + const codec = makeCodec() + const transport = makeTransport() + + const link = new StandardLink(codec, transport, { + plugins: [new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + filter: () => true, + })], + }) + + const [output1, output2] = await Promise.all([ + link.call(['POST', 'planet'], { value: 1 }, { context: {} }), + link.call(['POST', 'planet'], { value: 1 }, { context: {} }), + ]) + + expect(output1).toBe(output2) + expect(transport.send).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['blob', new Blob(['test'])], + ['form-data', new FormData()], + ['url-search-params', new URLSearchParams('a=1')], + ['readable-stream', new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1])) + controller.close() + }, + })], + ['async-iterator', (async function* () { yield 'chunk' }())], + ])('passes through unsupported %s bodies', async (_name, body) => { + const codec = makeCodec() + const transport = makeTransport() + + const link = new StandardLink(codec, transport, { + plugins: [new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + filter: () => true, + })], + }) + + await Promise.all([ + link.call(['POST', 'upload'], body, { context: {} }), + link.call(['POST', 'upload'], body, { context: {} }), + ]) + + expect(transport.send).toHaveBeenCalledTimes(2) + }) + + it('passes through when the request is already aborted', async () => { + const controller = new AbortController() + controller.abort() + + const codec = makeCodec() + const transport = makeTransport() + + const link = new StandardLink(codec, transport, { + plugins: [new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + })], + }) + + await Promise.all([ + link.call(['GET', 'planet'], { value: 1 }, { context: {}, signal: controller.signal }), + link.call(['GET', 'planet'], { value: 1 }, { context: {}, signal: controller.signal }), + ]) + + expect(transport.send).toHaveBeenCalledTimes(2) + }) + + it('rejects all callers when the request fails', async () => { + const codec = makeCodec() + const transport = makeTransport() + + vi.mocked(transport.send).mockRejectedValue(new Error('FAIL')) + + const link = new StandardLink(codec, transport, { + plugins: [new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + })], + }) + + const promise1 = link.call(['GET', 'planet'], { value: 1 }, { context: {} }) + const promise2 = link.call(['GET', 'planet'], { value: 1 }, { context: {} }) + + await expect(promise1).rejects.toThrow('FAIL') + await expect(promise2).rejects.toThrow('FAIL') + expect(transport.send).toHaveBeenCalledTimes(1) + }) + + it('does not dedupe when no group matches', async () => { + const codec = makeCodec() + const transport = makeTransport() + + const link = new StandardLink(codec, transport, { + plugins: [new DedupeLinkPlugin({ + groups: [{ condition: () => false, context: { group: true } }], + })], + }) + + const [output1, output2] = await Promise.all([ + link.call(['GET', 'planet'], { value: 1 }, { context: {} }), + link.call(['GET', 'planet'], { value: 1 }, { context: {} }), + ]) + + expect(output1).not.toBe(output2) + expect(transport.send).toHaveBeenCalledTimes(2) + }) +}) + +async function readAllAsync(iterator: AsyncIterable): Promise { + const values: T[] = [] + + for await (const value of iterator) { + values.push(value) + } + + return values +} + +async function readAllStream(stream: ReadableStream): Promise { + const reader = stream.getReader() + const values: T[] = [] + + try { + while (true) { + const result = await reader.read() + + if (result.done) { + return values + } + + values.push(result.value) + } + } + finally { + reader.releaseLock() + } +} diff --git a/packages/client/src/plugins/dedupe.ts b/packages/client/src/plugins/dedupe.ts new file mode 100644 index 000000000..c7628c3c0 --- /dev/null +++ b/packages/client/src/plugins/dedupe.ts @@ -0,0 +1,244 @@ +import type { InterceptorOptions, Value } from '@orpc/shared' +import type { StandardBody, StandardLazyResponse, StandardRequest } from '@standardserver/core' +import type { StandardLinkOptions, StandardLinkPlugin, StandardLinkTransportInterceptor, StandardLinkTransportInterceptorOptions } from '../adapters/standard' +import type { ClientContext } from '../types' +import { allAbortSignal, defer, isAsyncIteratorObject, replicateAsyncIterator, replicateReadableStream, stringifyJSON, toArray, value } from '@orpc/shared' + +export interface DedupeLinkPluginGroup { + condition: Value]> + /** + * The context used for the rest of the request lifecycle. + */ + context: Value, + StandardLinkTransportInterceptorOptions, + ...StandardLinkTransportInterceptorOptions[], + ]]> +} + +export interface DedupeLinkPluginOptions { + /** + * To enable deduplication, a request must match at least one defined group. + * Requests that fall into the same group are considered for deduplication together. + */ + groups: [DedupeLinkPluginGroup, ...DedupeLinkPluginGroup[]] + + /** + * Filters requests to dedupe. + * + * @default ({ request }) => request.method === 'GET' + */ + filter?: Value]> +} + +export class DedupeLinkPlugin implements StandardLinkPlugin { + name = '~dedupe' + before = ['~batch'] + + private readonly groups: DedupeLinkPluginOptions['groups'] + private readonly filter: Exclude['filter'], undefined> + + private readonly queue: Map, PendingDedupeRequest[]> = new Map() + + constructor(options: NoInfer>) { + this.groups = options.groups + this.filter = options.filter ?? (({ request }) => request.method === 'GET') + } + + init(options: StandardLinkOptions): StandardLinkOptions { + const transportInterceptor: StandardLinkTransportInterceptor = (interceptorOptions) => { + if (!canDedupeRequest(interceptorOptions.request) || !value(this.filter, interceptorOptions)) { + return interceptorOptions.next() + } + + const group = this.groups.find(group => value(group.condition, interceptorOptions)) + + if (!group) { + return interceptorOptions.next() + } + + return new Promise((resolve, reject) => { + this.enqueue(group, interceptorOptions, resolve, reject) + + defer(() => { + this.processPendingRequests() + }) + }) + } + + return { + ...options, + transportInterceptors: [...toArray(options.transportInterceptors), transportInterceptor], + } + } + + private enqueue( + group: DedupeLinkPluginGroup, + options: InterceptorOptions, Promise>, + resolve: (response: StandardLazyResponse) => void, + reject: (error: unknown) => void, + ): void { + let queue = this.queue.get(group) + + if (!queue) { + queue = [] + this.queue.set(group, queue) + } + + const requestKey = createRequestKey(options.path, options.request) + const matched = queue.find(item => item.requestKey === requestKey) + + if (matched) { + matched.matchedOptions.push(options) + matched.signals.push(options.request.signal) + matched.resolves.push(resolve) + matched.rejects.push(reject) + return + } + + queue.push({ + requestKey, + options, + matchedOptions: [options], + signals: [options.request.signal], + resolves: [resolve], + rejects: [reject], + }) + } + + private async processPendingRequests(): Promise { + const pending = new Map(this.queue) + this.queue.clear() + + const executions: Promise[] = [] + + for (const [group, items] of pending) { + for (const item of items) { + executions.push(this.execute(group, item)) + } + } + + await Promise.all(executions) + } + + private async execute( + group: DedupeLinkPluginGroup, + item: PendingDedupeRequest, + ): Promise { + try { + if (!shouldDedupe(item.matchedOptions)) { + const response = await item.options.next(item.options) + item.resolves[0]?.(response) + return + } + + const context = value(group.context, item.matchedOptions) as T + + const request: StandardRequest = { + ...item.options.request, + signal: allAbortSignal(item.signals), + } + + const response = await item.options.next({ + ...item.options, + request, + signal: request.signal, + context, + }) + + const replicatedResponses = replicateLazyResponse(response, item.resolves.length) + + for (const resolve of item.resolves) { + resolve(replicatedResponses.pop()!) + } + } + catch (error) { + for (const reject of item.rejects) { + reject(error) + } + } + } +} + +type PendingDedupeRequest = { + requestKey: string + options: InterceptorOptions, Promise> + matchedOptions: [ + StandardLinkTransportInterceptorOptions, + ...StandardLinkTransportInterceptorOptions[], + ] + signals: (AbortSignal | undefined)[] + resolves: ((response: StandardLazyResponse) => void)[] + rejects: ((error: unknown) => void)[] +} + +function canDedupeRequest(request: StandardRequest): boolean { + return !( + request.body instanceof Blob + || request.body instanceof FormData + || request.body instanceof URLSearchParams + || request.body instanceof ReadableStream + || isAsyncIteratorObject(request.body) + || request.signal?.aborted + ) +} + +function createRequestKey(path: string[], request: StandardRequest): string { + return stringifyJSON({ + path, + body: request.body, + headers: request.headers, + method: request.method, + url: request.url, + } satisfies Omit & { path: string[] }) +} + +function replicateLazyResponse(response: StandardLazyResponse, count: number): StandardLazyResponse[] { + const replicated: StandardLazyResponse[] = [] + + let bodyPromise: Promise | undefined + let replicatedAsyncIterators: StandardBody[] | undefined + let replicatedReadableStream: ReadableStream[] | undefined + + for (let i = 0; i < count; i++) { + let resolvedBody: { body: StandardBody } | undefined + + replicated.push({ + ...response, + resolveBody: async (hint) => { + if (resolvedBody) { + return resolvedBody.body + } + + bodyPromise ??= response.resolveBody(hint) + const body = await bodyPromise + + if (isAsyncIteratorObject(body)) { + replicatedAsyncIterators ??= replicateAsyncIterator(body, count) + resolvedBody = { body: replicatedAsyncIterators.pop() } + } + else if (body instanceof ReadableStream) { + replicatedReadableStream ??= replicateReadableStream(body, count) + resolvedBody = { body: replicatedReadableStream.pop() } + } + else { + resolvedBody = { body } + } + + return resolvedBody.body + }, + }) + } + + return replicated +} + +function shouldDedupe( + items: StandardLinkTransportInterceptorOptions[], +): items is [ + StandardLinkTransportInterceptorOptions, + StandardLinkTransportInterceptorOptions, + ...StandardLinkTransportInterceptorOptions[], +] { + return items.length >= 2 +} diff --git a/packages/client/src/plugins/index.test.ts b/packages/client/src/plugins/index.test.ts new file mode 100644 index 000000000..b2446d807 --- /dev/null +++ b/packages/client/src/plugins/index.test.ts @@ -0,0 +1,8 @@ +it('exports BatchLinkPlugin, BatchLinkPluginError, DedupeLinkPlugin, RetryLinkPlugin, RetryAfterLinkPlugin', async () => { + await expect(import('./index')).resolves.toMatchObject({ + BatchLinkPlugin: expect.any(Function), + DedupeLinkPlugin: expect.any(Function), + RetryLinkPlugin: expect.any(Function), + RetryAfterLinkPlugin: expect.any(Function), + }) +}) diff --git a/packages/client/src/plugins/index.ts b/packages/client/src/plugins/index.ts index cadc80e6d..b4e54dc03 100644 --- a/packages/client/src/plugins/index.ts +++ b/packages/client/src/plugins/index.ts @@ -1,5 +1,4 @@ export * from './batch' -export * from './dedupe-requests' +export * from './dedupe' export * from './retry' export * from './retry-after' -export * from './simple-csrf-protection' diff --git a/packages/client/src/plugins/retry-after.test.ts b/packages/client/src/plugins/retry-after.test.ts index 1809990b1..1fca95c1f 100644 --- a/packages/client/src/plugins/retry-after.test.ts +++ b/packages/client/src/plugins/retry-after.test.ts @@ -1,58 +1,75 @@ -import type { StandardLazyResponse } from '@orpc/standard-server' +import type { StandardLazyResponse, StandardRequest } from '@standardserver/core' +import type { StandardLinkCodec, StandardLinkTransport } from '../adapters/standard' import { StandardLink } from '../adapters/standard' -import { RetryAfterPlugin } from './retry-after' - -describe('retryAfterPlugin', () => { - const signal1 = AbortSignal.timeout(10000) - - const encode = vi.fn(async (path, input, { signal }): Promise => ({ - url: new URL(`http://localhost/${path.join('/')}`), - method: 'GET', - headers: {}, - body: input, - signal, - })) - - const decode = vi.fn(async (response): Promise => response.body()) - - beforeEach(() => { - vi.clearAllMocks() - vi.useFakeTimers() - }) +import { RetryAfterLinkPlugin } from './retry-after' + +function makeCodec(): StandardLinkCodec { + return { + encodeInput: vi.fn(async () => ({ + method: 'POST', + url: '/test', + headers: {}, + body: undefined, + } satisfies StandardRequest)), + decodeResponse: vi.fn(async response => ({ + kind: 'output' as const, + output: await response.resolveBody(), + })), + } +} + +function makeTransport(): StandardLinkTransport { + return { + send: vi.fn(async () => ({ + status: 200, + headers: {}, + resolveBody: async () => 'success', + } satisfies StandardLazyResponse)), + } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() +}) - afterEach(() => { - expect(vi.getTimerCount()).toBe(0) - vi.useRealTimers() - }) +afterEach(() => { + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() +}) +describe('retryAfterLinkPlugin', () => { describe('core behavior', () => { - it('should retry on 429/503 with retry-after header and succeed', async () => { + it.each([429, 503])('should retry on %i with retry-after header and succeed', async (status) => { + const codec = makeCodec() + const transport = makeTransport() + let callCount = 0 - const clientCall = vi.fn(async () => { + vi.mocked(transport.send).mockImplementation(async () => { callCount++ if (callCount === 1) { return { - status: 429, + status, headers: { 'retry-after': '2' }, - body: async () => 'rate limited', + resolveBody: async () => 'rate limited', } satisfies StandardLazyResponse } return { status: 200, headers: {}, - body: async () => 'success', + resolveBody: async () => 'success', } satisfies StandardLazyResponse }) - const link = new StandardLink({ encode, decode }, { call: clientCall }, { - plugins: [new RetryAfterPlugin()], + const link = new StandardLink(codec, transport, { + plugins: [new RetryAfterLinkPlugin()], }) - const promise = link.call(['test'], 'input', { context: {}, signal: signal1 }) + const promise = link.call(['test'], 'input', { context: {} }) await vi.runAllTimersAsync() expect(await promise).toBe('success') - expect(clientCall).toHaveBeenCalledTimes(2) + expect(transport.send).toHaveBeenCalledTimes(2) }) it('should not retry without retry-after header or on non-retryable status', async () => { @@ -63,215 +80,201 @@ describe('retryAfterPlugin', () => { ] for (const { status, headers, body } of testCases) { - const clientCall = vi.fn(async () => ({ + const codec = makeCodec() + const transport = makeTransport() + + vi.mocked(transport.send).mockResolvedValue({ status, headers, - body: async () => body, - } satisfies StandardLazyResponse)) + resolveBody: async () => body, + } satisfies StandardLazyResponse) - const link = new StandardLink({ encode, decode }, { call: clientCall }, { - plugins: [new RetryAfterPlugin()], + const link = new StandardLink(codec, transport, { + plugins: [new RetryAfterLinkPlugin()], }) - const result = await link.call(['test'], 'input', { context: {}, signal: signal1 }) + const result = await link.call(['test'], 'input', { context: {} }) expect(result).toBe(body) - expect(clientCall).toHaveBeenCalledTimes(1) + expect(transport.send).toHaveBeenCalledTimes(1) vi.clearAllMocks() } }) }) describe('retry-after parsing', () => { - it('should parse various retry-after formats', async () => { - const testCases = [ - { value: '3', description: 'seconds' }, - { value: new Date(Date.now() + 5000).toUTCString(), description: 'HTTP date' }, - { value: ' 2 ', description: 'whitespace' }, - ] + it.each([ + { value: '3', description: 'seconds' }, + { value: new Date(Date.now() + 5000).toUTCString(), description: 'HTTP date' }, + { value: ' 2 ', description: 'whitespace' }, + ])('should parse various retry-after formats: %s', async ({ value }) => { + const codec = makeCodec() + const transport = makeTransport() - for (const { value, description } of testCases) { - let callCount = 0 - const clientCall = vi.fn(async () => { - callCount++ - if (callCount === 1) { - return { - status: 429, - headers: { 'retry-after': value }, - body: async () => 'rate limited', - } satisfies StandardLazyResponse - } + let callCount = 0 + vi.mocked(transport.send).mockImplementation(async () => { + callCount++ + if (callCount === 1) { return { - status: 200, - headers: {}, - body: async () => 'success', + status: 429, + headers: { 'retry-after': value }, + resolveBody: async () => 'rate limited', } satisfies StandardLazyResponse - }) + } + return { + status: 200, + headers: {}, + resolveBody: async () => 'success', + } satisfies StandardLazyResponse + }) - const link = new StandardLink({ encode, decode }, { call: clientCall }, { - plugins: [new RetryAfterPlugin()], - }) + const link = new StandardLink(codec, transport, { + plugins: [new RetryAfterLinkPlugin()], + }) - const promise = link.call(['test'], 'input', { context: {}, signal: signal1 }) - await vi.runAllTimersAsync() + const promise = link.call(['test'], 'input', { context: {} }) + await vi.runAllTimersAsync() - expect(await promise).toBe('success') - expect(clientCall).toHaveBeenCalledTimes(2) - vi.clearAllMocks() - } + expect(await promise).toBe('success') + expect(transport.send).toHaveBeenCalledTimes(2) + vi.clearAllMocks() }) it('should not retry on invalid retry-after values', async () => { const invalidValues = ['invalid', ''] - for (const value of invalidValues) { - const clientCall = vi.fn(async () => ({ + for (const val of invalidValues) { + const codec = makeCodec() + const transport = makeTransport() + + vi.mocked(transport.send).mockResolvedValue({ status: 429, - headers: { 'retry-after': value }, - body: async () => 'rate limited', - } satisfies StandardLazyResponse)) + headers: { 'retry-after': val }, + resolveBody: async () => 'rate limited', + } satisfies StandardLazyResponse) - const link = new StandardLink({ encode, decode }, { call: clientCall }, { - plugins: [new RetryAfterPlugin()], + const link = new StandardLink(codec, transport, { + plugins: [new RetryAfterLinkPlugin()], }) - const result = await link.call(['test'], 'input', { context: {}, signal: signal1 }) + const result = await link.call(['test'], 'input', { context: {} }) expect(result).toBe('rate limited') - expect(clientCall).toHaveBeenCalledTimes(1) + expect(transport.send).toHaveBeenCalledTimes(1) vi.clearAllMocks() } }) }) - describe('maxAttempts', () => { - it('should respect maxAttempts (static and dynamic)', async () => { - const testCases = [ - { maxAttempts: undefined, expected: 3, description: 'default' }, - { maxAttempts: 5, expected: 5, description: 'custom' }, - { maxAttempts: vi.fn(() => 2), expected: 2, description: 'dynamic' }, - ] + it('should respect maxAttempts option', async () => { + const codec = makeCodec() + const transport = makeTransport() - for (const { maxAttempts, expected, description } of testCases) { - const clientCall = vi.fn(async () => ({ - status: 429, - headers: { 'retry-after': '0' }, - body: async () => 'rate limited', - } satisfies StandardLazyResponse)) - - const link = new StandardLink({ encode, decode }, { call: clientCall }, { - plugins: [new RetryAfterPlugin(maxAttempts !== undefined ? { maxAttempts } : {})], - }) + const maxAttempts = vi.fn(() => 2) - const promise = link.call(['test'], 'input', { context: {}, signal: signal1 }) - await vi.runAllTimersAsync() + vi.mocked(transport.send).mockResolvedValue({ + status: 429, + headers: { 'retry-after': '0' }, + resolveBody: async () => 'rate limited', + } satisfies StandardLazyResponse) - expect(await promise).toBe('rate limited') - expect(clientCall).toHaveBeenCalledTimes(expected) + const link = new StandardLink(codec, transport, { + plugins: [new RetryAfterLinkPlugin({ maxAttempts })], + }) - if (typeof maxAttempts === 'function') { - expect(maxAttempts).toHaveBeenCalledWith( - expect.objectContaining({ status: 429 }), - expect.objectContaining({ context: {} }), - ) - } + const promise = link.call(['test'], 'input', { context: { context: true } }) + await vi.runAllTimersAsync() - vi.clearAllMocks() - } - }) + expect(await promise).toBe('rate limited') + expect(transport.send).toHaveBeenCalledTimes(2) + expect(maxAttempts).toHaveBeenCalledWith( + expect.objectContaining({ status: 429 }), + expect.objectContaining({ context: { context: true } }), + ) }) describe('timeout and custom condition', () => { it('should stop retrying after timeout', async () => { - const clientCall = vi.fn(async () => ({ - status: 429, - headers: { 'retry-after': '3' }, - body: async () => 'rate limited', - } satisfies StandardLazyResponse)) - - const link = new StandardLink({ encode, decode }, { call: clientCall }, { - plugins: [new RetryAfterPlugin({ timeout: 5000 })], - }) - - const promise = link.call(['test'], 'input', { context: {}, signal: signal1 }) - await vi.runAllTimersAsync() + const codec = makeCodec() + const transport = makeTransport() - expect(await promise).toBe('rate limited') - expect(clientCall).toHaveBeenCalledTimes(2) - }) - - it('should support dynamic timeout function', async () => { - const clientCall = vi.fn(async () => ({ + vi.mocked(transport.send).mockResolvedValue({ status: 429, headers: { 'retry-after': '2' }, - body: async () => 'rate limited', - } satisfies StandardLazyResponse)) + resolveBody: async () => 'rate limited', + } satisfies StandardLazyResponse) const timeoutFn = vi.fn(() => 3000) - const link = new StandardLink({ encode, decode }, { call: clientCall }, { - plugins: [new RetryAfterPlugin({ timeout: timeoutFn })], + const link = new StandardLink(codec, transport, { + plugins: [new RetryAfterLinkPlugin({ timeout: timeoutFn })], }) - const promise = link.call(['test'], 'input', { context: {}, signal: signal1 }) + const promise = link.call(['test'], 'input', { context: {} }) await vi.runAllTimersAsync() expect(await promise).toBe('rate limited') - expect(clientCall).toHaveBeenCalledTimes(2) + expect(transport.send).toHaveBeenCalledTimes(2) expect(timeoutFn).toHaveBeenCalledWith( expect.objectContaining({ status: 429 }), - expect.objectContaining({ context: {}, signal: signal1 }), + expect.objectContaining({ context: {} }), ) }) - it('should respect custom condition function', async () => { + it('should respect custom condition', async () => { + const codec = makeCodec() + const transport = makeTransport() + let callCount = 0 - const clientCall = vi.fn(async () => { + vi.mocked(transport.send).mockImplementation(async () => { callCount++ if (callCount === 1) { return { status: 400, headers: { 'retry-after': '1' }, - body: async () => 'bad request', + resolveBody: async () => 'bad request', } satisfies StandardLazyResponse } return { status: 200, headers: {}, - body: async () => 'success', + resolveBody: async () => 'success', } satisfies StandardLazyResponse }) const condition = vi.fn((response: StandardLazyResponse) => response.status === 400) - const link = new StandardLink({ encode, decode }, { call: clientCall }, { - plugins: [new RetryAfterPlugin({ condition })], + const link = new StandardLink(codec, transport, { + plugins: [new RetryAfterLinkPlugin({ condition })], }) - const promise = link.call(['test'], 'input', { context: {}, signal: signal1 }) + const promise = link.call(['test'], 'input', { context: {} }) await vi.runAllTimersAsync() expect(await promise).toBe('success') - expect(clientCall).toHaveBeenCalledTimes(2) + expect(transport.send).toHaveBeenCalledTimes(2) expect(condition).toHaveBeenCalledWith( expect.objectContaining({ status: 400 }), - expect.objectContaining({ context: {}, signal: signal1 }), + expect.objectContaining({ context: {} }), ) }) }) describe('signal handling', () => { it('should stop retrying when signal is aborted during delay', async () => { - const clientCall = vi.fn(async () => ({ + const codec = makeCodec() + const transport = makeTransport() + + vi.mocked(transport.send).mockResolvedValue({ status: 429, headers: { 'retry-after': '5' }, - body: async () => 'rate limited', - } satisfies StandardLazyResponse)) + resolveBody: async () => 'rate limited', + } satisfies StandardLazyResponse) const controller = new AbortController() - const link = new StandardLink({ encode, decode }, { call: clientCall }, { - plugins: [new RetryAfterPlugin()], + const link = new StandardLink(codec, transport, { + plugins: [new RetryAfterLinkPlugin()], }) const promise = link.call(['test'], 'input', { context: {}, signal: controller.signal }) @@ -281,27 +284,30 @@ describe('retryAfterPlugin', () => { await vi.advanceTimersByTimeAsync(3000) expect(await promise).toBe('rate limited') - expect(clientCall).toHaveBeenCalledTimes(1) + expect(transport.send).toHaveBeenCalledTimes(1) }) it('should not retry if signal is already aborted', async () => { - const clientCall = vi.fn(async () => ({ + const codec = makeCodec() + const transport = makeTransport() + + vi.mocked(transport.send).mockResolvedValue({ status: 429, headers: { 'retry-after': '1' }, - body: async () => 'rate limited', - } satisfies StandardLazyResponse)) + resolveBody: async () => 'rate limited', + } satisfies StandardLazyResponse) const controller = new AbortController() controller.abort() - const link = new StandardLink({ encode, decode }, { call: clientCall }, { - plugins: [new RetryAfterPlugin()], + const link = new StandardLink(codec, transport, { + plugins: [new RetryAfterLinkPlugin()], }) const result = await link.call(['test'], 'input', { context: {}, signal: controller.signal }) expect(result).toBe('rate limited') - expect(clientCall).toHaveBeenCalledTimes(1) + expect(transport.send).toHaveBeenCalledTimes(1) }) }) }) diff --git a/packages/client/src/plugins/retry-after.ts b/packages/client/src/plugins/retry-after.ts index f6f80539d..fdf487596 100644 --- a/packages/client/src/plugins/retry-after.ts +++ b/packages/client/src/plugins/retry-after.ts @@ -1,12 +1,12 @@ import type { Value } from '@orpc/shared' -import type { StandardLazyResponse } from '@orpc/standard-server' -import type { StandardLinkClientInterceptorOptions, StandardLinkOptions, StandardLinkPlugin } from '../adapters/standard' +import type { StandardLazyResponse } from '@standardserver/core' +import type { StandardLinkOptions, StandardLinkPlugin, StandardLinkTransportInterceptor, StandardLinkTransportInterceptorOptions } from '../adapters/standard' import type { ClientContext } from '../types' -import { value } from '@orpc/shared' -import { flattenHeader } from '@orpc/standard-server' -import { COMMON_ORPC_ERROR_DEFS } from '../error' +import { sleep, toArray, value } from '@orpc/shared' +import { flattenStandardHeader } from '@standardserver/core' +import { COMMON_ERROR_STATUS_MAP } from '../error' -export interface RetryAfterPluginOptions { +export interface RetryAfterLinkPluginOptions { /** * Override condition to determine whether to retry or not. * @@ -14,7 +14,7 @@ export interface RetryAfterPluginOptions { */ condition?: Value, + options: StandardLinkTransportInterceptorOptions, ]> /** @@ -24,7 +24,7 @@ export interface RetryAfterPluginOptions { */ maxAttempts?: Value, + options: StandardLinkTransportInterceptorOptions, ]> /** @@ -34,38 +34,36 @@ export interface RetryAfterPluginOptions { */ timeout?: Value, + options: StandardLinkTransportInterceptorOptions, ]> } /** - * The Retry After Plugin automatically retries requests based on server `Retry-After` headers. + * The Retry After Link Plugin automatically retries requests based on server `retry-after` header. * This is particularly useful for handling rate limiting and temporary server unavailability. * * @see {@link https://orpc.dev/docs/plugins/retry-after Retry After Plugin Docs} */ -export class RetryAfterPlugin implements StandardLinkPlugin { - private readonly condition: Exclude['condition'], undefined> - private readonly maxAttempts: Exclude['maxAttempts'], undefined> - private readonly timeout: Exclude['timeout'], undefined> +export class RetryAfterLinkPlugin implements StandardLinkPlugin { + private readonly condition: Exclude['condition'], undefined> + private readonly maxAttempts: Exclude['maxAttempts'], undefined> + private readonly timeout: Exclude['timeout'], undefined> - order = 1_900_000 + name = '~retry-after' - constructor(options: RetryAfterPluginOptions = {}) { + constructor(options: RetryAfterLinkPluginOptions = {}) { this.condition = options.condition ?? ( response => - response.status === COMMON_ORPC_ERROR_DEFS.TOO_MANY_REQUESTS.status - || response.status === COMMON_ORPC_ERROR_DEFS.SERVICE_UNAVAILABLE.status + response.status === COMMON_ERROR_STATUS_MAP.TOO_MANY_REQUESTS + || response.status === COMMON_ERROR_STATUS_MAP.SERVICE_UNAVAILABLE ) this.maxAttempts = options.maxAttempts ?? 3 this.timeout = options.timeout ?? 5 * 60 * 1000 // 5 minutes } - init(options: StandardLinkOptions): void { - options.clientInterceptors ??= [] - - options.clientInterceptors.push(async (interceptorOptions) => { + init(options: StandardLinkOptions): StandardLinkOptions { + const interceptor: StandardLinkTransportInterceptor = async (interceptorOptions) => { const startTime = Date.now() let attemptCount = 0 @@ -78,8 +76,8 @@ export class RetryAfterPlugin implements StandardLinkPl return response } - const retryAfterHeader = flattenHeader(response.headers['retry-after']) - const retryAfterMs = this.parseRetryAfterHeader(retryAfterHeader) + const retryAfterHeader = flattenStandardHeader(response.headers['retry-after']) + const retryAfterMs = parseRetryAfterHeader(retryAfterHeader) if (retryAfterMs === undefined) { return response } @@ -94,54 +92,39 @@ export class RetryAfterPlugin implements StandardLinkPl return response } - await this.delayExecution(retryAfterMs, interceptorOptions.signal) + try { + await sleep(retryAfterMs, { signal: interceptorOptions.signal }) + } + catch { + // can throw if the signal is aborted while sleeping + } + if (interceptorOptions.signal?.aborted) { return response } } - }) - } - - private parseRetryAfterHeader(value: string | undefined): number | undefined { - value = value?.trim() - - if (!value) { - return undefined } - const seconds = Number(value) - if (Number.isFinite(seconds)) { - return Math.max(0, seconds * 1000) - } + return { ...options, transportInterceptors: [interceptor, ...toArray(options.transportInterceptors)] } + } +} - const retryDate = Date.parse(value) - if (!Number.isNaN(retryDate)) { - return Math.max(0, retryDate - Date.now()) - } +function parseRetryAfterHeader(value: string | undefined): number | undefined { + value = value?.trim() + if (!value) { return undefined } - private delayExecution(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve) => { - if (signal?.aborted) { - resolve() - return - } - - let timeout: ReturnType | undefined - const onAbort = () => { - clearTimeout(timeout) - timeout = undefined - resolve() - } - - signal?.addEventListener('abort', onAbort, { once: true }) + const seconds = Number(value) + if (Number.isFinite(seconds)) { + return Math.max(0, seconds * 1000) + } - timeout = setTimeout(() => { - signal?.removeEventListener('abort', onAbort) - resolve() - }, ms) - }) + const retryDate = Date.parse(value) + if (!Number.isNaN(retryDate)) { + return Math.max(0, retryDate - Date.now()) } + + return undefined } diff --git a/packages/client/src/plugins/retry.test.ts b/packages/client/src/plugins/retry.test.ts index c294d865f..676e52447 100644 --- a/packages/client/src/plugins/retry.test.ts +++ b/packages/client/src/plugins/retry.test.ts @@ -1,557 +1,416 @@ -import type { RouterClient } from '../../../server/src/router-client' -import type { ClientRetryPluginContext } from './retry' -import * as Shared from '@orpc/shared' -import { getEventMeta, withEventMeta } from '@orpc/standard-server' -import { RPCHandler } from '../../../server/src/adapters/fetch/rpc-handler' -import { os } from '../../../server/src/builder' -import { RPCLink } from '../adapters/fetch' -import { createORPCClient } from '../client' -import { ORPCError } from '../error' -import { ClientRetryPlugin } from './retry' - -const overlayProxySpy = vi.spyOn(Shared, 'overlayProxy') +import type { StandardLazyResponse, StandardRequest } from '@standardserver/core' +import type { StandardLinkCodec, StandardLinkTransport } from '../adapters/standard' +import type { RetryLinkPluginContext } from './retry' +import { withEventMeta } from '@standardserver/core' +import { StandardLink } from '../adapters/standard' +import { RetryLinkPlugin, RetryLinkPluginInvalidEventIteratorRetryResponse } from './retry' + +interface TestContext extends RetryLinkPluginContext { + tag?: string +} -interface ORPCClientContext extends ClientRetryPluginContext { +function makeCodec(): StandardLinkCodec { + return { + encodeInput: vi.fn(async () => ({ + method: 'POST', + url: '/test', + headers: {}, + body: undefined, + } satisfies StandardRequest)), + decodeResponse: vi.fn(), + } +} +function makeTransport(): StandardLinkTransport { + return { + send: vi.fn(async () => ({ + status: 200, + headers: {}, + resolveBody: async () => undefined, + } satisfies StandardLazyResponse)), + } } beforeEach(() => { vi.clearAllMocks() + vi.useRealTimers() }) -describe('clientRetryPlugin', () => { - const handlerFn = vi.fn() - - const router = os.handler(handlerFn) - - const handler = new RPCHandler(router) - - const link = new RPCLink({ - url: 'http://localhost:3000', - fetch: async (request) => { - if (request.signal?.aborted === true) { - // fake real fetch abort behavior - throw new Error('AbortError') - } - - const { response } = await handler.handle(request) - return response ?? new Response('fail', { status: 500 }) - }, - plugins: [ - new ClientRetryPlugin(), - ], - }) +describe('retryLinkPlugin', () => { + it('does not retry by default', async () => { + const codec = makeCodec() + const transport = makeTransport() - const client: RouterClient = createORPCClient(link) + vi.mocked(codec.decodeResponse).mockRejectedValue(new Error('FAIL')) - it('should not retry by default', async () => { - handlerFn.mockRejectedValueOnce(new Error('fail')) + const link = new StandardLink(codec, transport, { + plugins: [new RetryLinkPlugin()], + }) - await expect(client('hello')).rejects.toThrow('Internal server error') + await expect(link.call(['planet', 'create'], { name: 'Earth' }, { context: {} })).rejects.toThrow('FAIL') - expect(handlerFn).toHaveBeenCalledTimes(1) + expect(codec.decodeResponse).toHaveBeenCalledTimes(1) }) - it('should retry', async () => { - handlerFn.mockRejectedValue(new Error('fail')) - - const retry = vi.fn(() => 3) + it('retries until max attempts and then throws', async () => { + const codec = makeCodec() + const transport = makeTransport() - await expect(client('hello', { context: { retry, retryDelay: 0 } })).rejects.toThrow('Internal server error') + vi.mocked(codec.decodeResponse).mockRejectedValue(new Error('FAIL')) - expect(handlerFn).toHaveBeenCalledTimes(4) - expect(retry).toHaveBeenCalledTimes(1) - expect(retry).toHaveBeenCalledWith(expect.objectContaining({ context: { retry, retryDelay: 0 }, path: [], input: 'hello' })) - }) - - it('should not retry if success', async () => { - handlerFn.mockResolvedValue('success') + const link = new StandardLink(codec, transport, { + plugins: [new RetryLinkPlugin()], + }) - const output = await client('hello', { context: { retry: 3, retryDelay: 0 } }) + await expect(link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 1, retryDelay: 0 } })).rejects.toThrow('FAIL') - expect(output).toBe('success') - expect(handlerFn).toHaveBeenCalledTimes(1) - expect(handlerFn).toHaveBeenCalledWith(expect.objectContaining({ input: 'hello' })) + expect(codec.decodeResponse).toHaveBeenCalledTimes(2) }) - it('should retry with delay', { retry: 5 }, async () => { - handlerFn.mockRejectedValue(new Error('fail')) + it('respects shouldRetry=false', async () => { + const codec = makeCodec() + const transport = makeTransport() - const start = Date.now() - await expect(client('hello', { context: { retry: 4, retryDelay: 50 } })).rejects.toThrow('Internal server error') + vi.mocked(codec.decodeResponse).mockRejectedValue(new Error('FAIL')) - expect(Date.now() - start).toBeGreaterThanOrEqual(200) - expect(Date.now() - start).toBeLessThanOrEqual(249) + const shouldRetry = vi.fn(async () => false) - expect(handlerFn).toHaveBeenCalledTimes(5) - }) - - it('should not retry if shouldRetry=false', { retry: 5 }, async () => { - handlerFn.mockRejectedValue(new Error('fail')) - - let times = 0 - const shouldRetry = vi.fn(() => { - times++ - - return times < 2 + const link = new StandardLink(codec, transport, { + plugins: [new RetryLinkPlugin()], }) - await expect(client('hello', { context: { retry: 3, shouldRetry, retryDelay: 0 } })).rejects.toThrow('Internal server error') - - expect(handlerFn).toHaveBeenCalledTimes(2) - - expect(shouldRetry).toHaveBeenCalledTimes(2) - expect(shouldRetry).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - attemptIndex: 0, - error: expect.any(ORPCError), - context: { retry: 3, shouldRetry, retryDelay: 0 }, - input: 'hello', - path: [], - }), - ) + await expect(link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 5, retryDelay: 0, shouldRetry } })).rejects.toThrow('FAIL') - expect(shouldRetry).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - attemptIndex: 1, - error: expect.any(ORPCError), - context: { retry: 3, shouldRetry, retryDelay: 0 }, - input: 'hello', - path: [], - }), - ) + expect(shouldRetry).toHaveBeenCalledTimes(1) + expect(codec.decodeResponse).toHaveBeenCalledTimes(1) }) - it('onRetry', async () => { + it('calls onRetry cleanup with success/failure state', async () => { + const codec = makeCodec() + const transport = makeTransport() + let count = 0 - handlerFn.mockImplementation(() => { + vi.mocked(codec.decodeResponse).mockImplementation(async () => { count++ - if (count === 4) { - return 'success' + if (count < 3) { + throw new Error(`FAIL_${count}`) } - throw new Error('fail') + return { kind: 'output', output: 'OK' } }) const clean = vi.fn() const onRetry = vi.fn(() => clean) - await expect(client('hello', { context: { retry: 3, retryDelay: 0, onRetry } })).resolves.toEqual('success') - - expect(handlerFn).toHaveBeenCalledTimes(4) + const link = new StandardLink(codec, transport, { + plugins: [new RetryLinkPlugin()], + }) - expect(onRetry).toHaveBeenCalledTimes(3) - expect(onRetry).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - attemptIndex: 0, - error: expect.any(ORPCError), - context: { retry: 3, retryDelay: 0, onRetry }, - input: 'hello', - path: [], - }), - ) - expect(onRetry).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - attemptIndex: 1, - error: expect.any(ORPCError), - context: { retry: 3, retryDelay: 0, onRetry }, - input: 'hello', - path: [], - }), - ) - expect(onRetry).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - attemptIndex: 2, - error: expect.any(ORPCError), - context: { retry: 3, retryDelay: 0, onRetry }, - input: 'hello', - path: [], - }), - ) + await expect(link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 3, retryDelay: 0, onRetry } })).resolves.toBe('OK') - expect(clean).toHaveBeenCalledTimes(3) + expect(onRetry).toHaveBeenCalledTimes(2) + expect(clean).toHaveBeenCalledTimes(2) expect(clean).toHaveBeenNthCalledWith(1, false) - expect(clean).toHaveBeenNthCalledWith(2, false) - expect(clean).toHaveBeenNthCalledWith(3, true) + expect(clean).toHaveBeenNthCalledWith(2, true) }) - it('should not retry if signal aborted', async () => { - handlerFn.mockRejectedValue(new Error('fail')) + it('does not retry when signal is aborted', async () => { + const codec = makeCodec() + const transport = makeTransport() const controller = new AbortController() - controller.abort() - await expect(client('hello', { context: { retry: 3, retryDelay: 0 }, signal: controller.signal })).rejects.toThrow('AbortError') - - expect(handlerFn).toHaveBeenCalledTimes(0) - }) - - describe('event iterator', () => { - it('should not retry by default', async () => { - handlerFn.mockImplementation(async function* () { - throw new Error('fail') - }) - - const iterator = await client('hello') + vi.mocked(transport.send).mockRejectedValue(new Error('AbortError')) - await expect(iterator.next()).rejects.toThrow('Internal server error') - - expect(handlerFn).toHaveBeenCalledTimes(1) - expect(overlayProxySpy).toHaveBeenCalledTimes(1) + const link = new StandardLink(codec, transport, { + plugins: [new RetryLinkPlugin()], }) - it('should retry', async () => { - handlerFn.mockImplementation(async function* () { - throw new Error('fail') - }) + await expect(link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 3, retryDelay: 0 }, signal: controller.signal })).rejects.toThrow('AbortError') - const iterator = await client('hello', { context: { retry: 3, retryDelay: 0 } }) + expect(transport.send).toHaveBeenCalledTimes(1) + }) - await expect(iterator.next()).rejects.toThrow('Internal server error') + it('uses constructor defaults', async () => { + const codec = makeCodec() + const transport = makeTransport() - expect(handlerFn).toHaveBeenCalledTimes(4) - expect(overlayProxySpy).toHaveBeenCalledTimes(5) // handler 4, plugin 1 - expect(overlayProxySpy).toHaveBeenNthCalledWith(2, expect.any(Function), expect.any(Shared.AsyncIteratorClass)) - expect(iterator).toBe(overlayProxySpy.mock.results[1]?.value) - }) + vi.mocked(codec.decodeResponse) + .mockRejectedValueOnce(new Error('FAIL_1')) + .mockResolvedValueOnce({ kind: 'output', output: 'OK' }) - it('should not retry if success', async () => { - handlerFn.mockImplementation(async function* () { - yield 1 - yield withEventMeta({ order: 2 }, { id: '5' }) - return withEventMeta({ order: 3 }, { retry: 6000 }) - }) + const link = new StandardLink(codec, transport, { + plugins: [new RetryLinkPlugin({ default: { retry: 1, retryDelay: 0 } })], + }) - const iterator = await client('hello', { context: { retry: 3, retryDelay: 0 } }) + await expect(link.call(['planet', 'create'], { name: 'Earth' }, { context: {} })).resolves.toBe('OK') - expect(await iterator.next()).toSatisfy(({ done, value }) => { - expect(done).toEqual(false) - expect(value).toEqual(1) - return true - }) + expect(codec.decodeResponse).toHaveBeenCalledTimes(2) + }) - expect(await iterator.next()).toSatisfy(({ done, value }) => { - expect(done).toEqual(false) - expect(value).toEqual({ order: 2 }) - expect(getEventMeta(value)).toMatchObject({ id: '5' }) - return true - }) + it('uses default retryDelay fallback when lastEventRetry is undefined', async () => { + vi.useFakeTimers() - expect(await iterator.next()).toSatisfy(({ done, value }) => { - expect(done).toEqual(true) - expect(value).toEqual({ order: 3 }) - expect(getEventMeta(value)).toMatchObject({ retry: 6000 }) - return true - }) + const codec = makeCodec() + const transport = makeTransport() - expect(await iterator.next()).toSatisfy(({ done, value }) => { - expect(done).toEqual(true) - expect(value).toEqual(undefined) - return true - }) + vi.mocked(codec.decodeResponse) + .mockRejectedValueOnce(new Error('FAIL_1')) + .mockResolvedValueOnce({ kind: 'output', output: 'OK' }) - expect(handlerFn).toHaveBeenCalledTimes(1) + const link = new StandardLink(codec, transport, { + plugins: [new RetryLinkPlugin({ default: { retry: 1 } })], }) - it('should retry with meta data', async () => { - handlerFn.mockImplementation(async function* () { - yield 1 - yield withEventMeta({ order: 2 }, { id: '5', retry: 5678 }) - throw new Error('fail') - }) + const callPromise = link.call(['planet', 'create'], { name: 'Earth' }, { context: {} }) - const shouldRetry = vi.fn(() => true) + await vi.advanceTimersByTimeAsync(1999) + await Promise.resolve() + expect(codec.decodeResponse).toHaveBeenCalledTimes(1) - const iterator = await client('hello', { context: { retry: 3, retryDelay: 0, shouldRetry }, lastEventId: '1' }) + await vi.advanceTimersByTimeAsync(1) + await expect(callPromise).resolves.toBe('OK') + expect(codec.decodeResponse).toHaveBeenCalledTimes(2) + }) - expect(await iterator.next()).toSatisfy(({ done, value }) => { - expect(done).toEqual(false) - expect(value).toEqual(1) - return true - }) + it('does not wait full retry delay when signal is aborted mid-delay', async () => { + vi.useFakeTimers() - expect(await iterator.next()).toSatisfy(({ done, value }) => { - expect(done).toEqual(false) - expect(value).toEqual({ order: 2 }) - expect(getEventMeta(value)).toMatchObject({ id: '5', retry: 5678 }) - return true - }) + const codec = makeCodec() + const transport = makeTransport() - expect(await iterator.next()).toSatisfy(({ done, value }) => { - expect(done).toEqual(false) - expect(value).toEqual(1) - return true - }) + vi.mocked(codec.decodeResponse).mockRejectedValue(new Error('FAIL')) - expect(handlerFn).toHaveBeenCalledTimes(2) - expect(handlerFn).toHaveBeenNthCalledWith(1, expect.objectContaining({ input: 'hello', lastEventId: '1' })) - expect(handlerFn).toHaveBeenNthCalledWith(2, expect.objectContaining({ input: 'hello', lastEventId: '5' })) + const controller = new AbortController() - expect(shouldRetry).toHaveBeenCalledTimes(1) - expect(shouldRetry).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - error: expect.any(Error), - lastEventId: '5', - lastEventRetry: 5678, - input: 'hello', - path: [], - }), - ) + const link = new StandardLink(codec, transport, { + plugins: [new RetryLinkPlugin()], }) - it('should retry with meta in error', async () => { - handlerFn.mockImplementation(async function* () { - yield 1 - yield { order: 2 } - throw withEventMeta(new Error('fail'), { id: '10', retry: 1234 }) - }) + const clean = vi.fn() + const onRetry = vi.fn(() => clean) - const shouldRetry = vi.fn(() => true) + const callPromise = link.call( + ['planet', 'create'], + { name: 'Earth' }, + { context: { retry: 3, retryDelay: 5000, onRetry }, signal: controller.signal }, + ) - const iterator = await client('hello', { context: { retry: 1, retryDelay: 0, shouldRetry }, lastEventId: '1' }) + await vi.advanceTimersByTimeAsync(1) + controller.abort() - expect(await iterator.next()).toSatisfy(({ done, value }) => { - expect(done).toEqual(false) - expect(value).toEqual(1) - return true - }) + await expect(callPromise).rejects.toThrow() - expect(await iterator.next()).toSatisfy(({ done, value }) => { - expect(done).toEqual(false) - expect(value).toEqual({ order: 2 }) - return true - }) + expect(codec.decodeResponse).toHaveBeenCalledTimes(1) - expect(await iterator.next()).toSatisfy(({ done, value }) => { - expect(done).toEqual(false) - expect(value).toEqual(1) - return true - }) + expect(onRetry).toHaveBeenCalledTimes(1) + expect(clean).toHaveBeenCalledTimes(1) + expect(clean).toHaveBeenCalledWith(false) + }) - expect(await iterator.next()).toSatisfy(({ done, value }) => { - expect(done).toEqual(false) - expect(value).toEqual({ order: 2 }) - return true - }) + describe('event iterator', () => { + it('retries event iterator and forwards lastEventId from metadata', async () => { + const codec = makeCodec() + const transport = makeTransport() + + let callCount = 0 + vi.mocked(codec.decodeResponse).mockImplementation(async () => { + callCount++ + + if (callCount === 1) { + return { + kind: 'output', + output: (async function* () { + yield withEventMeta({ phase: 'first' }, { id: 'evt-1', retry: 0 }) + throw withEventMeta(new Error('ITER_FAIL'), { id: 'evt-2', retry: 0 }) + })(), + } + } - await expect(iterator.next()).rejects.toSatisfy((error) => { - expect(error).toBeInstanceOf(ORPCError) - expect(getEventMeta(error)).toMatchObject({ id: '10', retry: 1234 }) - return true + return { + kind: 'output', + output: (async function* () { + yield { phase: 'second' } + })(), + } }) - expect(handlerFn).toHaveBeenCalledTimes(2) - expect(handlerFn).toHaveBeenNthCalledWith(1, expect.objectContaining({ input: 'hello', lastEventId: '1' })) - expect(handlerFn).toHaveBeenNthCalledWith(2, expect.objectContaining({ input: 'hello', lastEventId: '10' })) - - expect(shouldRetry).toHaveBeenCalledTimes(1) - expect(shouldRetry).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - error: expect.any(Error), - lastEventId: '10', - lastEventRetry: 1234, - input: 'hello', - path: [], - }), - ) - }) + const shouldRetry = vi.fn(() => true) - it('should retry with delay', { retry: 5 }, async () => { - handlerFn.mockImplementation(async function* () { - throw new Error('fail') + const link = new StandardLink(codec, transport, { + plugins: [new RetryLinkPlugin()], }) - const start = Date.now() - const iterator = await client('hello', { context: { retry: 4, retryDelay: 50 } }) + const iterator = await link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 1, shouldRetry }, lastEventId: 'init-id' }) as AsyncIterator - await expect(iterator.next()).rejects.toThrow('Internal server error') + await expect(iterator.next()).resolves.toEqual({ done: false, value: { phase: 'first' } }) + await expect(iterator.next()).resolves.toEqual({ done: false, value: { phase: 'second' } }) - expect(Date.now() - start).toBeGreaterThanOrEqual(200) - expect(Date.now() - start).toBeLessThanOrEqual(249) + expect(vi.mocked(codec.encodeInput).mock.calls[0]?.[2]).toMatchObject({ lastEventId: 'init-id' }) + expect(vi.mocked(codec.encodeInput).mock.calls[1]?.[2]).toMatchObject({ lastEventId: 'evt-2' }) - expect(handlerFn).toHaveBeenCalledTimes(5) + expect(shouldRetry).toHaveBeenCalledTimes(1) + expect(shouldRetry).toHaveBeenCalledWith(expect.objectContaining({ lastEventRetry: 0 })) }) - it('should not retry if shouldRetry=false', { retry: 5 }, async () => { - handlerFn.mockImplementation(async function* () { - throw new Error('fail') - }) - - let times = 0 - const shouldRetry = vi.fn(() => { - times++ + it('throws when retry response is not an event iterator', async () => { + const codec = makeCodec() + const transport = makeTransport() - return times < 2 - }) + let callCount = 0 + vi.mocked(codec.decodeResponse).mockImplementation(async () => { + callCount++ - const iterator = await client('hello', { context: { retry: 3, shouldRetry, retryDelay: 0 } }) - - await expect(iterator.next()).rejects.toThrow('Internal server error') - - expect(handlerFn).toHaveBeenCalledTimes(2) - - expect(shouldRetry).toHaveBeenCalledTimes(2) - expect(shouldRetry).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - attemptIndex: 0, - error: expect.any(ORPCError), - context: { retry: 3, shouldRetry, retryDelay: 0 }, - input: 'hello', - path: [], - }), - ) - expect(shouldRetry).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - attemptIndex: 1, - error: expect.any(ORPCError), - context: { retry: 3, shouldRetry, retryDelay: 0 }, - input: 'hello', - path: [], - }), - ) - }) + if (callCount === 1) { + return { + kind: 'output', + output: (async function* () { + throw new Error('ITER_FAIL') + })(), + } + } - it('onRetry', async () => { - let time = 0 - handlerFn.mockImplementation(async function* () { - throw withEventMeta(new Error('fail'), { id: `${time++}` }) + return { kind: 'output', output: 'NOT_ITERATOR' } }) - const clean = vi.fn() - const onRetry = vi.fn(() => clean) - - const iterator = await client('hello', { context: { retry: 3, retryDelay: 0, onRetry } }) - - await expect(iterator.next()).rejects.toThrow('Internal server error') - - expect(handlerFn).toHaveBeenCalledTimes(4) - - expect(onRetry).toHaveBeenCalledTimes(3) - expect(onRetry).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - attemptIndex: 0, - error: expect.any(ORPCError), - lastEventId: '0', - context: { retry: 3, retryDelay: 0, onRetry }, - input: 'hello', - path: [], - }), - ) - expect(onRetry).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - attemptIndex: 1, - error: expect.any(ORPCError), - lastEventId: '1', - context: { retry: 3, retryDelay: 0, onRetry }, - input: 'hello', - path: [], - }), - ) - expect(onRetry).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - attemptIndex: 2, - error: expect.any(ORPCError), - lastEventId: '2', - context: { retry: 3, retryDelay: 0, onRetry }, - input: 'hello', - path: [], - }), - ) - - expect(clean).toHaveBeenCalledTimes(3) - }) - - it('should not retry if signal aborted', async () => { - handlerFn.mockImplementation(async function* () { - throw new Error('fail') + const link = new StandardLink(codec, transport, { + plugins: [new RetryLinkPlugin()], }) - const controller = new AbortController() - - controller.abort() - - await expect(client('hello', { context: { retry: 3, retryDelay: 0 }, signal: controller.signal })).rejects.toThrow('AbortError') + const iterator = await link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 1, retryDelay: 0 } }) as AsyncIterator - expect(handlerFn).toHaveBeenCalledTimes(0) + await expect(iterator.next()).rejects.toBeInstanceOf(RetryLinkPluginInvalidEventIteratorRetryResponse) }) - it('throw right away if retry invalid event iterator response', async () => { - let times = 0 - handlerFn.mockImplementation(async () => { - times++ + it('support manually cleanup', async () => { + const codec = makeCodec() + const transport = makeTransport() - if (times === 2) { - return 'not-an-event-iterator' - } + const cleanup = vi.fn() - return (async function* () { - throw new Error('fail') - })() + vi.mocked(codec.decodeResponse).mockResolvedValue({ + kind: 'output', + output: (async function* () { + try { + yield 1 + yield 2 + } + finally { + cleanup() + } + })(), + }) + + const link = new StandardLink(codec, transport, { + plugins: [new RetryLinkPlugin()], }) - const iterator = await client('hello', { context: { retry: 3, retryDelay: 0 } }) + const iterator = await link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 1, retryDelay: 0 } }) as AsyncIterator - await expect(iterator.next()).rejects.toThrow('RetryPlugin: Expected an Event Iterator, got a non-Event Iterator') + await iterator.next() + await iterator.return?.() - expect(handlerFn).toHaveBeenCalledTimes(2) + expect(cleanup).toHaveBeenCalledTimes(1) }) - it('manually .return still works', async () => { + it('automatically cleanup retried iterator when cleanup during retry', async () => { + vi.useFakeTimers() + + const codec = makeCodec() + const transport = makeTransport() + const cleanup = vi.fn() - handlerFn.mockImplementation(async function* () { - try { - while (true) { - yield 1 + const retriedReturn = vi.fn(async () => ({ done: true as const, value: undefined })) + + let callCount = 0 + vi.mocked(codec.decodeResponse).mockImplementation(async () => { + callCount++ + + if (callCount === 1) { + return { + kind: 'output', + output: (async function* () { + try { + throw new Error('ITER_FAIL') + } + finally { + cleanup() + } + })(), } } - finally { - cleanup() + + return { + kind: 'output', + output: { + async next() { + return { done: false as const, value: 'RETRIED' } + }, + return: retriedReturn, + [Symbol.asyncIterator]() { + return this + }, + }, } }) - const iterator = await client('hello', { context: { retry: 3, retryDelay: 0 } }) + const link = new StandardLink(codec, transport, { + plugins: [new RetryLinkPlugin()], + }) - await iterator.next() + const iterator = await link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 1, retryDelay: 50 } }) as AsyncIterator + + const nextPromise = iterator.next() + const nextExpectation = expect(nextPromise).rejects.toThrow('ITER_FAIL') + + await vi.advanceTimersByTimeAsync(1) + + const returnPromise = iterator.return?.() - await iterator.return() + await vi.advanceTimersByTimeAsync(60) - await vi.waitFor(() => expect(cleanup).toHaveBeenCalledTimes(1)) + await nextExpectation + await returnPromise + + expect(cleanup).toHaveBeenCalledTimes(1) + expect(retriedReturn).toHaveBeenCalledTimes(1) }) - it('cleanup correctly if throw and retry after .return', async () => { - const cleanup = vi.fn() + it('reset special event iterator properties after retry', async () => { + const codec = makeCodec() + const transport = makeTransport() - handlerFn.mockImplementation(async function* () { - try { - throw new Error('fail') - } - finally { - cleanup() + let callCount = 0 + vi.mocked(codec.decodeResponse).mockImplementation(async () => { + callCount++ + + const gen = (async function* () { + throw new Error('ITER_FAIL') + })() + + Object.defineProperty(gen, 'specialProperty', { + value: `specialValue:${callCount}`, + configurable: true, + }) + + return { + kind: 'output', + output: gen, } }) - const iterator = await client('hello', { context: { retry: 1, retryDelay: 0 } }) + const link = new StandardLink(codec, transport, { + plugins: [new RetryLinkPlugin()], + }) + + const iterator = await link.call(['planet', 'create'], { name: 'Earth' }, { context: { retry: 1, retryDelay: 0 } }) as AsyncIterator - const promise = expect(iterator.next()).rejects.toThrow('Internal server error') - await new Promise(r => setTimeout(r, 1)) - await iterator.return() - await promise - expect(cleanup).toHaveBeenCalledTimes(2) + expect((iterator as any).specialProperty).toEqual('specialValue:1') + await expect(iterator.next()).rejects.toThrow('ITER_FAIL') + expect((iterator as any).specialProperty).toEqual('specialValue:2') }) }) }) diff --git a/packages/client/src/plugins/retry.ts b/packages/client/src/plugins/retry.ts index 3efff59c6..8360a4ab4 100644 --- a/packages/client/src/plugins/retry.ts +++ b/packages/client/src/plugins/retry.ts @@ -1,106 +1,115 @@ import type { Promisable, Value } from '@orpc/shared' -import type { StandardLinkInterceptorOptions, StandardLinkOptions, StandardLinkPlugin } from '../adapters/standard' +import type { StandardLinkInterceptor, StandardLinkInterceptorOptions, StandardLinkOptions, StandardLinkPlugin } from '../adapters/standard' import type { ClientContext } from '../types' -import { AsyncIteratorClass, isAsyncIteratorObject, overlayProxy, value } from '@orpc/shared' -import { getEventMeta } from '@orpc/standard-server' +import { AsyncIteratorClass, isAsyncIteratorObject, override, sleep, toArray, value } from '@orpc/shared' +import { getEventMeta } from '@standardserver/core' -export interface ClientRetryPluginAttemptOptions extends StandardLinkInterceptorOptions { +export interface RetryLinkPluginAttemptOptions extends StandardLinkInterceptorOptions { + /** + * Latest retry delay advertised by the server via event metadata. + */ lastEventRetry: number | undefined - attemptIndex: number + + /** + * Current retry attempt number, starting at 1. + */ + attempt: number + + /** + * Error that triggered this retry attempt. + */ error: unknown } -export interface ClientRetryPluginContext { +export interface RetryLinkPluginContext { /** - * Maximum retry attempts before throwing - * Use `Number.POSITIVE_INFINITY` for infinite retries (e.g., when handling Server-Sent Events). + * Maximum retry attempts before throwing. + * Use `Number.POSITIVE_INFINITY` for infinite retries (e.g. for event iterators). * * @default 0 */ - retry?: Value, [StandardLinkInterceptorOptions]> + retry?: Value, [Omit, 'next'>]> /** * Delay (in ms) before retrying. * + * @info Why 2000ms? The EventSource spec suggests a default retry delay of 2 seconds if it doesn't specify * @default (o) => o.lastEventRetry ?? 2000 */ - retryDelay?: Value, [ClientRetryPluginAttemptOptions]> + retryDelay?: Value, [RetryLinkPluginAttemptOptions]> /** - * Determine should retry or not. + * Determine whether to retry. * * @default true */ - shouldRetry?: Value, [ClientRetryPluginAttemptOptions]> + shouldRetry?: Value, [RetryLinkPluginAttemptOptions]> /** - * The hook called when retrying, and return the unsubscribe function. + * Hook called before each retry. Can return a cleanup callback. */ - onRetry?: (options: ClientRetryPluginAttemptOptions) => void | ((isSuccess: boolean) => void) + onRetry?: (options: RetryLinkPluginAttemptOptions) => void | ((isSuccess: boolean) => void) } -export class ClientRetryPluginInvalidEventIteratorRetryResponse extends Error { } - -export interface ClientRetryPluginOptions { - default?: ClientRetryPluginContext +export interface RetryLinkPluginOptions<_T extends RetryLinkPluginContext> { + /** + * Default retry options. Can be overridden by individual calls via the context. + */ + default?: RetryLinkPluginContext | undefined } -/** - * The Client Retry Plugin enables retrying client calls when errors occur. - * - * @see {@link https://orpc.dev/docs/plugins/client-retry Client Retry Plugin Docs} - */ -export class ClientRetryPlugin implements StandardLinkPlugin { - private readonly defaultRetry: Exclude - private readonly defaultRetryDelay: Exclude - private readonly defaultShouldRetry: Exclude - private readonly defaultOnRetry: ClientRetryPluginContext['onRetry'] +export class RetryLinkPluginInvalidEventIteratorRetryResponse extends Error { } + +export class RetryLinkPlugin implements StandardLinkPlugin { + private readonly defaultRetry: Exclude + private readonly defaultRetryDelay: Exclude + private readonly defaultShouldRetry: Exclude + private readonly defaultOnRetry: RetryLinkPluginContext['onRetry'] - order = 1_800_000 + name = '~retry' - constructor(options: ClientRetryPluginOptions = {}) { + constructor(options: RetryLinkPluginOptions = {}) { this.defaultRetry = options.default?.retry ?? 0 this.defaultRetryDelay = options.default?.retryDelay ?? (o => o.lastEventRetry ?? 2000) this.defaultShouldRetry = options.default?.shouldRetry ?? true this.defaultOnRetry = options.default?.onRetry } - init(options: StandardLinkOptions): void { - options.interceptors ??= [] - - options.interceptors.push(async (interceptorOptions) => { + init(options: StandardLinkOptions): StandardLinkOptions { + const interceptor: StandardLinkInterceptor = async (interceptorOptions) => { + const { next, ...callOptions } = interceptorOptions const maxAttempts = await value( - interceptorOptions.context.retry ?? this.defaultRetry, - interceptorOptions, + callOptions.context.retry ?? this.defaultRetry, + callOptions, ) - const retryDelay = interceptorOptions.context.retryDelay ?? this.defaultRetryDelay - const shouldRetry = interceptorOptions.context.shouldRetry ?? this.defaultShouldRetry - const onRetry = interceptorOptions.context.onRetry ?? this.defaultOnRetry + const retryDelay = callOptions.context.retryDelay ?? this.defaultRetryDelay + const shouldRetry = callOptions.context.shouldRetry ?? this.defaultShouldRetry + const onRetry = callOptions.context.onRetry ?? this.defaultOnRetry if (maxAttempts <= 0) { - return interceptorOptions.next() + return next(callOptions) } - let lastEventId = interceptorOptions.lastEventId + let lastEventId = callOptions.lastEventId let lastEventRetry: undefined | number let callback: void | ((isSuccess: boolean) => void) - let attemptIndex = 0 + let attempt = 1 - const next = async (initialError?: { error: unknown }) => { + const callNext = async (initialError?: { error: unknown }) => { let currentError = initialError while (true) { - const updatedInterceptorOptions = { ...interceptorOptions, lastEventId } + const updatedCallOptions = { ...callOptions, lastEventId } if (currentError) { - if (attemptIndex >= maxAttempts) { + if (attempt > maxAttempts) { throw currentError.error } - const attemptOptions: ClientRetryPluginAttemptOptions = { - ...updatedInterceptorOptions, - attemptIndex, + const attemptOptions: RetryLinkPluginAttemptOptions = { + ...updatedCallOptions, + attempt, error: currentError.error, lastEventRetry, } @@ -115,22 +124,30 @@ export class ClientRetryPlugin implements St } callback = onRetry?.(attemptOptions) - - const retryDelayMs = await value(retryDelay, attemptOptions) - - await new Promise(resolve => setTimeout(resolve, retryDelayMs)) - - attemptIndex++ } try { + if (currentError) { + const retryDelayMs = await value(retryDelay, { + ...updatedCallOptions, + attempt, + error: currentError.error, + lastEventRetry, + }) + + // can throw if signal is aborted while sleeping + await sleep(retryDelayMs, { signal: updatedCallOptions.signal }) + + attempt++ + } + currentError = undefined - return await interceptorOptions.next(updatedInterceptorOptions) + return await next(updatedCallOptions) } catch (error) { currentError = { error } - if (updatedInterceptorOptions.signal?.aborted) { + if (updatedCallOptions.signal?.aborted) { throw error } } @@ -141,7 +158,7 @@ export class ClientRetryPlugin implements St } } - const output = await next() + const output = await callNext() if (!isAsyncIteratorObject(output)) { return output @@ -150,13 +167,13 @@ export class ClientRetryPlugin implements St let current = output let isIteratorAborted = false - return overlayProxy(() => current, new AsyncIteratorClass( + return override(() => current, new AsyncIteratorClass( async () => { while (true) { try { const item = await current.next() - const meta = getEventMeta(item.value) + lastEventId = meta?.id ?? lastEventId lastEventRetry = meta?.retry ?? lastEventRetry @@ -164,22 +181,19 @@ export class ClientRetryPlugin implements St } catch (error) { const meta = getEventMeta(error) + lastEventId = meta?.id ?? lastEventId lastEventRetry = meta?.retry ?? lastEventRetry - const maybeEventIterator = await next({ error }) - + const maybeEventIterator = await callNext({ error }) if (!isAsyncIteratorObject(maybeEventIterator)) { - throw new ClientRetryPluginInvalidEventIteratorRetryResponse( - 'RetryPlugin: Expected an Event Iterator, got a non-Event Iterator', + throw new RetryLinkPluginInvalidEventIteratorRetryResponse( + 'RetryLinkPlugin: Expected an Event Iterator, got a non-Event Iterator', ) } current = maybeEventIterator - /** - * If iterator is aborted while retrying, we should cleanup right away - */ if (isIteratorAborted) { await current.return?.() throw error @@ -187,13 +201,16 @@ export class ClientRetryPlugin implements St } } }, - async (reason) => { + async ({ kind }) => { isIteratorAborted = true - if (reason !== 'next') { + + if (kind === 'cancelled') { await current.return?.() } }, )) - }) + } + + return { ...options, interceptors: [interceptor, ...toArray(options.interceptors)] } } } diff --git a/packages/client/src/plugins/simple-csrf-protection.test.ts b/packages/client/src/plugins/simple-csrf-protection.test.ts deleted file mode 100644 index 1befe7247..000000000 --- a/packages/client/src/plugins/simple-csrf-protection.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { RPCHandler } from '../../../server/src/adapters/fetch/rpc-handler' -import { os } from '../../../server/src/builder' -import { SimpleCsrfProtectionHandlerPlugin } from '../../../server/src/plugins/simple-csrf-protection' -import { RPCLink } from '../adapters/fetch' -import { SimpleCsrfProtectionLinkPlugin } from './simple-csrf-protection' - -describe('simpleCsrfProtectionLinkPlugin', () => { - const handler = new RPCHandler({ - ping: os.handler(() => 'pong'), - }, { - plugins: [ - new SimpleCsrfProtectionHandlerPlugin(), - ], - }) - - const link = new RPCLink({ - url: new URL('http://localhost/prefix'), - fetch: async (request) => { - const { response } = await handler.handle(request, { prefix: '/prefix' }) - - return response ?? new Response(null, { - status: 500, - }) - }, - plugins: [ - new SimpleCsrfProtectionLinkPlugin(), - ], - }) - - it('should work', async () => { - await expect( - link.call(['ping'], 'input', { context: {} }), - ).resolves.toEqual('pong') - }) - - it('can exclude procedure', async () => { - const exclude = vi.fn(() => true) - - const link = new RPCLink({ - url: new URL('http://localhost/prefix'), - fetch: async (request) => { - const { response } = await handler.handle(request, { prefix: '/prefix' }) - - return response ?? new Response(null, { - status: 500, - }) - }, - plugins: [ - new SimpleCsrfProtectionLinkPlugin({ exclude }), - ], - }) - - await expect( - link.call(['ping'], 'input', { context: {} }), - ).rejects.toThrowError('Invalid CSRF token') - - expect(exclude).toHaveBeenCalledTimes(1) - expect(exclude).toHaveBeenCalledWith(expect.objectContaining({ - path: ['ping'], - })) - }) -}) diff --git a/packages/client/src/plugins/simple-csrf-protection.ts b/packages/client/src/plugins/simple-csrf-protection.ts deleted file mode 100644 index 9880fb167..000000000 --- a/packages/client/src/plugins/simple-csrf-protection.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { Promisable, Value } from '@orpc/shared' -import type { StandardLinkClientInterceptorOptions, StandardLinkOptions, StandardLinkPlugin } from '../adapters/standard' -import type { ClientContext } from '../types' -import { value } from '@orpc/shared' - -export interface SimpleCsrfProtectionLinkPluginOptions { - /** - * The name of the header to check. - * - * @default 'x-csrf-token' - */ - headerName?: Value, [options: StandardLinkClientInterceptorOptions]> - - /** - * The value of the header to check. - * - * @default 'orpc' - * - */ - headerValue?: Value, [options: StandardLinkClientInterceptorOptions]> - - /** - * Exclude a procedure from the plugin. - * - * @default false - */ - exclude?: Value, [options: StandardLinkClientInterceptorOptions]> -} - -/** - * This plugin adds basic Cross-Site Request Forgery (CSRF) protection to your oRPC application. - * It helps ensure that requests to your procedures originate from JavaScript code, - * not from other sources like standard HTML forms or direct browser navigation. - * - * @see {@link https://orpc.dev/docs/plugins/simple-csrf-protection Simple CSRF Protection Plugin Docs} - */ -export class SimpleCsrfProtectionLinkPlugin implements StandardLinkPlugin { - private readonly headerName: Exclude['headerName'], undefined> - private readonly headerValue: Exclude['headerValue'], undefined> - private readonly exclude: Exclude['exclude'], undefined> - - constructor(options: SimpleCsrfProtectionLinkPluginOptions = {}) { - this.headerName = options.headerName ?? 'x-csrf-token' - this.headerValue = options.headerValue ?? 'orpc' - this.exclude = options.exclude ?? false - } - - order = 8_000_000 - - init(options: StandardLinkOptions): void { - options.clientInterceptors ??= [] - - options.clientInterceptors.push(async (options) => { - const excluded = await value(this.exclude, options) - - if (excluded) { - return options.next() - } - - const headerName = await value(this.headerName, options) - const headerValue = await value(this.headerValue, options) - - return options.next({ - ...options, - request: { - ...options.request, - headers: { - ...options.request.headers, - [headerName]: headerValue, - }, - }, - }) - }) - } -} diff --git a/packages/client/src/rpc-json-serializer.test.ts b/packages/client/src/rpc-json-serializer.test.ts new file mode 100644 index 000000000..7caefff9a --- /dev/null +++ b/packages/client/src/rpc-json-serializer.test.ts @@ -0,0 +1,221 @@ +import { builtInRPCSupportDataTypes } from '../../../tests/rpc/__shared__/built-in-support-data-types' +import { RPCJsonSerializer } from './rpc-json-serializer' + +class Person { + constructor( + public name: string, + public date: Date, + ) {} + + toJSON() { + return { + name: this.name, + date: this.date, + } + } +} + +class Person2 { + constructor( + public name: string, + public data: any, + ) { } + + toJSON() { + return { + name: this.name, + data: this.data, + } + } +} + +const customSupportedDataTypes: { name: string, value: unknown, expected: unknown }[] = [ + { + name: 'person - 1', + value: new Person('unnoq', new Date('2023-01-01')), + expected: new Person('unnoq', new Date('2023-01-01')), + }, + { + name: 'person - 2', + value: new Person2('unnoq - 2', [{ nested: new Date('2023-01-02') }, /uic/gi]), + expected: new Person2('unnoq - 2', [{ nested: new Date('2023-01-02') }, /uic/gi]), + }, + { + name: 'should not resolve toJSON', + value: { value: { toJSON: () => 'hello' } }, + expected: { value: { } }, + }, + { + name: 'should resolve invalid toJSON', + value: { value: { toJSON: 'hello' } }, + expected: { value: { toJSON: 'hello' } }, + }, +] + +describe.each([ + ...builtInRPCSupportDataTypes, + ...customSupportedDataTypes, +])('rpcJsonSerializer: $name', ({ value, expected }) => { + const serializer = new RPCJsonSerializer({ + handlers: { + person: { + condition: data => data instanceof Person, + serialize: data => data.toJSON(), + deserialize: data => new Person(data.name, data.date), + }, + person2: { + condition: data => data instanceof Person2, + serialize: data => data.toJSON(), + deserialize: data => new Person2(data.name, data.data), + }, + }, + }) + + function assert(value: unknown, expected: unknown) { + const { json, meta, maps, blobs } = serializer.serialize(value) + + const result = JSON.parse(JSON.stringify({ json, meta, maps })) + + const deserialized = serializer.deserialize({ ...result, blobs }) + expect(deserialized).toEqual(expected) + } + + it('flat', () => { + assert(value, expected) + }) + + it('nested object', () => { + assert({ + data: value, + nested: { + data: value, + }, + }, { + data: expected, + nested: { + data: expected, + }, + }) + }) + + it('nested array', () => { + assert([value, [value]], [expected, [expected]]) + }) + + it('complex', () => { + assert({ + 'date': new Date('2023-01-01'), + 'regexp': /uic/gi, + 'url': new URL('https://unnoq.com'), + '!@#$%^^&()[]>?<~_<:"~+!_': value, + 'list': [value], + 'map': new Map([[value, value]]), + 'set': new Set([value]), + 'nested': { + nested: value, + }, + }, { + 'date': new Date('2023-01-01'), + 'regexp': /uic/gi, + 'url': new URL('https://unnoq.com'), + '!@#$%^^&()[]>?<~_<:"~+!_': expected, + 'list': [expected], + 'map': new Map([[expected, expected]]), + 'set': new Set([expected]), + 'nested': { + nested: expected, + }, + }) + }) +}) + +describe('rpcJsonSerializer', () => { + it('support override default handlers', () => { + const serializer = new RPCJsonSerializer({ + handlers: { + date: { + condition: data => data instanceof Date, + serialize: (value: Date) => `___TEST___${value.getTime()}`, + deserialize: (value: string) => new Date(Number(value.slice(10))), + }, + }, + }) + + const date = new Date('2023-01-01') + const serialized = serializer.serialize({ value: date }) + expect(serialized.json).toEqual({ value: `___TEST___${date.getTime()}` }) + expect(serialized.meta).toEqual([['date', 'value']]) + }) + + it('disable default handlers', () => { + const serializer = new RPCJsonSerializer({ + handlers: { + date: undefined, + }, + }) + + const date = new Date('2023-01-01') + const serialized = serializer.serialize({ value: date }) + expect(serialized.json).toEqual({ value: date }) + expect(serialized.meta).toEqual(undefined) + }) + + it('can disable omit undefined properties', () => { + const serializer = new RPCJsonSerializer({ + omitUndefinedProperties: false, + }) + + const serialized = serializer.serialize({ a: 1, b: undefined }) + expect(serialized.json).toEqual({ a: 1, b: null }) + expect(serialized.meta).toEqual([['undefined', 'b']]) + }) + + it.each(['doesNotExist', '__proto__', 'constructor'])('should throw on deserialization if path does not exist to avoid prototype pollution', (segment) => { + const serializer = new RPCJsonSerializer() + + expect( + () => serializer.deserialize({ + json: { o: {} }, + meta: [['date', segment]], + }), + ).toThrow(`Security error: Invalid serialized data. Segment "${segment}" does not exist.`) + + expect( + () => serializer.deserialize({ + json: { o: {} }, + meta: [['date', 'o', segment]], + }), + ).toThrow(`Security error: Invalid serialized data. Segment "${segment}" does not exist.`) + + expect( + () => serializer.deserialize({ + json: { o: {} }, + meta: [['date', segment, 'o']], + }), + ).toThrow(`Security error: Invalid serialized data. Segment "${segment}" does not exist.`) + + expect( + () => serializer.deserialize({ + json: { o: {} }, + blobs: [new Blob()], + maps: [[segment]], + }), + ).toThrow(`Security error: Invalid serialized data. Segment "${segment}" does not exist.`) + + expect( + () => serializer.deserialize({ + json: { o: {} }, + blobs: [new Blob()], + maps: [['o', segment]], + }), + ).toThrow(`Security error: Invalid serialized data. Segment "${segment}" does not exist.`) + + expect( + () => serializer.deserialize({ + json: { o: {} }, + blobs: [new Blob()], + maps: [[segment, 'o']], + }), + ).toThrow(`Security error: Invalid serialized data. Segment "${segment}" does not exist.`) + }) +}) diff --git a/packages/client/src/rpc-json-serializer.ts b/packages/client/src/rpc-json-serializer.ts new file mode 100644 index 000000000..906249ca2 --- /dev/null +++ b/packages/client/src/rpc-json-serializer.ts @@ -0,0 +1,301 @@ +import type { Segment } from '@orpc/shared' +import { isPlainObject } from '@orpc/shared' + +export type RPCJsonSerializationMeta = [type: string, ...path: Segment[]] +export type RPCJsonSerialization + = | { json: unknown, meta?: RPCJsonSerializationMeta[] | undefined, maps?: undefined, blobs?: undefined } + | { json: unknown, meta?: RPCJsonSerializationMeta[] | undefined, maps: Segment[][], blobs: Blob[] } + +export interface RPCJsonSerializerHandler { + condition(value: unknown): boolean + serialize(value: any): unknown + deserialize(serialized: any): unknown + /** + * If false, the result of this serializer will not be further processed by other serializers, + * even if it matches their conditions and treat it as final serialized value. + * This can be useful for serializers that return primitive values, which should not be further processed. + * to improve performance and avoid potential issues with other serializers. + * + * @default false + */ + isTerminal?: boolean +} + +const REGEX_STRING_PATTERN = /^\/(.*)\/([a-z]*)$/ + +const DEFAULT_RPC_JSON_SERIALIZER_HANDLERS: Record = { + undefined: { + condition(data: unknown): boolean { + return data === undefined + }, + serialize() { + return null + }, + deserialize() { + return undefined + }, + isTerminal: true, + }, + bigint: { + condition(data: unknown): boolean { + return typeof data === 'bigint' + }, + serialize(data: bigint): string { + return data.toString() + }, + deserialize(serialized: string): bigint { + return BigInt(serialized) + }, + isTerminal: true, + }, + date: { + condition(data: unknown): boolean { + return data instanceof Date + }, + serialize(data: Date): string | null { + if (Number.isNaN(data.getTime())) { + return null + } + + return data.toISOString() + }, + deserialize(serialized: string | null): Date { + return new Date(serialized ?? 'Invalid Date') + }, + isTerminal: true, + }, + nan: { + condition(data: unknown): boolean { + return typeof data === 'number' && Number.isNaN(data) + }, + serialize() { + return null + }, + deserialize() { + return Number.NaN + }, + isTerminal: true, + }, + url: { + condition(data: unknown): boolean { + return data instanceof URL + }, + serialize(data: URL): string { + return data.toString() + }, + deserialize(serialized: string): URL { + return new URL(serialized) + }, + isTerminal: true, + }, + regexp: { + condition(data: unknown): boolean { + return data instanceof RegExp + }, + serialize(data: RegExp): string { + return data.toString() + }, + deserialize(serialized: string): RegExp { + const [, pattern, flags] = serialized.match(REGEX_STRING_PATTERN)! + return new RegExp(pattern!, flags) + }, + isTerminal: true, + }, + set: { + condition(data: unknown): boolean { + return data instanceof Set + }, + serialize(data: Set): unknown[] { + return Array.from(data) + }, + deserialize(serialized: unknown[]): Set { + return new Set(serialized) + }, + }, + map: { + condition(data: unknown): boolean { + return data instanceof Map + }, + serialize(data: Map): unknown[] { + return Array.from(data.entries()) + }, + deserialize(serialized: [unknown, unknown][]): Map { + return new Map(serialized) + }, + }, +} + +export interface RPCJsonSerializerOptions { + /** + * Extend or override the built-in type handlers used during serialization and deserialization. + * + * Each key is a unique type identifier (e.g. `"date"`, `"bigint"`) and maps to a handler + * that defines how to detect, serialize, and deserialize values of that type. + * + * **Extending:** Add new keys to support custom types: + * ```ts + * handlers: { + * buffer: { + * condition: (v) => v instanceof Buffer, + * serialize: (v: Buffer) => v.toString('base64'), + * deserialize: (s: string) => Buffer.from(s, 'base64'), + * isTerminal: true, + * } + * } + * ``` + * + * **Overriding:** Use an existing key to replace a built-in handler: + * ```ts + * handlers: { + * date: { + * condition: (v) => v instanceof Date, + * serialize: (v: Date) => v.getTime(), + * deserialize: (n: number) => new Date(n), + * isTerminal: true, + * } + * } + * ``` + * + * **Disabling:** Set a key to `undefined` to remove a built-in handler: + * ```ts + * handlers: { regexp: undefined } + * ``` + * + * Built-in type keys: `undefined`, `bigint`, `date`, `nan`, `url`, `regexp`, `set`, `map`. + */ + handlers?: Record | undefined + + /** + * If true, properties with undefined values will be omitted during serialization. + * + * @default true + */ + omitUndefinedProperties?: boolean | undefined +} + +export class RPCJsonSerializer { + private readonly handlers: Exclude + private readonly omitUndefinedProperties: boolean + + constructor(options: RPCJsonSerializerOptions = {}) { + this.handlers = { + ...DEFAULT_RPC_JSON_SERIALIZER_HANDLERS, + ...options.handlers, + } + + this.omitUndefinedProperties = options.omitUndefinedProperties !== false + } + + serialize(data: unknown): RPCJsonSerialization { + const [json, meta_, maps, blobs] = this.serializeValue(data, [], [], [], []) + + const meta = meta_.length === 0 ? undefined : meta_ + + if (maps.length === 0) { + return { json, meta } + } + + return { json, meta, maps, blobs } + } + + private serializeValue(data: unknown, segments: Segment[], meta: RPCJsonSerializationMeta[], maps: Segment[][], blobs: Blob[]): [unknown, RPCJsonSerializationMeta[], Segment[][], Blob[]] { + for (const key in this.handlers) { + const handler = this.handlers[key] + + if (handler && handler.condition(data)) { + const serialized = handler.serialize(data) + + if (handler.isTerminal) { + meta.push([key, ...segments]) + return [serialized, meta, maps, blobs] + } + + const result = this.serializeValue(serialized, segments, meta, maps, blobs) + meta.push([key, ...segments]) + return result + } + } + + if (data instanceof Blob) { + maps.push(segments) + blobs.push(data) + return [data, meta, maps, blobs] + } + + if (Array.isArray(data)) { + const json = data.map((v, i) => { + return this.serializeValue(v, [...segments, i], meta, maps, blobs)[0] + }) + + return [json, meta, maps, blobs] + } + + if (isPlainObject(data)) { + const json: Record = {} + + for (const k in data) { + const v = data[k] + /** + * Skip custom toJSON methods to avoid JSON.stringify invoking them, + * which could cause meta and serialized data mismatches during deserialization. + * Instead, rely on custom handlers. + */ + if (k === 'toJSON' && typeof v === 'function') { + continue + } + + if (v === undefined && this.omitUndefinedProperties) { + continue + } + + json[k] = this.serializeValue(v, [...segments, k], meta, maps, blobs)[0] + } + + return [json, meta, maps, blobs] + } + + return [data, meta, maps, blobs] + } + + deserialize(serialized: RPCJsonSerialization): unknown { + const ref = { data: serialized.json } + + if (serialized.blobs?.length) { + serialized.maps.forEach((segments, i) => { + let currentRef: any = ref + let preSegment: string | number = 'data' + + segments.forEach((segment) => { + currentRef = currentRef[preSegment] + preSegment = segment + + if (!Object.hasOwn(currentRef, preSegment)) { + throw new Error(`Security error: Invalid serialized data. Segment "${preSegment}" does not exist.`) + } + }) + + currentRef[preSegment] = serialized.blobs[i] + }) + } + + serialized.meta?.forEach((item) => { + const type = item[0] + + let currentRef: any = ref + let preSegment: string | number = 'data' + + for (let i = 1; i < item.length; i++) { + currentRef = currentRef[preSegment] + preSegment = item[i]! + + if (!Object.hasOwn(currentRef, preSegment)) { + throw new Error(`Security error: Invalid serialized data. Segment "${preSegment}" does not exist.`) + } + } + + currentRef[preSegment] = this.handlers[type]!.deserialize(currentRef[preSegment]) + }) + + return ref.data + } +} diff --git a/packages/client/src/rpc-serializer.test.ts b/packages/client/src/rpc-serializer.test.ts new file mode 100644 index 000000000..30be2538f --- /dev/null +++ b/packages/client/src/rpc-serializer.test.ts @@ -0,0 +1,293 @@ +import { isAsyncIteratorObject, parseEmptyableJSON } from '@orpc/shared' +import { ErrorEvent, getEventMeta, withEventMeta } from '@standardserver/core' +import { builtInRPCSupportDataTypes } from '../../../tests/rpc/__shared__/built-in-support-data-types' +import { ORPCError } from './error' +import { RPCSerializer } from './rpc-serializer' + +describe('rpcSerializer', () => { + describe.each(builtInRPCSupportDataTypes)('$name', ({ value, expected }) => { + const serializer = new RPCSerializer() + + function serializeAndDeserialize(value: unknown): unknown { + const serialized = serializer.serialize(value) + + if (serialized instanceof FormData || serialized instanceof Blob) { + return serializer.deserialize(serialized) + } + + return serializer.deserialize(parseEmptyableJSON(JSON.stringify(serialized) ?? '')) // like in the real world + } + + it('should work on flat', async () => { + expect( + serializeAndDeserialize(value), + ).toEqual( + expected, + ) + }) + + it('should work on nested object', async () => { + expect( + serializeAndDeserialize({ + data: value, + }), + ).toEqual( + { + data: expected, + }, + ) + }) + + it('should work on complex object', async () => { + expect( + serializeAndDeserialize({ + '!@#$%^^&()[]>?<~_<:"~+!_': value, + 'list': [value], + 'map': new Map([[value, value]]), + 'set': new Set([value]), + 'nested': { + nested: value, + }, + }), + ).toEqual({ + '!@#$%^^&()[]>?<~_<:"~+!_': expected, + 'list': [expected], + 'map': new Map([[expected, expected]]), + 'set': new Set([expected]), + 'nested': { + nested: expected, + }, + }) + }) + }) + + describe('event iterator', async () => { + const serializer = new RPCSerializer() + + function serializeAndDeserialize(value: unknown): unknown { + const serialized = serializer.serialize(value) + return serializer.deserialize(serialized) + } + + const date = new Date() + + it('on success', async () => { + const iterator = (async function* () { + yield 1 + yield withEventMeta({ order: 2, date }, { retry: 1000 }) + return withEventMeta({ order: 3 }, { id: '123456' }) + })() + + const deserialized = serializeAndDeserialize(iterator) as any + + expect(deserialized).toSatisfy(isAsyncIteratorObject) + await expect(deserialized.next()).resolves.toSatisfy(({ value, done }) => { + expect(done).toBe(false) + expect(value).toEqual(1) + expect(getEventMeta(value)).toEqual(undefined) + + return true + }) + + await expect(deserialized.next()).resolves.toSatisfy(({ value, done }) => { + expect(done).toBe(false) + expect(value).toEqual({ order: 2, date }) + expect(getEventMeta(value)).toEqual({ retry: 1000 }) + + return true + }) + + await expect(deserialized.next()).resolves.toSatisfy(({ value, done }) => { + expect(done).toBe(true) + expect(value).toEqual({ order: 3 }) + expect(getEventMeta(value)).toEqual({ id: '123456' }) + + return true + }) + }) + + it('passes through undefined yielded values as is', async () => { + const iterator = (async function* () { + yield undefined + return 'done' + })() + + const deserialized = serializeAndDeserialize(iterator) as any + + expect(deserialized).toSatisfy(isAsyncIteratorObject) + await expect(deserialized.next()).resolves.toEqual({ value: undefined, done: false }) + await expect(deserialized.next()).resolves.toEqual({ value: 'done', done: true }) + }) + + it('on error with ORPCError', async () => { + const error = withEventMeta(new ORPCError('BAD_GATEWAY', { data: { order: 3 } }), { id: '123456' }) + + const iterator = (async function* () { + yield 1 + yield withEventMeta({ order: 2, date }, { retry: 1000 }) + throw error + })() + + const deserialized = serializeAndDeserialize(iterator) as any + + expect(deserialized).toSatisfy(isAsyncIteratorObject) + await expect(deserialized.next()).resolves.toSatisfy(({ value, done }) => { + expect(done).toBe(false) + expect(value).toEqual(1) + expect(getEventMeta(value)).toEqual(undefined) + + return true + }) + + await expect(deserialized.next()).resolves.toSatisfy(({ value, done }) => { + expect(done).toBe(false) + expect(value).toEqual({ order: 2, date }) + expect(getEventMeta(value)).toEqual({ retry: 1000 }) + + return true + }) + + await expect(deserialized.next()).rejects.toSatisfy((e: any) => { + expect(e).toEqual(error) + expect(e).toBeInstanceOf(ORPCError) + expect(e.cause).toBeInstanceOf(ErrorEvent) + + return true + }) + }) + + it('on error with unknown error when deserialize', async () => { + const error = withEventMeta(new Error('UNKNOWN'), { id: '123456' }) + + const iterator = (async function* () { + yield serializer.serialize(1) + yield withEventMeta(serializer.serialize({ order: 2, date }) as any, { retry: 1000 }) + throw error + })() + + const deserialized = serializer.deserialize(iterator as any) as any + + expect(deserialized).toSatisfy(isAsyncIteratorObject) + await expect(deserialized.next()).resolves.toSatisfy(({ value, done }) => { + expect(done).toBe(false) + expect(value).toEqual(1) + expect(getEventMeta(value)).toEqual(undefined) + + return true + }) + + await expect(deserialized.next()).resolves.toSatisfy(({ value, done }) => { + expect(done).toBe(false) + expect(value).toEqual({ order: 2, date }) + expect(getEventMeta(value)).toEqual({ retry: 1000 }) + + return true + }) + + await expect(deserialized.next()).rejects.toBe(error) + }) + + it('deserialize an invalid ORPCError json', async () => { + const iterator = serializer.deserialize((async function* () { + throw new ErrorEvent({ json: { value: 1234 } }) + })()) as any + + await expect(iterator.next()).rejects.toSatisfy((e: any) => { + expect(e).toBeInstanceOf(ErrorEvent) + expect(e.data).toEqual({ value: 1234 }) + + return true + }) + }) + }) + + describe('readable stream & blob', () => { + it('should serialize and deserialize ReadableStream as is', () => { + const serializer = new RPCSerializer() + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue('test') + controller.close() + }, + }) + + const serialized = serializer.serialize(stream) + expect(serialized).toBe(stream) + + const deserialized = serializer.deserialize(serialized) + expect(deserialized).toBe(stream) + }) + + it('should serialize and deserialize Blob as is', () => { + const serializer = new RPCSerializer() + + const blob = new Blob(['test'], { type: 'text/plain' }) + + const serialized = serializer.serialize(blob) + expect(serialized).toBe(blob) + + const deserialized = serializer.deserialize(serialized) + expect(deserialized).toBe(blob) + }) + + it('should serialize and deserialize undefined as is', () => { + const serializer = new RPCSerializer() + + const serialized = serializer.serialize(undefined) + expect(serialized).toBe(undefined) + + const deserialized = serializer.deserialize(serialized) + expect(deserialized).toBe(undefined) + }) + }) + + describe('constructor', () => { + it('should passing options to RPCJsonSerializer', () => { + const serializer = new RPCSerializer({ + handlers: { + date: { + condition: (data: unknown): boolean => data instanceof Date, + serialize: (date: Date) => `__TEST_DATE__${date.toISOString()}`, + deserialize: (isoString: string) => new Date(isoString.replace('__TEST_DATE__', '')), + }, + }, + }) + + const date = new Date('2023-01-01') + const serialized = serializer.serialize(date) + expect((serialized as any).json).toBe(`__TEST_DATE__${date.toISOString()}`) + }) + }) + + describe('useFormDataForBlobFields option', () => { + it('should respect the useFormDataForBlobFields option in the constructor', () => { + const serializer = new RPCSerializer({ + serialize: { useFormDataForBlobFields: false }, + }) + + const blob = new Blob(['test'], { type: 'text/plain' }) + const serialized = serializer.serialize({ blob }) + + expect(serialized).not.toBeInstanceOf(FormData) + }) + + it('should respect the useFormDataForBlobFields option in the serialize method', () => { + const serializer = new RPCSerializer() + + const blob = new Blob(['test'], { type: 'text/plain' }) + const serialized = serializer.serialize({ blob }, { useFormDataForBlobFields: false }) + + expect(serialized).not.toBeInstanceOf(FormData) + }) + + it('should prefer serialize option over constructor option', () => { + const serializer = new RPCSerializer({ serialize: { useFormDataForBlobFields: false } }) + + const blob = new Blob(['test'], { type: 'text/plain' }) + const serialized = serializer.serialize({ blob }, { useFormDataForBlobFields: true }) + + expect(serialized).toBeInstanceOf(FormData) + }) + }) +}) diff --git a/packages/client/src/rpc-serializer.ts b/packages/client/src/rpc-serializer.ts new file mode 100644 index 000000000..ee15428f9 --- /dev/null +++ b/packages/client/src/rpc-serializer.ts @@ -0,0 +1,132 @@ +import type { StandardBody } from '@standardserver/core' +import type { RPCJsonSerializerOptions } from './rpc-json-serializer' +import { isAsyncIteratorObject, stringifyJSON } from '@orpc/shared' +import { ErrorEvent } from '@standardserver/core' +import { createORPCErrorFromJson, isORPCErrorJson, toORPCError } from './error-utils' +import { wrapEventIteratorPreservingMeta } from './event-iterator' +import { RPCJsonSerializer } from './rpc-json-serializer' + +export interface RPCSerializerSerializeOptions { + /** + * Use FormData for serialization when nested blobs are present. + * Does not apply to root-level Blob values. + * + * @default true + */ + useFormDataForBlobFields?: boolean +} + +export interface RPCSerializerOptions extends RPCJsonSerializerOptions { + /** + * Default options for serialize method + */ + serialize?: RPCSerializerSerializeOptions | undefined +} + +export class RPCSerializer { + private readonly jsonSerializer: RPCJsonSerializer + private readonly defaultSerializeOptions: RPCSerializerOptions['serialize'] + + constructor( + options: RPCSerializerOptions = {}, + ) { + this.jsonSerializer = new RPCJsonSerializer(options) + this.defaultSerializeOptions = options.serialize + } + + serialize(data: unknown, options: RPCSerializerSerializeOptions = {}): StandardBody { + // standard body already supports these types without additional serialization. + if (data === undefined || data instanceof ReadableStream || data instanceof Blob) { + return data + } + + if (isAsyncIteratorObject(data)) { + return wrapEventIteratorPreservingMeta(data, { + mapResult: (result) => { + // standard event stream data already supports these types without additional serialization. + if (result.value === undefined) { + return result + } + + return { done: result.done, value: this.serializeValue(result.value, options) } + }, + mapError: e => new ErrorEvent( + this.serializeValue(toORPCError(e).toJSON(), { ...options, useFormDataForBlobFields: false }), + { cause: e }, + ), + }) + } + + return this.serializeValue(data, options) + } + + private serializeValue(data: unknown, options: RPCSerializerSerializeOptions): unknown { + const useFormDataForBlobs = options.useFormDataForBlobFields ?? this.defaultSerializeOptions?.useFormDataForBlobFields ?? true + + const { json, meta, maps, blobs } = this.jsonSerializer.serialize(data) + + if (!useFormDataForBlobs || !blobs?.length) { + return { json, meta } + } + + const form = new FormData() + + form.set('data', stringifyJSON({ json, meta, maps })) + + blobs.forEach((blob, i) => { + form.set(i.toString(), blob) + }) + + return form + } + + deserialize(data: StandardBody): unknown { + if (data === undefined || data instanceof ReadableStream || data instanceof Blob) { + return data + } + + if (isAsyncIteratorObject(data)) { + return wrapEventIteratorPreservingMeta(data, { + mapResult: (result) => { + if (result.value === undefined) { + return result + } + + return { done: result.done, value: this.deserializeValue(result.value) } + }, + mapError: (e) => { + if (!(e instanceof ErrorEvent)) { + return e + } + + const deserialized = this.deserializeValue(e.data) + + if (isORPCErrorJson(deserialized)) { + return createORPCErrorFromJson(deserialized, { cause: e }) + } + + return new ErrorEvent(deserialized, { cause: e }) + }, + }) + } + + return this.deserializeValue(data) + } + + private deserializeValue(data: any): unknown { + if (!(data instanceof FormData)) { + return this.jsonSerializer.deserialize(data) + } + + const serialized = JSON.parse(data.get('data') as string) + + const blobs: Blob[] = [] + for (const [key, value] of data.entries()) { + if (value instanceof Blob) { + blobs[Number(key)] = value + } + } + + return this.jsonSerializer.deserialize({ ...serialized, blobs }) + } +} diff --git a/packages/client/src/types.test-d.ts b/packages/client/src/types.test-d.ts index 339751aa0..64500d879 100644 --- a/packages/client/src/types.test-d.ts +++ b/packages/client/src/types.test-d.ts @@ -1,40 +1,34 @@ import type { ORPCError } from './error' -import type { Client, ClientContext, InferClientBodyInputs, InferClientBodyOutputs, InferClientErrors, InferClientErrorUnion, InferClientInputs, InferClientOutputs } from './types' +import type { Client, ClientContext, InferClientBodyInputs, InferClientBodyOutputs, InferClientContext, InferClientError, InferClientErrors, InferClientInputs, InferClientOutputs } from './types' describe('client', () => { const client: Client<{ cache?: boolean }, string, number, Error | ORPCError<'OVERRIDE', unknown>> = async (...args) => { const [input, options] = args expectTypeOf(input).toEqualTypeOf() - expectTypeOf(options).toMatchTypeOf<{ context?: { cache?: boolean }, signal?: AbortSignal } | undefined>() + expectTypeOf(options).toExtend<{ context?: { cache?: boolean }, signal?: AbortSignal } | undefined>() + return 123 } it('just a function', () => { - expectTypeOf(client).toMatchTypeOf<(input: string, options: { context?: ClientContext, signal?: AbortSignal }) => Promise>() + expectTypeOf(client).toExtend<(input: string, options: { context?: ClientContext, signal?: AbortSignal }) => Promise>() }) it('infer correct input', () => { - client('123') - // @ts-expect-error - invalid input - client(undefined) - // @ts-expect-error - missing input - client() + expectTypeOf(client).parameter(0).toEqualTypeOf() - // @ts-expect-error - invalid input - client(123) - // @ts-expect-error - invalid input - client({}) + // @ts-expect-error - input is required + client() }) it('optional undefinedable input', () => { const client = {} as Client - client({ val: '123' }) + expectTypeOf(client).parameter(0).toEqualTypeOf<{ val: string } | undefined>() + client(undefined) client() - // @ts-expect-error - invalid input - client({ val: 123 }) }) it('accept signal', () => { @@ -95,6 +89,10 @@ describe('infer utilities', () => { } } + it('InferClientContext', () => { + expectTypeOf>().toEqualTypeOf<{ cache?: boolean }>() + }) + it('InferClientInputs', () => { expectTypeOf>().toEqualTypeOf<{ ping: string @@ -150,7 +148,7 @@ describe('infer utilities', () => { }>() }) - it('InferClientErrorUnion', () => { - expectTypeOf>().toEqualTypeOf | ORPCError<'NESTED_PING', unknown>>() + it('InferClientError', () => { + expectTypeOf>().toEqualTypeOf | ORPCError<'NESTED_PING', unknown>>() }) }) diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index f04c6d898..2d8dc7f35 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -1,40 +1,39 @@ import type { PromiseWithError } from '@orpc/shared' -export type HTTPPath = `/${string}` -export type HTTPMethod = 'HEAD' | 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' - -export type ClientContext = Record +export interface ClientContext { + [key: PropertyKey]: any +} export interface ClientOptions { - signal?: AbortSignal + signal?: AbortSignal | undefined lastEventId?: string | undefined context: T } export type FriendlyClientOptions = & Omit, 'context'> - & (Record extends T ? { context?: T } : { context: T }) + & (object extends T ? { context?: T } : { context: T }) -export type ClientRest = Record extends TClientContext +export type ClientRest = object extends TClientContext ? undefined extends TInput ? [input?: TInput, options?: FriendlyClientOptions] : [input: TInput, options?: FriendlyClientOptions] : [input: TInput, options: FriendlyClientOptions] -export type ClientPromiseResult = PromiseWithError - export interface Client { - (...rest: ClientRest): ClientPromiseResult + (...rest: ClientRest): PromiseWithError } export type NestedClient = Client | { [k: string]: NestedClient } -export type InferClientContext> = T extends NestedClient ? U : never +export type AnyNestedClient = NestedClient + +export type InferClientContext = T extends NestedClient ? U : never export interface ClientLink { - call: (path: readonly string[], input: unknown, options: ClientOptions) => Promise + call: (path: string[], input: unknown, options: ClientOptions) => Promise } /** @@ -42,11 +41,11 @@ export interface ClientLink { * * Produces a nested map where each endpoint's input type is preserved. */ -export type InferClientInputs> +export type InferClientInputs = T extends Client ? U : { - [K in keyof T]: T[K] extends NestedClient ? InferClientInputs : never + [K in keyof T]: T[K] extends AnyNestedClient ? InferClientInputs : never } /** @@ -55,11 +54,11 @@ export type InferClientInputs> * If an endpoint's input includes `{ body: ... }`, only the `body` portion is extracted. * Produces a nested map of body input types. */ -export type InferClientBodyInputs> +export type InferClientBodyInputs = T extends Client ? U extends { body: infer UBody } ? UBody : U : { - [K in keyof T]: T[K] extends NestedClient ? InferClientBodyInputs : never + [K in keyof T]: T[K] extends AnyNestedClient ? InferClientBodyInputs : never } /** @@ -67,11 +66,11 @@ export type InferClientBodyInputs> * * Produces a nested map where each endpoint's output type is preserved. */ -export type InferClientOutputs> +export type InferClientOutputs = T extends Client ? U : { - [K in keyof T]: T[K] extends NestedClient ? InferClientOutputs : never + [K in keyof T]: T[K] extends AnyNestedClient ? InferClientOutputs : never } /** @@ -80,11 +79,11 @@ export type InferClientOutputs> * If an endpoint's output includes `{ body: ... }`, only the `body` portion is extracted. * Produces a nested map of body output types. */ -export type InferClientBodyOutputs> +export type InferClientBodyOutputs = T extends Client ? U extends { body: infer UBody } ? UBody : U : { - [K in keyof T]: T[K] extends NestedClient ? InferClientBodyOutputs : never + [K in keyof T]: T[K] extends AnyNestedClient ? InferClientBodyOutputs : never } /** @@ -92,11 +91,11 @@ export type InferClientBodyOutputs> * * Produces a nested map where each endpoint's error type is preserved. */ -export type InferClientErrors> +export type InferClientErrors = T extends Client ? U : { - [K in keyof T]: T[K] extends NestedClient ? InferClientErrors : never + [K in keyof T]: T[K] extends AnyNestedClient ? InferClientErrors : never } /** @@ -104,9 +103,9 @@ export type InferClientErrors> * * Useful when you want to handle all possible errors from any endpoint at once. */ -export type InferClientErrorUnion> +export type InferClientError = T extends Client ? U : { - [K in keyof T]: T[K] extends NestedClient ? InferClientErrorUnion : never + [K in keyof T]: T[K] extends AnyNestedClient ? InferClientError : never }[keyof T] diff --git a/packages/client/src/utils.test-d.ts b/packages/client/src/utils.test-d.ts index dba101670..02c33ec92 100644 --- a/packages/client/src/utils.test-d.ts +++ b/packages/client/src/utils.test-d.ts @@ -1,65 +1,71 @@ -import type { OnFinishState } from '@orpc/shared' +import type { PromiseWithError } from '@orpc/shared' import type { ORPCError } from './error' -import type { Client, ClientContext, ClientPromiseResult } from './types' -import { isDefinedError } from './error' +import type { Client, ClientContext } from './types' +import { isInferableError } from './error-utils' import { consumeEventIterator, safe } from './utils' describe('safe', async () => { const client = {} as Client> it('tuple style', async () => { - const [error, data, isDefined, isSuccess] = await safe(client('123')) + const [error, data, inferableError, isSuccess] = await safe(client('123')) if (error || !isSuccess) { expectTypeOf(error).toEqualTypeOf>() expectTypeOf(data).toEqualTypeOf() - expectTypeOf(isDefined).toEqualTypeOf() + expectTypeOf(inferableError).toEqualTypeOf>() - if (isDefinedError(error)) { + if (isInferableError(error)) { expectTypeOf(error).toEqualTypeOf>() } - if (isDefined) { + if (inferableError) { expectTypeOf(error).toEqualTypeOf>() + expectTypeOf(inferableError).toEqualTypeOf>() } else { - expectTypeOf(error).toEqualTypeOf() + // TODO: FIX IT - ORPCError should not showing here + expectTypeOf(error).toEqualTypeOf>() + expectTypeOf(inferableError).toEqualTypeOf() } } else { expectTypeOf(error).toEqualTypeOf() expectTypeOf(data).toEqualTypeOf() - expectTypeOf(isDefined).toEqualTypeOf() + expectTypeOf(inferableError).toEqualTypeOf() } }) it('object style', async () => { - const { error, data, isDefined, isSuccess } = await safe(client('123')) + const { error, data, inferableError, isSuccess } = await safe(client('123')) if (error || !isSuccess) { expectTypeOf(error).toEqualTypeOf>() expectTypeOf(data).toEqualTypeOf() - expectTypeOf(isDefined).toEqualTypeOf() + expectTypeOf(inferableError).toEqualTypeOf>() - if (isDefinedError(error)) { + if (isInferableError(error)) { expectTypeOf(error).toEqualTypeOf>() } - if (isDefined) { + if (inferableError) { expectTypeOf(error).toEqualTypeOf>() + expectTypeOf(inferableError).toEqualTypeOf>() } else { - expectTypeOf(error).toEqualTypeOf() + // TODO: FIX IT - ORPCError should not showing here + expectTypeOf(error).toEqualTypeOf>() + expectTypeOf(inferableError).toEqualTypeOf() } } else { expectTypeOf(error).toEqualTypeOf() expectTypeOf(data).toEqualTypeOf() - expectTypeOf(isDefined).toEqualTypeOf() + expectTypeOf(inferableError).toEqualTypeOf() } }) - it('can catch Promise', async () => { + it('support regular Promise', async () => { const { error, data } = await safe({} as Promise) expectTypeOf(error).toEqualTypeOf() @@ -68,8 +74,8 @@ describe('safe', async () => { }) describe('consumeEventIterator', () => { - it('can infer types from ClientPromiseResult + AsyncGenerator', () => { - void consumeEventIterator({} as ClientPromiseResult, 'error-value'>, { + it('can infer types from PromiseWithError + AsyncGenerator', () => { + void consumeEventIterator({} as PromiseWithError, 'error-value'>, { onEvent: (message) => { expectTypeOf(message).toEqualTypeOf<'message-value'>() }, @@ -79,8 +85,15 @@ describe('consumeEventIterator', () => { onSuccess: (value) => { expectTypeOf(value).toEqualTypeOf<'done-value' | undefined>() }, - onFinish: (state) => { - expectTypeOf(state).toEqualTypeOf>() + onFinish: ([error, data, isSuccess]) => { + if (!error || isSuccess) { + expectTypeOf(error).toEqualTypeOf() + expectTypeOf(data).toEqualTypeOf<'done-value' | undefined>() + } + else { + expectTypeOf(error).toEqualTypeOf<'error-value'>() + expectTypeOf(data).toEqualTypeOf() + } }, }) }) @@ -96,8 +109,15 @@ describe('consumeEventIterator', () => { onSuccess: (value) => { expectTypeOf(value).toEqualTypeOf<'done-value' | undefined>() }, - onFinish: (state) => { - expectTypeOf(state).toEqualTypeOf>() + onFinish: ([error, data, isSuccess]) => { + if (!error || isSuccess) { + expectTypeOf(error).toEqualTypeOf() + expectTypeOf(data).toEqualTypeOf<'done-value' | undefined>() + } + else { + expectTypeOf(error).toEqualTypeOf() + expectTypeOf(data).toEqualTypeOf() + } }, }) }) diff --git a/packages/client/src/utils.test.ts b/packages/client/src/utils.test.ts index 4e1d5bad2..ecca9a61c 100644 --- a/packages/client/src/utils.test.ts +++ b/packages/client/src/utils.test.ts @@ -1,31 +1,43 @@ import { ORPCError } from './error' -import { consumeEventIterator, resolveFriendlyClientOptions, safe } from './utils' +import { consumeEventIterator, resolveClientRest, resolveFriendlyClientOptions, safe } from './utils' + +describe('resolveFriendlyClientOptions', () => { + it('works', () => { + expect(resolveFriendlyClientOptions({})).toEqual({ context: {} }) + expect(resolveFriendlyClientOptions({ context: { a: 1 } })).toEqual({ context: { a: 1 } }) + expect(resolveFriendlyClientOptions({ lastEventId: '123' })).toEqual({ context: {}, lastEventId: '123' }) + }) +}) + +describe('resolveClientRest', () => { + it('works', () => { + expect(resolveClientRest(['123'])).toEqual(['123', { context: {} }]) + expect(resolveClientRest(['123', { context: { a: 1 } }])).toEqual(['123', { context: { a: 1 } }]) + expect(resolveClientRest(['123', { lastEventId: '123' }])).toEqual(['123', { context: {}, lastEventId: '123' }]) + expect(resolveClientRest([])).toEqual([undefined, { context: {} }]) + }) +}) it('safe', async () => { const r1 = await safe(Promise.resolve(1)) - expect([...r1]).toEqual([null, 1, false, true]) - expect({ ...r1 }).toEqual(expect.objectContaining({ error: null, data: 1, isDefined: false, isSuccess: true })) + expect([...r1]).toEqual([null, 1, null, true]) + expect({ ...r1 }).toEqual(expect.objectContaining({ error: null, data: 1, inferableError: null, isSuccess: true })) const e2 = new Error('error') const r2 = await safe(Promise.reject(e2)) - expect([...r2]).toEqual([e2, undefined, false, false]) - expect({ ...r2 }).toEqual(expect.objectContaining({ error: e2, data: undefined, isDefined: false, isSuccess: false })) + expect([...r2]).toEqual([e2, undefined, null, false]) + expect({ ...r2 }).toEqual(expect.objectContaining({ error: e2, data: undefined, inferableError: null, isSuccess: false })) - const e3 = new ORPCError('BAD_GATEWAY', { defined: true }) + const e3 = new ORPCError('BAD_GATEWAY') + ;(e3 as any).inferable = true // simulate inferable error const r3 = await safe(Promise.reject(e3)) - expect([...r3]).toEqual([e3, undefined, true, false]) - expect({ ...r3 }).toEqual(expect.objectContaining({ error: e3, data: undefined, isDefined: true, isSuccess: false })) + expect([...r3]).toEqual([e3, undefined, e3, false]) + expect({ ...r3 }).toEqual(expect.objectContaining({ error: e3, data: undefined, inferableError: e3, isSuccess: false })) const e4 = new ORPCError('BAD_GATEWAY') const r4 = await safe(Promise.reject(e4)) - expect([...r4]).toEqual([e4, undefined, false, false]) - expect({ ...r4 }).toEqual(expect.objectContaining({ error: e4, data: undefined, isDefined: false, isSuccess: false })) -}) - -it('resolveFriendlyClientOptions', () => { - expect(resolveFriendlyClientOptions({})).toEqual({ context: {} }) - expect(resolveFriendlyClientOptions({ context: { a: 1 } })).toEqual({ context: { a: 1 } }) - expect(resolveFriendlyClientOptions({ lastEventId: '123' })).toEqual({ context: {}, lastEventId: '123' }) + expect([...r4]).toEqual([e4, undefined, null, false]) + expect({ ...r4 }).toEqual(expect.objectContaining({ error: e4, data: undefined, inferableError: null, isSuccess: false })) }) describe('consumeEventIterator', () => { @@ -41,7 +53,7 @@ describe('consumeEventIterator', () => { const onSuccess = vi.fn() const onFinish = vi.fn() - const unsubscribe = consumeEventIterator(iterator, { + void consumeEventIterator(iterator, { onEvent, onError, onSuccess, @@ -75,7 +87,7 @@ describe('consumeEventIterator', () => { const onSuccess = vi.fn() const onFinish = vi.fn() - const unsubscribe = consumeEventIterator(iterator, { + void consumeEventIterator(iterator, { onEvent, onError, onSuccess, @@ -97,11 +109,10 @@ describe('consumeEventIterator', () => { }) }) - it('on error without onError and onFinish', async () => { + it('on error without onError and onFinish', async ({ onTestFinished }) => { const unhandledRejectionHandler = vi.fn() process.on('unhandledRejection', unhandledRejectionHandler) - - afterEach(() => { + onTestFinished(() => { process.off('unhandledRejection', unhandledRejectionHandler) }) @@ -114,7 +125,7 @@ describe('consumeEventIterator', () => { const onEvent = vi.fn() - const unsubscribe = consumeEventIterator(iterator, { + void consumeEventIterator(iterator, { onEvent, }) @@ -132,7 +143,7 @@ describe('consumeEventIterator', () => { let cleanup = false const iterator = (async function* () { try { - await new Promise(resolve => setTimeout(resolve, 25)) + await new Promise(resolve => setTimeout(resolve, 10)) yield 1 yield 2 return 3 @@ -174,7 +185,7 @@ describe('consumeEventIterator', () => { let cleanup = false const iterator = (async function* () { try { - await new Promise(resolve => setTimeout(resolve, 25)) + await new Promise(resolve => setTimeout(resolve, 10)) yield 1 yield 2 return 3 diff --git a/packages/client/src/utils.ts b/packages/client/src/utils.ts index 962328a30..dd3ca6ee6 100644 --- a/packages/client/src/utils.ts +++ b/packages/client/src/utils.ts @@ -1,54 +1,67 @@ -import type { OnFinishState, ThrowableError } from '@orpc/shared' -import type { ORPCError } from './error' -import type { ClientContext, ClientOptions, ClientPromiseResult, FriendlyClientOptions } from './types' -import { isDefinedError } from './error' +import type { PromiseWithError, ThrowableError } from '@orpc/shared' +import type { AnyORPCError } from './error' +import type { ClientContext, ClientOptions, ClientRest, FriendlyClientOptions } from './types' +import { isInferableError } from './error-utils' + +export function resolveFriendlyClientOptions(options: FriendlyClientOptions): ClientOptions { + return { + ...options, + context: options.context ?? {} as T, // Context only optional if all fields are optional + } +} + +export function resolveClientRest(rest: ClientRest): [input: TInput, options: ClientOptions] { + return [ + rest[0] as TInput, // rest[0] can be undefined if TInput is optional, + resolveFriendlyClientOptions(rest[1] ?? {} as FriendlyClientOptions), // rest[1] can be undefined if all fields of FriendlyClientOptions are optional + ] +} export type SafeResult - = | [error: null, data: TOutput, isDefined: false, isSuccess: true] - & { error: null, data: TOutput, isDefined: false, isSuccess: true } - | [error: Exclude>, data: undefined, isDefined: false, isSuccess: false] - & { error: Exclude>, data: undefined, isDefined: false, isSuccess: false } - | [error: Extract>, data: undefined, isDefined: true, isSuccess: false] - & { error: Extract>, data: undefined, isDefined: true, isSuccess: false } + = | [error: null, data: TOutput, inferableError: null, isSuccess: true] + & { error: null, data: TOutput, inferableError: null, isSuccess: true } + | [error: Exclude, data: undefined, inferableError: null, isSuccess: false] + & { error: Exclude, data: undefined, inferableError: null, isSuccess: false } + | [error: Extract, data: undefined, inferableError: Extract, isSuccess: false] + & { error: Extract, data: undefined, inferableError: Extract, isSuccess: false } /** - * Works like try/catch, but can infer error types. + * Works like try/catch, but help you infer the error type if it is inferable ORPCError. * - * @info support both tuple `[error, data, isDefined, isSuccess]` and object `{ error, data, isDefined, isSuccess }` styles. - * @see {@link https://orpc.dev/docs/client/error-handling Client Error Handling Docs} + * @example + * ```ts + * const [error, data, inferableError, isSuccess] = await safe(client(...)) + * // or const { error, data, inferableError, isSuccess } = await safe(client(...)) + * + * if (inferableError) { + * console.log(inferableError) // or error, both are well typed + * } */ -export async function safe(promise: ClientPromiseResult): Promise> { +export async function safe(promise: PromiseWithError): Promise> { try { const output = await promise return Object.assign( - [null, output, false, true] satisfies [null, TOutput, false, true], - { error: null, data: output, isDefined: false as const, isSuccess: true as const }, + [null, output, null, true] satisfies [null, TOutput, null, true], + { error: null, data: output, inferableError: null, isSuccess: true as const }, ) } catch (e) { const error = e as TError - if (isDefinedError(error)) { + if (isInferableError(error)) { return Object.assign( - [error, undefined, true, false] satisfies [typeof error, undefined, true, false], - { error, data: undefined, isDefined: true as const, isSuccess: false as const }, + [error, undefined, error, false] satisfies [typeof error, undefined, typeof error, false], + { error, data: undefined, inferableError: error, isSuccess: false as const }, ) } return Object.assign( - [error as Exclude>, undefined, false, false] satisfies [Exclude>, undefined, false, false], - { error: error as Exclude>, data: undefined, isDefined: false as const, isSuccess: false as const }, + [error as Exclude, undefined, null, false] satisfies [Exclude, undefined, null, false], + { error: error as Exclude, data: undefined, inferableError: null, isSuccess: false as const }, ) } } -export function resolveFriendlyClientOptions(options: FriendlyClientOptions): ClientOptions { - return { - ...options, - context: options.context ?? {} as T, // Context only optional if all fields are optional - } -} - export interface ConsumeEventIteratorOptions { /** * Called on each event @@ -69,21 +82,21 @@ export interface ConsumeEventIteratorOptions { * * @info If iterator is canceled, `undefined` can be passed on success */ - onFinish?: (state: OnFinishState) => void + onFinish?: (state: [error: TError, data: undefined, isSuccess: false] | [error: null, data: TReturn | undefined, isSuccess: true]) => void } /** * Consumes an event iterator with lifecycle callbacks * - * @warning If no `onError` or `onFinish` is provided, unhandled rejections will be thrown + * @warning If no `onError` or `onFinish` is provided, error will be thrown into unhandled rejection channel. * @return unsubscribe callback */ export function consumeEventIterator( - iterator: AsyncIterator | ClientPromiseResult, TError>, + iterator: AsyncIterator | PromiseWithError, TError>, options: ConsumeEventIteratorOptions, ): () => Promise { void (async () => { - let onFinishState: OnFinishState + let onFinishState: [error: TError, data: undefined, isSuccess: false] | [error: null, data: TReturn | undefined, isSuccess: true] try { const resolvedIterator = await iterator @@ -107,7 +120,7 @@ export function consumeEventIterator( /** * If no `onError` or `onFinish` is provided, unhandled rejections will be thrown - * This is best practice for error handling - error should always be handled + * This is best practice for error handling - error should not be silently ignored */ if (!options.onError && !options.onFinish) { throw error diff --git a/packages/client/tests/e2e.test-d.ts b/packages/client/tests/e2e.test-d.ts deleted file mode 100644 index 54cebaee9..000000000 --- a/packages/client/tests/e2e.test-d.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ORPCError } from '@orpc/contract' -import { safe } from '../src' -import { orpc } from './helpers' - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('e2e', () => { - it('infer input', () => { - orpc.post.find({ id: '123' }) - // @ts-expect-error - invalid input - orpc.post.find({ id: 123 }) - - orpc.post.create({ title: 'hello', thumbnail: new File(['hello'], 'hello.txt') }) - // @ts-expect-error - invalid input - orpc.post.create({ }) - }) - - it('infer output', async () => { - expectTypeOf(await orpc.post.find({ id: '123' })).toEqualTypeOf<{ id: string, title: string, thumbnail?: string }>() - expectTypeOf(await orpc.post.create({ title: 'hello' })).toEqualTypeOf<{ id: string, title: string, thumbnail?: string }>() - }) - - it('infer errors', async () => { - const [error] = await safe(orpc.post.find({ id: '123' })) - - expectTypeOf(error).toEqualTypeOf< - | null - | Error - | ORPCError<'NOT_FOUND', { id: string }> - >() - - const [error2] = await safe(orpc.post.create({ title: 'title' })) - - expectTypeOf(error2).toEqualTypeOf< - | null - | Error - | ORPCError<'CONFLICT', { title: string, thumbnail?: File }> - | ORPCError<'FORBIDDEN', { title: string, thumbnail?: File }> - >() - }) - - it('infer client context', async () => { - orpc.post.find({ id: '123' }, { context: { cache: 'force' } }) - // @ts-expect-error -- invalid context - orpc.post.find({ id: '123' }, { context: { cache: 123 } }) - }) -}) diff --git a/packages/client/tests/e2e.test.ts b/packages/client/tests/e2e.test.ts deleted file mode 100644 index e7e041b77..000000000 --- a/packages/client/tests/e2e.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ORPCError } from '@orpc/contract' -import { safe } from '../src' -import { orpc } from './helpers' - -describe('e2e', () => { - it('on success', () => { - expect( - orpc.post.find({ id: '1' }), - ).resolves.toEqual({ id: '1', title: 'title-1' }) - - expect( - orpc.post.create({ title: 'new-title', thumbnail: new File(['hello'], 'hello.txt') }), - ).resolves.toEqual({ id: 'id-new-title', title: 'new-title', thumbnail: 'hello.txt' }) - }) - - it('on error', async () => { - const [error,, isDefined] = await safe(orpc.post.find({ id: 'NOT_FOUND' })) - - expect(isDefined).toBe(true) - expect(error).toBeInstanceOf(ORPCError) - expect((error as any).data).toEqual({ id: 'NOT_FOUND' }) - - const [error2,, isDefined2] = await safe(orpc.post.create({ title: 'CONFLICT' })) - - expect(isDefined2).toBe(true) - expect(error2).toBeInstanceOf(ORPCError) - expect((error2 as any).data).toEqual({ title: 'CONFLICT' }) - - // @ts-expect-error - invalid input - const [error3,, isDefined3] = await safe(orpc.post.create({ })) - - expect(isDefined3).toBe(false) - expect(error3).toBeInstanceOf(ORPCError) - expect((error3 as any).code).toEqual('BAD_REQUEST') - expect((error3 as any).data).toEqual({ - issues: [expect.objectContaining({ - message: expect.any(String), - path: ['title'], - })], - }) - }) - - it('with client context', async () => { - expect( - orpc.post.find({ id: '1' }, { context: { cache: 'force' } }), - ).rejects.toThrow('cache=force is not supported') - }) -}) diff --git a/packages/client/tests/helpers.ts b/packages/client/tests/helpers.ts deleted file mode 100644 index d273294ef..000000000 --- a/packages/client/tests/helpers.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { RouterClient } from '@orpc/server' -import { oc } from '@orpc/contract' -import { implement, os } from '@orpc/server' -import { RPCHandler } from '@orpc/server/fetch' -import * as z from 'zod' -import { createORPCClient } from '../src' -import { RPCLink } from '../src/adapters/fetch' - -export const PostFindInput = z.object({ - id: z.string(), -}) - -export const PostFindOutput = z.object({ - id: z.string(), - title: z.string(), - thumbnail: z.string().optional(), -}) - -export const PostListInput = z.object({ - cursor: z.number().default(0), - keyword: z.string().optional(), -}) - -export const PostListOutput = z.object({ - items: z.array(PostFindOutput), - nextCursor: z.number(), -}) - -export const PostCreateInput = z.object({ - title: z.string(), - thumbnail: z.file().optional(), -}) - -export const PostCreateOutput = PostFindOutput - -export const contract = oc.router({ - post: { - find: oc - .input(PostFindInput) - .output(PostFindOutput) - .errors({ - NOT_FOUND: { - message: 'Post not found', - data: PostFindInput, - }, - }), - list: oc - .input(PostListInput) - .output(PostListOutput) - .errors({ - TOO_MANY_REQUESTS: { - message: 'Too many requests', - data: PostListInput, - }, - }), - create: oc - .input(PostCreateInput) - .output(PostCreateOutput) - .errors({ - CONFLICT: { - message: 'Duplicated title', - data: PostCreateInput, - }, - FORBIDDEN: { - message: 'You are not allowed to create post', - data: PostCreateInput, - }, - }), - }, -}) - -export const router = implement(contract).router({ - post: os.lazy(() => Promise.resolve({ // this lazy help tests more real, more complex - default: { - find: implement(contract.post.find).handler(async ({ input, errors }) => { - if (input.id === 'NOT_FOUND') { - throw errors.NOT_FOUND({ - data: input, - }) - } - - return { - id: input.id, - title: `title-${input.id}`, - } - }), - list: implement(contract.post.list).handler(async ({ input, errors }) => { - if (input.keyword === 'TOO_MANY_REQUESTS') { - throw errors.TOO_MANY_REQUESTS({ - data: input, - }) - } - - return { - items: [ - { - id: `id-${input.cursor}`, - title: `title-${input.cursor}`, - }, - ], - nextCursor: input.cursor + 1, - } - }), - create: implement(contract.post.create).handler(async ({ input, errors }) => { - if (input.title === 'CONFLICT') { - throw errors.CONFLICT({ - data: input, - }) - } - - if (input.title === 'FORBIDDEN') { - throw errors.FORBIDDEN({ - data: input, - }) - } - - return { - id: `id-${input.title}`, - title: input.title, - thumbnail: input.thumbnail?.name, - } - }), - }, - })), -}) - -const rpcHandler = new RPCHandler(router) - -export type ClientContext = { cache?: string } - -const rpcLink = new RPCLink({ - url: 'http://localhost:3000', - fetch: async (url, init, { context }) => { - if (context?.cache) { - throw new Error(`cache=${context.cache} is not supported`) - } - - const request = new Request(url, init) - - const { matched, response } = await rpcHandler.handle(request) - - if (!matched) { - throw new Error('No procedure matched') - } - - return response - }, -}) - -export const orpc: RouterClient = createORPCClient(rpcLink) diff --git a/packages/client/tests/shared.ts b/packages/client/tests/shared.ts deleted file mode 100644 index 8c7d5b8fd..000000000 --- a/packages/client/tests/shared.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { RouterClient } from '@orpc/server' -import { RPCHandler } from '@orpc/server/fetch' -import { router, streamed } from '../../server/tests/shared' -import { createORPCClient } from '../src' -import { RPCLink } from '../src/adapters/fetch' - -const rpcHandler = new RPCHandler(router) - -type ClientContext = { cache?: string } - -const rpcLink = new RPCLink({ - url: 'http://localhost:3000', - fetch: async (url, init, { context }) => { - if (context?.cache) { - throw new Error(`cache=${context.cache} is not supported`) - } - - const request = new Request(url, init) - - const { matched, response } = await rpcHandler.handle(request, { - context: { db: 'postgres' }, - }) - - if (!matched) { - throw new Error('No procedure matched') - } - - return response - }, -}) - -export const orpc: RouterClient = createORPCClient(rpcLink) - -const streamedHandler = new RPCHandler({ streamed }) - -export const streamedOrpc: RouterClient<{ streamed: typeof streamed }, ClientContext> = createORPCClient(new RPCLink({ - url: 'http://localhost:3000', - fetch: async (url, init) => { - const { response } = await streamedHandler.handle(new Request(url, init), { - context: { db: 'postgres' }, - }) - - return response ?? new Response('not found', { status: 404 }) - }, -})) - -enum Test { - A = 1, - B = 2, - C = 'C', - D = 'D', -} - -/** - * The data types that oRPC guarantees to be supported. - */ -export const supportedDataTypes: { name: string, value: unknown, expected: unknown }[] = [ - { - name: 'enum', - value: Test.B, - expected: Test.B, - }, - { - name: 'string', - value: 'some-string', - expected: 'some-string', - }, - { - name: 'number', - value: 123, - expected: 123, - }, - { - name: 'NaN', - value: Number.NaN, - expected: Number.NaN, - }, - { - name: 'true', - value: true, - expected: true, - }, - { - name: 'false', - value: false, - expected: false, - }, - { - name: 'null', - value: null, - expected: null, - }, - { - name: 'undefined', - value: undefined, - expected: undefined, - }, - { - name: 'date', - value: new Date('2023-01-01'), - expected: new Date('2023-01-01'), - }, - { - name: 'Invalid Date', - value: new Date('Invalid'), - expected: new Date('Invalid'), - }, - { - name: 'BigInt', - value: 99999999999999999999999999999n, - expected: 99999999999999999999999999999n, - }, - { - name: 'regex without flags', - value: /npa|npb/, - expected: /npa|npb/, - }, - { - name: 'regex with flags', - value: /uic/gi, - expected: /uic/gi, - }, - { - name: 'URL', - value: new URL('https://orpc.dev'), - expected: new URL('https://orpc.dev'), - }, - { - name: 'object', - value: { a: 1, b: 2, c: 3 }, - expected: { a: 1, b: 2, c: 3 }, - }, - { - name: 'array', - value: [1, 2, 3], - expected: [1, 2, 3], - }, - { - name: 'map', - value: new Map([[1, 2], [3, 4]]), - expected: new Map([[1, 2], [3, 4]]), - }, - { - name: 'set', - value: new Set([1, 2, 3]), - expected: new Set([1, 2, 3]), - }, - { - name: 'blob', - value: new Blob(['blob'], { type: 'text/plain' }), - expected: expect.toSatisfy((file: any) => { - expect(file).toBeInstanceOf(Blob) - expect(file.type).toBe('text/plain') - expect(file.size).toBe(4) - - return true - }), - }, - { - name: 'file', - value: new File(['"name"'], 'file.json', { type: 'application/json' }), - expected: expect.toSatisfy((file: any) => { - expect(file).toBeInstanceOf(File) - expect(file.name).toBe('file.json') - expect(file.type).toBe('application/json') - expect(file.size).toBe(6) - - return true - }), - }, -] diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 528c4dad0..ad530f45f 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -1,12 +1,9 @@ { "extends": "../../tsconfig.lib.json", "references": [ - { "path": "../shared" }, - { "path": "../standard-server" }, - { "path": "../standard-server-fetch" }, - { "path": "../standard-server-peer" } + { "path": "../shared" } ], - "include": ["src"], + "include": ["package.json", "src"], "exclude": [ "**/*.test.*", "**/*.bench.*", diff --git a/packages/contract/README.md b/packages/contract/README.md index 7c5de21d3..539d8b7c0 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -1,8 +1,4 @@ -
- oRPC logo -
- -

+

oRPC - Typesafe APIs Made Simple 🪄

-

Typesafe APIs Made Simple 🪄

+## Documentation -**oRPC is a powerful combination of RPC and OpenAPI**, makes it easy to build APIs that are end-to-end type-safe and adhere to OpenAPI standards +You can read the documentation [here](https://orpc.dev). ---- +## Packages -## Highlights +**Core** -- **🔗 End-to-End Type Safety**: Ensure type-safe inputs, outputs, and errors from client to server. -- **📘 First-Class OpenAPI**: Built-in support that fully adheres to the OpenAPI standard. -- **📝 Contract-First Development**: Optionally define your API contract before implementation. -- **🔍 First-Class OpenTelemetry**: Seamlessly integrate with OpenTelemetry for observability. -- **⚙️ Framework Integrations**: Seamlessly integrate with TanStack Query (React, Vue, Solid, Svelte, Angular), SWR, Pinia Colada, and more. -- **🚀 Server Actions**: Fully compatible with React Server Actions on Next.js, TanStack Start, and other platforms. -- **🔠 Standard Schema Support**: Works out of the box with Zod, Valibot, ArkType, and other schema validators. -- **🗃️ Native Types**: Supports native types like Date, File, Blob, BigInt, URL, and more. -- **⏱️ Lazy Router**: Enhance cold start times with our lazy routing feature. -- **📡 SSE & Streaming**: Enjoy full type-safe support for SSE and streaming. -- **🌍 Multi-Runtime Support**: Fast and lightweight on Cloudflare, Deno, Bun, Node.js, and beyond. -- **🔌 Extendability**: Easily extend functionality with plugins, middleware, and interceptors. +- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Define type-safe contracts for your API. +- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Implement your API or contract on the server. +- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API from the client with full type safety. +- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Bring OpenAPI compatibility to your APIs. -## Documentation +**Schema validation** -You can find the full documentation [here](https://orpc.dev). +- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): Integrate with [Zod](https://zod.dev/). +- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): Integrate with [Valibot](https://valibot.dev/). +- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): Integrate with [ArkType](https://arktype.io/). -## Packages +**Framework & ecosystem integrations** -- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Build your API contract. -- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build your API or implement API contract. -- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API on the client with type-safety. -- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Generate OpenAPI specs and handle OpenAPI requests. -- [@orpc/otel](https://www.npmjs.com/package/@orpc/otel): [OpenTelemetry](https://opentelemetry.io/) integration for observability. -- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Deeply integrate oRPC with [NestJS](https://nestjs.com/). -- [@orpc/react](https://www.npmjs.com/package/@orpc/react): Utilities for integrating oRPC with React and React Server Actions. -- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): [TanStack Query](https://tanstack.com/query/latest) integration. -- [@orpc/experimental-react-swr](https://www.npmjs.com/package/@orpc/experimental-react-swr): [SWR](https://swr.vercel.app/) integration. -- [@orpc/vue-colada](https://www.npmjs.com/package/@orpc/vue-colada): Integration with [Pinia Colada](https://pinia-colada.esm.dev/). -- [@orpc/hey-api](https://www.npmjs.com/package/@orpc/hey-api): [Hey API](https://heyapi.dev/) integration. -- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): More schemas that [Zod](https://zod.dev/) doesn't support yet. -- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): OpenAPI spec generation from [Valibot](https://valibot.dev/). -- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): OpenAPI spec generation from [ArkType](https://arktype.io/). - -## `@orpc/contract` - -Build your API contract. Read the [documentation](https://orpc.dev/docs/contract-first/define-contract) for more information. - -```ts -export const PlanetSchema = z.object({ - id: z.number().int().min(1), - name: z.string(), - description: z.string().optional(), -}) - -export const listPlanetContract = oc - .input( - z.object({ - limit: z.number().int().min(1).max(100).optional(), - cursor: z.number().int().min(0).default(0), - }), - ) - .output(z.array(PlanetSchema)) - -export const findPlanetContract = oc - .input(PlanetSchema.pick({ id: true })) - .output(PlanetSchema) - -export const createPlanetContract = oc - .input(PlanetSchema.omit({ id: true })) - .output(PlanetSchema) - -export const contract = { - planet: { - list: listPlanetContract, - find: findPlanetContract, - create: createPlanetContract, - }, -} -``` +- [@orpc/next](https://www.npmjs.com/package/@orpc/react): Use oRPC with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). +- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). +- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). + +**Built-in features** + +- [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub. +- [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting. +- [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. + +**Observability** + +- [@orpc/opentelemetry](https://www.npmjs.com/package/@orpc/opentelemetry): Instrument your API with [OpenTelemetry](https://opentelemetry.io/). +- [@orpc/pino](https://www.npmjs.com/package/@orpc/pino): Log with [Pino](https://getpino.io/). +- [@orpc/evlog](https://www.npmjs.com/package/@orpc/evlog): Log with [Evlog](https://evlog.dev/). ## Sponsors -If you find oRPC valuable and would like to support its development, you can do so here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). +Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). Every bit helps! 🚀 ### 🏆 Platinum Sponsor @@ -222,6 +176,13 @@ If you find oRPC valuable and would like to support its development, you can do plancraft

+## 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/contract/package.json b/packages/contract/package.json index 3ed19a508..a2b94f722 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -1,7 +1,7 @@ { "name": "@orpc/contract", "type": "module", - "version": "1.14.6", + "version": "1.13.4", "license": "MIT", "homepage": "https://orpc.dev", "repository": { @@ -15,6 +15,7 @@ "sideEffects": false, "publishConfig": { "exports": { + "./package.json": "./package.json", ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs", @@ -28,6 +29,7 @@ } }, "exports": { + "./package.json": "./package.json", ".": "./src/index.ts", "./plugins": "./src/plugins/index.ts" }, @@ -36,18 +38,16 @@ ], "scripts": { "build": "unbuild", - "build:watch": "pnpm run build --watch", "type:check": "tsc -b" }, "dependencies": { "@orpc/client": "workspace:*", "@orpc/shared": "workspace:*", - "@standard-schema/spec": "^1.1.0", - "openapi-types": "^12.1.3" + "@standard-schema/spec": "^1.1.0" }, "devDependencies": { - "arktype": "2.2.0", + "arktype": "^2.1.29", "valibot": "^1.2.0", - "zod": "^4.3.6" + "zod": "^4.4.3" } } diff --git a/packages/contract/src/builder-variants.test-d.ts b/packages/contract/src/builder-variants.test-d.ts index b40d515fd..d0544f3da 100644 --- a/packages/contract/src/builder-variants.test-d.ts +++ b/packages/contract/src/builder-variants.test-d.ts @@ -1,32 +1,32 @@ -import type { OmitChainMethodDeep } from '@orpc/shared' -import type { baseErrorMap, BaseMeta, inputSchema, outputSchema } from '../tests/shared' -import type { ContractBuilder } from './builder' -import type { ContractProcedureBuilder, ContractProcedureBuilderWithInput, ContractProcedureBuilderWithInputOutput, ContractProcedureBuilderWithOutput, ContractRouterBuilder } from './builder-variants' -import type { MergedErrorMap } from './error' -import type { ContractProcedure } from './procedure' -import type { EnhancedContractRouter } from './router-utils' -import type { Schema } from './schema' -import { generalSchema, ping, pong } from '../tests/shared' - -const generalBuilder = {} as ContractBuilder - -describe('ContractProcedureBuilder', () => { - const builder = {} as ContractProcedureBuilder - - it('backward compatibility', () => { - const expected = {} as OmitChainMethodDeep - - expectTypeOf(builder).toMatchTypeOf(expected) - expectTypeOf().toEqualTypeOf() - }) +import type { ProcedureContractBuilderWithInput, ProcedureContractBuilderWithInputOutput, ProcedureContractBuilderWithOutput } from './builder-variants' +import type { MergedErrorMap } from './error-utils' +import type { Meta } from './meta' +import type { MergedSchema, Schema } from './schema' +import { describe, expectTypeOf, it } from 'vitest' +import { z } from 'zod' + +const errorMap = { + BASE: { + data: z.object({ id: z.string() }), + message: 'base', + }, +} + +// Schemas should have distinct TInput and TOutput types to ensure correct inference. +const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) +const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) + +describe('ProcedureContractBuilderWithInput', () => { + const builder = {} as ProcedureContractBuilderWithInput< + typeof schema1, + typeof errorMap + > it('.errors', () => { - expectTypeOf(builder.errors({ INVALID: { message: 'invalid' }, OVERRIDE: { message: 'override' } })).toEqualTypeOf< - ContractProcedureBuilder< - typeof inputSchema, - typeof outputSchema, - MergedErrorMap, - BaseMeta + expectTypeOf(builder.errors({ INVALID: { message: 'invalid' } })).toEqualTypeOf< + ProcedureContractBuilderWithInput< + typeof schema1, + MergedErrorMap > >() @@ -35,298 +35,150 @@ describe('ContractProcedureBuilder', () => { }) it('.meta', () => { - expectTypeOf(builder.meta({ log: true })).toEqualTypeOf< - ContractProcedureBuilder< - typeof inputSchema, - typeof outputSchema, - typeof baseErrorMap, - BaseMeta - > - >() + const plugin = { name: 'test', init: (m: Meta) => m } + expectTypeOf(builder.meta(plugin)).toEqualTypeOf() // @ts-expect-error - invalid meta - builder.meta({ meta: 'INVALID' }) - }) - - it('.route', () => { - expectTypeOf(builder.route({ method: 'GET' })).toEqualTypeOf< - ContractProcedureBuilder< - typeof inputSchema, - typeof outputSchema, - typeof baseErrorMap, - BaseMeta - > - >() - - // @ts-expect-error - invalid method - builder.route({ method: 'INVALID' }) + builder.meta({ } as MetaPlugin, any, any>) }) it('.input', () => { - expectTypeOf(builder.input(generalSchema)).toEqualTypeOf< - ContractProcedureBuilderWithInput< - typeof generalSchema, - typeof outputSchema, - typeof baseErrorMap, - BaseMeta + const extraSchema = z.object({ extra: z.string() }) + + expectTypeOf(builder.input(extraSchema)).toEqualTypeOf< + ProcedureContractBuilderWithInput< + MergedSchema, + typeof errorMap > >() // @ts-expect-error - schema is invalid - builder.input({}) + builder.input('invalid') }) it('.output', () => { - expectTypeOf(builder.output(generalSchema)).toEqualTypeOf< - ContractProcedureBuilderWithOutput< - typeof inputSchema, - typeof generalSchema, - typeof baseErrorMap, - BaseMeta + expectTypeOf(builder.output(schema2)).toEqualTypeOf< + ProcedureContractBuilderWithInputOutput< + typeof schema1, + typeof schema2, + typeof errorMap > >() // @ts-expect-error - schema is invalid - builder.output({}) + builder.output('invalid') }) }) -describe('ContractProcedureBuilderWithInput', () => { - const builder = {} as ContractProcedureBuilderWithInput - - it('backward compatibility', () => { - const expected = {} as OmitChainMethodDeep - - expectTypeOf(builder).toMatchTypeOf(expected) - expectTypeOf().toEqualTypeOf() - }) +describe('ProcedureContractBuilderWithOutput', () => { + const builder = {} as ProcedureContractBuilderWithOutput< + typeof schema2, + typeof errorMap + > it('.errors', () => { - expectTypeOf(builder.errors({ INVALID: { message: 'invalid' }, OVERRIDE: { message: 'override' } })).toEqualTypeOf< - ContractProcedureBuilderWithInput< - typeof inputSchema, - typeof outputSchema, - MergedErrorMap, - BaseMeta + expectTypeOf(builder.errors({ INVALID: { message: 'invalid' } })).toEqualTypeOf< + ProcedureContractBuilderWithOutput< + typeof schema2, + MergedErrorMap > >() - // @ts-expect-error - schema is invalid - builder.errors({ TOO_MANY_REQUESTS: { data: {} } }) + // @ts-expect-error - invalid errors + builder.errors({ INTERNAL_SERVER_ERROR: { data: {} } }) }) it('.meta', () => { - expectTypeOf(builder.meta({ log: true })).toEqualTypeOf< - ContractProcedureBuilderWithInput< - typeof inputSchema, - typeof outputSchema, - typeof baseErrorMap, - BaseMeta - > - >() + const plugin = { name: 'test', init: (m: Meta) => m } + expectTypeOf(builder.meta(plugin)).toEqualTypeOf() // @ts-expect-error - invalid meta - builder.meta({ meta: 'INVALID' }) + builder.meta({ } as MetaPlugin, any, any>) }) - it('.route', () => { - expectTypeOf(builder.route({ method: 'GET' })).toEqualTypeOf< - ContractProcedureBuilderWithInput< - typeof inputSchema, - typeof outputSchema, - typeof baseErrorMap, - BaseMeta + it('.input', () => { + expectTypeOf(builder.input(schema1)).toEqualTypeOf< + ProcedureContractBuilderWithInputOutput< + typeof schema1, + typeof schema2, + typeof errorMap > >() - // @ts-expect-error - invalid method - builder.route({ method: 'INVALID' }) + // @ts-expect-error - schema is invalid + builder.input('invalid') }) it('.output', () => { - expectTypeOf(builder.output(generalSchema)).toEqualTypeOf< - ContractProcedureBuilderWithInputOutput< - typeof inputSchema, - typeof generalSchema, - typeof baseErrorMap, - BaseMeta + const extraSchema = z.object({ extra: z.string() }) + + expectTypeOf(builder.output(extraSchema)).toEqualTypeOf< + ProcedureContractBuilderWithOutput< + MergedSchema, + typeof errorMap > >() // @ts-expect-error - schema is invalid - builder.output({}) + builder.output('invalid') }) }) -describe('ContractProcedureBuilderWithOutput', () => { - const builder = {} as ContractProcedureBuilderWithOutput - - it('backward compatibility', () => { - const expected = {} as OmitChainMethodDeep - - expectTypeOf(builder).toMatchTypeOf(expected) - expectTypeOf().toEqualTypeOf() - }) +describe('ProcedureContractBuilderWithInputOutput', () => { + const builder = {} as ProcedureContractBuilderWithInputOutput< + typeof schema1, + typeof schema2, + typeof errorMap + > it('.errors', () => { - expectTypeOf(builder.errors({ INVALID: { message: 'invalid' }, OVERRIDE: { message: 'override' } })).toEqualTypeOf< - ContractProcedureBuilderWithOutput< - typeof inputSchema, - typeof outputSchema, - MergedErrorMap, - BaseMeta + expectTypeOf(builder.errors({ INVALID: { message: 'invalid' } })).toEqualTypeOf< + ProcedureContractBuilderWithInputOutput< + typeof schema1, + typeof schema2, + MergedErrorMap > >() - // @ts-expect-error - schema is invalid - builder.errors({ TOO_MANY_REQUESTS: { data: {} } }) + // @ts-expect-error - invalid errors + builder.errors({ INTERNAL_SERVER_ERROR: { data: {} } }) }) it('.meta', () => { - expectTypeOf(builder.meta({ log: true })).toEqualTypeOf< - ContractProcedureBuilderWithOutput< - typeof inputSchema, - typeof outputSchema, - typeof baseErrorMap, - BaseMeta - > - >() + const plugin = { name: 'test', init: (m: Meta) => m } + expectTypeOf(builder.meta(plugin)).toEqualTypeOf() // @ts-expect-error - invalid meta - builder.meta({ meta: 'INVALID' }) - }) - - it('.route', () => { - expectTypeOf(builder.route({ method: 'GET' })).toEqualTypeOf< - ContractProcedureBuilderWithOutput< - typeof inputSchema, - typeof outputSchema, - typeof baseErrorMap, - BaseMeta - > - >() - - // @ts-expect-error - invalid method - builder.route({ method: 'INVALID' }) + builder.meta({ } as MetaPlugin, any, any>) }) it('.input', () => { - expectTypeOf(builder.input(generalSchema)).toEqualTypeOf< - ContractProcedureBuilderWithInputOutput< - typeof generalSchema, - typeof outputSchema, - typeof baseErrorMap, - BaseMeta - > - >() + const extraSchema = z.object({ extra: z.string() }) - // @ts-expect-error - schema is invalid - builder.input({}) - }) -}) - -it('ContractProcedureBuilderWithInputOutput', () => { - const builder = {} as ContractProcedureBuilderWithInputOutput - - it('backward compatibility', () => { - const expected = {} as OmitChainMethodDeep - - expectTypeOf(builder).toMatchTypeOf(expected) - expectTypeOf().toEqualTypeOf() - }) - - it('.errors', () => { - expectTypeOf(builder.errors({ INVALID: { message: 'invalid' }, OVERRIDE: { message: 'override' } })).toEqualTypeOf< - ContractProcedureBuilderWithInputOutput< - typeof inputSchema, - typeof outputSchema, - MergedErrorMap, - BaseMeta + expectTypeOf(builder.input(extraSchema)).toEqualTypeOf< + ProcedureContractBuilderWithInputOutput< + MergedSchema, + typeof schema2, + typeof errorMap > >() // @ts-expect-error - schema is invalid - builder.errors({ TOO_MANY_REQUESTS: { data: {} } }) + builder.input('invalid') }) - it('.meta', () => { - expectTypeOf(builder.meta({ log: true })).toEqualTypeOf< - ContractProcedureBuilderWithInputOutput< - typeof inputSchema, - typeof outputSchema, - typeof baseErrorMap, - BaseMeta - > - >() - - // @ts-expect-error - invalid meta - builder.meta({ meta: 'INVALID' }) - }) + it('.output', () => { + const extraSchema = z.object({ extra: z.string() }) - it('.route', () => { - expectTypeOf(builder.route({ method: 'GET' })).toEqualTypeOf< - ContractProcedureBuilderWithInputOutput< - typeof inputSchema, - typeof outputSchema, - typeof baseErrorMap, - BaseMeta + expectTypeOf(builder.output(extraSchema)).toEqualTypeOf< + ProcedureContractBuilderWithInputOutput< + typeof schema1, + MergedSchema, + typeof errorMap > >() - // @ts-expect-error - invalid method - builder.route({ method: 'INVALID' }) - }) -}) - -describe('ContractRouterBuilder', () => { - const builder = {} as ContractRouterBuilder - - it('backward compatibility', () => { - const expected = {} as OmitChainMethodDeep - - // expectTypeOf(builder).toMatchTypeOf(expected) - expectTypeOf().toEqualTypeOf() - }) - - it('.prefix', () => { - expectTypeOf(builder.prefix('/api')).toEqualTypeOf< - ContractRouterBuilder - >() - - // @ts-expect-error - invalid prefix - builder.prefix(1) - }) - - it('.tag', () => { - expectTypeOf(builder.tag('tag1', 'tag2')).toEqualTypeOf< - ContractRouterBuilder - >() - - // @ts-expect-error - invalid tag - builder.tag(1) - }) - - it('.router', () => { - const router = { - ping, - pong, - } - - expectTypeOf(builder.router(router)).toEqualTypeOf< - EnhancedContractRouter - >() - - // @ts-expect-error - invalid router - builder.router(123) - - builder.router({ - // @ts-expect-error - conflict meta def - ping: {} as ContractProcedure< - Schema, - typeof outputSchema, - typeof baseErrorMap, - { mode?: number } - >, - }) + // @ts-expect-error - schema is invalid + builder.output('invalid') }) }) diff --git a/packages/contract/src/builder-variants.ts b/packages/contract/src/builder-variants.ts index 3b8dc7799..848627e38 100644 --- a/packages/contract/src/builder-variants.ts +++ b/packages/contract/src/builder-variants.ts @@ -1,245 +1,70 @@ -import type { HTTPPath } from '@orpc/client' -import type { ErrorMap, MergedErrorMap } from './error' -import type { Meta } from './meta' -import type { ContractProcedure } from './procedure' -import type { Route } from './route' -import type { ContractRouter } from './router' -import type { EnhanceContractRouterOptions, EnhancedContractRouter } from './router-utils' -import type { AnySchema } from './schema' - -export interface ContractProcedureBuilder< +import type { InitialInputSchema, InitialOutputSchema } from './builder' +import type { ErrorMap } from './error' +import type { MergedErrorMap } from './error-utils' +import type { MetaPlugin } from './meta' +import type { ProcedureContract } from './procedure' +import type { AnySchema, MergedSchema } from './schema' + +export interface ProcedureContractBuilderWithInput< TInputSchema extends AnySchema, - TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, - TMeta extends Meta, -> extends ContractProcedure { - /** - * Adds type-safe custom errors to the contract. - * The provided errors are spared-merged with any existing errors in the contract. - * - * @see {@link https://orpc.dev/docs/error-handling#type%E2%80%90safe-error-handling Type-Safe Error Handling Docs} - */ - errors( - errors: U, - ): ContractProcedureBuilder, TMeta> - - /** - * Sets or updates the metadata for the contract. - * The provided metadata is spared-merged with any existing metadata in the contract. - * - * @see {@link https://orpc.dev/docs/metadata Metadata Docs} - */ +>extends ProcedureContract { meta( - meta: TMeta, - ): ContractProcedureBuilder + ...plugins: MetaPlugin[] + ): ProcedureContractBuilderWithInput - /** - * Sets or updates the route definition for the contract. - * The provided route is spared-merged with any existing route in the contract. - * This option is typically relevant when integrating with OpenAPI. - * - * @see {@link https://orpc.dev/docs/openapi/routing OpenAPI Routing Docs} - * @see {@link https://orpc.dev/docs/openapi/input-output-structure OpenAPI Input/Output Structure Docs} - */ - route( - route: Route, - ): ContractProcedureBuilder + errors( + errors: T, + ): ProcedureContractBuilderWithInput> - /** - * Defines the input validation schema for the contract. - * - * @see {@link https://orpc.dev/docs/procedure#input-output-validation Input Validation Docs} - */ - input( - schema: U, - ): ContractProcedureBuilderWithInput + input( + schema: T, + ): ProcedureContractBuilderWithInput, TErrorMap> - /** - * Defines the output validation schema for the contract. - * - * @see {@link https://orpc.dev/docs/procedure#input-output-validation Output Validation Docs} - */ - output( - schema: U, - ): ContractProcedureBuilderWithOutput + output( + schema: T, + ): ProcedureContractBuilderWithInputOutput } -export interface ContractProcedureBuilderWithInput< - TInputSchema extends AnySchema, +export interface ProcedureContractBuilderWithOutput< TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, - TMeta extends Meta, ->extends ContractProcedure { - /** - * Adds type-safe custom errors to the contract. - * The provided errors are spared-merged with any existing errors in the contract. - * - * @see {@link https://orpc.dev/docs/error-handling#type%E2%80%90safe-error-handling Type-Safe Error Handling Docs} - */ - errors( - errors: U, - ): ContractProcedureBuilderWithInput, TMeta> - - /** - * Sets or updates the metadata for the contract. - * The provided metadata is spared-merged with any existing metadata in the contract. - * - * @see {@link https://orpc.dev/docs/metadata Metadata Docs} - */ +>extends ProcedureContract { meta( - meta: TMeta, - ): ContractProcedureBuilderWithInput + ...plugins: MetaPlugin[] + ): ProcedureContractBuilderWithOutput - /** - * Sets or updates the route definition for the contract. - * The provided route is spared-merged with any existing route in the contract. - * This option is typically relevant when integrating with OpenAPI. - * - * @see {@link https://orpc.dev/docs/openapi/routing OpenAPI Routing Docs} - * @see {@link https://orpc.dev/docs/openapi/input-output-structure OpenAPI Input/Output Structure Docs} - */ - route( - route: Route, - ): ContractProcedureBuilderWithInput + errors( + errors: T, + ): ProcedureContractBuilderWithOutput> - /** - * Defines the output validation schema for the contract. - * - * @see {@link https://orpc.dev/docs/procedure#input-output-validation Output Validation Docs} - */ - output( - schema: U, - ): ContractProcedureBuilderWithInputOutput -} - -export interface ContractProcedureBuilderWithOutput< - TInputSchema extends AnySchema, - TOutputSchema extends AnySchema, - TErrorMap extends ErrorMap, - TMeta extends Meta, -> extends ContractProcedure { - /** - * Adds type-safe custom errors to the contract. - * The provided errors are spared-merged with any existing errors in the contract. - * - * @see {@link https://orpc.dev/docs/error-handling#type%E2%80%90safe-error-handling Type-Safe Error Handling Docs} - */ - errors( - errors: U, - ): ContractProcedureBuilderWithOutput, TMeta> + input( + schema: T, + ): ProcedureContractBuilderWithInputOutput - /** - * Sets or updates the metadata for the contract. - * The provided metadata is spared-merged with any existing metadata in the contract. - * - * @see {@link https://orpc.dev/docs/metadata Metadata Docs} - */ - meta( - meta: TMeta, - ): ContractProcedureBuilderWithOutput - - /** - * Sets or updates the route definition for the contract. - * The provided route is spared-merged with any existing route in the contract. - * This option is typically relevant when integrating with OpenAPI. - * - * @see {@link https://orpc.dev/docs/openapi/routing OpenAPI Routing Docs} - * @see {@link https://orpc.dev/docs/openapi/input-output-structure OpenAPI Input/Output Structure Docs} - */ - route( - route: Route, - ): ContractProcedureBuilderWithOutput - - /** - * Defines the input validation schema for the contract. - * - * @see {@link https://orpc.dev/docs/procedure#input-output-validation Input Validation Docs} - */ - input( - schema: U, - ): ContractProcedureBuilderWithInputOutput + output( + schema: T, + ): ProcedureContractBuilderWithOutput, TErrorMap> } -export interface ContractProcedureBuilderWithInputOutput< +export interface ProcedureContractBuilderWithInputOutput< TInputSchema extends AnySchema, TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, - TMeta extends Meta, -> extends ContractProcedure { - /** - * Adds type-safe custom errors to the contract. - * The provided errors are spared-merged with any existing errors in the contract. - * - * @see {@link https://orpc.dev/docs/error-handling#type%E2%80%90safe-error-handling Type-Safe Error Handling Docs} - */ - errors( - errors: U, - ): ContractProcedureBuilderWithInputOutput, TMeta> - - /** - * Sets or updates the metadata for the contract. - * The provided metadata is spared-merged with any existing metadata in the contract. - * - * @see {@link https://orpc.dev/docs/metadata Metadata Docs} - */ +>extends ProcedureContract { meta( - meta: TMeta, - ): ContractProcedureBuilderWithInputOutput - - /** - * Sets or updates the route definition for the contract. - * The provided route is spared-merged with any existing route in the contract. - * This option is typically relevant when integrating with OpenAPI. - * - * @see {@link https://orpc.dev/docs/openapi/routing OpenAPI Routing Docs} - * @see {@link https://orpc.dev/docs/openapi/input-output-structure OpenAPI Input/Output Structure Docs} - */ - route( - route: Route, - ): ContractProcedureBuilderWithInputOutput -} - -export interface ContractRouterBuilder< - TErrorMap extends ErrorMap, - TMeta extends Meta, -> { - /** - * This property holds the defined options for the contract router. - */ - '~orpc': EnhanceContractRouterOptions - - /** - * Adds type-safe custom errors to the contract. - * The provided errors are spared-merged with any existing errors in the contract. - * - * @see {@link https://orpc.dev/docs/error-handling#type%E2%80%90safe-error-handling Type-Safe Error Handling Docs} - */ - 'errors'( - errors: U, - ): ContractRouterBuilder, TMeta> + ...plugins: MetaPlugin[] + ): ProcedureContractBuilderWithInputOutput - /** - * Prefixes all procedures in the contract router. - * The provided prefix is post-appended to any existing router prefix. - * - * @note This option does not affect procedures that do not define a path in their route definition. - * - * @see {@link https://orpc.dev/docs/openapi/routing#route-prefixes OpenAPI Route Prefixes Docs} - */ - 'prefix'(prefix: HTTPPath): ContractRouterBuilder + errors( + errors: T, + ): ProcedureContractBuilderWithInputOutput> - /** - * Adds tags to all procedures in the contract router. - * This helpful when you want to group procedures together in the OpenAPI specification. - * - * @see {@link https://orpc.dev/docs/openapi/openapi-specification#operation-metadata OpenAPI Operation Metadata Docs} - */ - 'tag'(...tags: string[]): ContractRouterBuilder + input( + schema: T, + ): ProcedureContractBuilderWithInputOutput, TOutputSchema, TErrorMap> - /** - * Applies all of the previously defined options to the specified contract router. - * - * @see {@link https://orpc.dev/docs/router#extending-router Extending Router Docs} - */ - 'router'>(router: T): EnhancedContractRouter + output( + schema: T, + ): ProcedureContractBuilderWithInputOutput, TErrorMap> } diff --git a/packages/contract/src/builder.test-d.ts b/packages/contract/src/builder.test-d.ts index ed9818e9d..e170fc46a 100644 --- a/packages/contract/src/builder.test-d.ts +++ b/packages/contract/src/builder.test-d.ts @@ -1,62 +1,42 @@ -import type { baseErrorMap, BaseMeta, inputSchema, outputSchema } from '../tests/shared' import type { ContractBuilder } from './builder' -import type { ContractProcedureBuilder, ContractProcedureBuilderWithInput, ContractProcedureBuilderWithOutput, ContractRouterBuilder } from './builder-variants' -import type { MergedErrorMap } from './error' -import type { ContractProcedure } from './procedure' -import type { EnhancedContractRouter } from './router-utils' +import type { ProcedureContractBuilderWithInput, ProcedureContractBuilderWithOutput } from './builder-variants' +import type { MergedErrorMap } from './error-utils' +import type { Meta, MetaPlugin } from './meta' +import type { ProcedureContract } from './procedure' +import type { AugmentedContractRouter } from './router-utils' import type { Schema } from './schema' -import { generalSchema, ping, pong } from '../tests/shared' +import { describe, expectTypeOf, it } from 'vitest' +import { z } from 'zod' +import { oc } from './builder' -const builder = {} as ContractBuilder +const errorMap = { + BASE: { + data: z.object({ id: z.string() }), + message: 'base', + }, +} + +const builder = {} as ContractBuilder + +// Schemas should have distinct TInput and TOutput types to ensure correct inference. +const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) +const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) describe('ContractBuilder', () => { it('is a contract procedure', () => { - expectTypeOf(builder).toMatchTypeOf< - ContractProcedure< - typeof inputSchema, - typeof outputSchema, - Record, - BaseMeta + expectTypeOf(builder).toExtend< + ProcedureContract< + Schema, + Schema, + Record > >() }) - it('.$meta', () => { - type MetaDef = { meta1?: string, meta2?: number } - - expectTypeOf(builder.$meta({ meta1: 'value' })).toEqualTypeOf< - ContractBuilder> - >() - - // @ts-expect-error - invalid initial meta - builder.$meta({ meta1: 123 }) - }) - - it('.$route', () => { - expectTypeOf(builder.$route({ method: 'GET', path: '/api' })).toEqualTypeOf< - typeof builder - >() - - // @ts-expect-error - method is invalid - builder.$route({ method: 'INVALID' }) - }) - - it('.$input', () => { - expectTypeOf(builder.$input(generalSchema)).toEqualTypeOf< - ContractBuilder - >() - - // @ts-expect-error - schema is invalid - builder.$input({}) - }) - it('.errors', () => { expectTypeOf(builder.errors({ INVALID: { message: 'invalid' }, OVERRIDE: { message: 'override' } })).toEqualTypeOf< ContractBuilder< - typeof inputSchema, - typeof outputSchema, - MergedErrorMap, - BaseMeta + MergedErrorMap > >() @@ -65,40 +45,18 @@ describe('ContractBuilder', () => { }) it('.meta', () => { - expectTypeOf(builder.meta({ log: true })).toEqualTypeOf< - ContractProcedureBuilder< - typeof inputSchema, - typeof outputSchema, - typeof baseErrorMap, - BaseMeta - > - >() + const plugin = { name: 'test', init: (m: Meta) => m } + expectTypeOf(builder.meta(plugin)).toEqualTypeOf() // @ts-expect-error - invalid meta - builder.meta({ meta: 'INVALID' }) - }) - - it('.route', () => { - expectTypeOf(builder.route({ method: 'GET' })).toEqualTypeOf< - ContractProcedureBuilder< - typeof inputSchema, - typeof outputSchema, - typeof baseErrorMap, - BaseMeta - > - >() - - // @ts-expect-error - invalid method - builder.route({ method: 'INVALID' }) + builder.meta({ } as MetaPlugin, any, any>) }) it('.input', () => { - expectTypeOf(builder.input(generalSchema)).toEqualTypeOf< - ContractProcedureBuilderWithInput< - typeof generalSchema, - typeof outputSchema, - typeof baseErrorMap, - BaseMeta + expectTypeOf(builder.input(schema1)).toEqualTypeOf< + ProcedureContractBuilderWithInput< + typeof schema1, + typeof errorMap > >() @@ -107,12 +65,10 @@ describe('ContractBuilder', () => { }) it('.output', () => { - expectTypeOf(builder.output(generalSchema)).toEqualTypeOf< - ContractProcedureBuilderWithOutput< - typeof inputSchema, - typeof generalSchema, - typeof baseErrorMap, - BaseMeta + expectTypeOf(builder.output(schema2)).toEqualTypeOf< + ProcedureContractBuilderWithOutput< + typeof schema2, + typeof errorMap > >() @@ -120,45 +76,22 @@ describe('ContractBuilder', () => { builder.output({}) }) - it('.prefix', () => { - expectTypeOf(builder.prefix('/api')).toEqualTypeOf< - ContractRouterBuilder - >() - - // @ts-expect-error - invalid prefix - builder.prefix(1) - }) - - it('.tag', () => { - expectTypeOf(builder.tag('tag1', 'tag2')).toEqualTypeOf< - ContractRouterBuilder - >() - - // @ts-expect-error - invalid tag - builder.tag(1) - }) - it('.router', () => { const router = { - ping, - pong, + ping: oc.input(schema1).output(schema2), } expectTypeOf(builder.router(router)).toEqualTypeOf< - EnhancedContractRouter + AugmentedContractRouter >() // @ts-expect-error - invalid router builder.router(123) + }) +}) - builder.router({ - // @ts-expect-error - conflict meta def - ping: {} as ContractProcedure< - Schema, - typeof outputSchema, - typeof baseErrorMap, - { mode?: number } - >, - }) +describe('oc', () => { + it('is a contract builder', () => { + expectTypeOf(oc).toEqualTypeOf>() }) }) diff --git a/packages/contract/src/builder.test.ts b/packages/contract/src/builder.test.ts index 62c8fcc9f..6e8b02550 100644 --- a/packages/contract/src/builder.test.ts +++ b/packages/contract/src/builder.test.ts @@ -1,144 +1,219 @@ -import { baseErrorMap, baseMeta, baseRoute, generalSchema, inputSchema, outputSchema, ping, pong } from '../tests/shared' -import { ContractBuilder } from './builder' -import { mergeErrorMap } from './error' -import { isContractProcedure } from './procedure' +import type { ErrorMap } from './error' +import type { AnyMetaPlugin } from './meta' +import z from 'zod' +import { ContractBuilder, oc } from './builder' +import * as ErrorUtilsModule from './error-utils' +import { setHiddenMetaPlugins } from './meta' +import * as MetaUtilsModule from './meta-utils' +import { ProcedureContract } from './procedure' import * as RouterUtilsModule from './router-utils' -const enhanceContractRouterSpy = vi.spyOn(RouterUtilsModule, 'enhanceContractRouter') - -const def = { - errorMap: baseErrorMap, - outputSchema, - inputSchema, - route: baseRoute, - meta: baseMeta, - prefix: '/adapt' as const, - tags: ['adapt'], -} - -const builder = new ContractBuilder(def) +const resolveMetaPluginsSpy = vi.spyOn(MetaUtilsModule, 'resolveMetaPlugins') +const mergeErrorMapSpy = vi.spyOn(ErrorUtilsModule, 'mergeErrorMap') +const augmentContractRouterSpy = vi.spyOn(RouterUtilsModule, 'augmentContractRouter') beforeEach(() => { vi.clearAllMocks() }) describe('contractBuilder', () => { - it('is a contract procedure', () => { - expect(builder).toSatisfy(isContractProcedure) + const builder = ContractBuilder.create() + builder['~orpc'] = { + errorMap: { + BASE: { status: 400 }, + }, + inputSchemas: [ + z.object({ init: z.string() }), + ], + outputSchemas: [ + z.object({ init: z.string() }), + ], + meta: { + base: true, + }, + metaPlugins: [ + { + name: 'test1', + init: m => m, + }, + ], + } + + const metaPlugin: AnyMetaPlugin = { + name: 'test2', + init: m => ({ ...m, metaPlugin: true }), + } + + it('is a procedure contract', () => { + expect(builder).toBeInstanceOf(ProcedureContract) }) - it('.$meta', () => { - const meta = { dev: true, log: true } - const applied = builder.$meta(meta) - expect(applied).toBeInstanceOf(ContractBuilder) - expect(applied).not.toBe(builder) - expect(applied['~orpc']).toEqual({ - ...def, - meta, + it('create', () => { + expect(oc).toBeInstanceOf(ContractBuilder) + expect(oc['~orpc']).toEqual({ + errorMap: {}, + meta: {}, }) }) - it('.$route', () => { - const route = { path: '/api', method: 'GET' } as const - - const applied = builder.$route(route) + it('.meta', () => { + const applied = builder.meta(metaPlugin) expect(applied).toBeInstanceOf(ContractBuilder) expect(applied).not.toBe(builder) - expect(applied['~orpc']).toEqual({ - ...def, - route, - }) - }) - it('.$input', () => { - const applied = builder.$input(generalSchema) - expect(applied).toBeInstanceOf(ContractBuilder) - expect(applied).not.toBe(builder) - expect(applied['~orpc']).toEqual({ - ...def, - inputSchema: generalSchema, - }) - }) - - it('.errors', () => { - const errors = { BAD_GATEWAY: { data: outputSchema }, OVERRIDE: { message: 'override' } } as const + expect(resolveMetaPluginsSpy).toHaveBeenCalledOnce() + expect(resolveMetaPluginsSpy).toHaveBeenCalledWith( + builder['~orpc'].meta, + builder['~orpc'].metaPlugins, + [metaPlugin], + ) - const applied = builder.errors(errors) - expect(applied).toBeInstanceOf(ContractBuilder) - expect(applied).not.toBe(builder) expect(applied['~orpc']).toEqual({ - ...def, - errorMap: mergeErrorMap(def.errorMap, errors), + ...builder['~orpc'], + meta: resolveMetaPluginsSpy.mock.results[0]?.value[0], + metaPlugins: resolveMetaPluginsSpy.mock.results[0]?.value[1], }) }) - it('.meta', () => { - const meta = { dev: true, log: true } - const applied = builder.meta(meta) - expect(applied).toBeInstanceOf(ContractBuilder) - expect(applied).not.toBe(builder) - expect(applied['~orpc']).toEqual({ - ...def, - meta: { ...def.meta, ...meta }, + describe('.errors', () => { + it('without meta plugins', () => { + const errors = { + OVERRIDE: { message: 'override' }, + } satisfies ErrorMap + + const applied = builder.errors(errors) + expect(applied).toBeInstanceOf(ContractBuilder) + expect(applied).not.toBe(builder) + + expect(mergeErrorMapSpy).toHaveBeenCalledOnce() + expect(mergeErrorMapSpy).toHaveBeenCalledWith( + builder['~orpc'].errorMap, + errors, + ) + + expect(applied['~orpc']).toEqual({ + ...builder['~orpc'], + errorMap: mergeErrorMapSpy.mock.results[0]?.value, + }) }) - }) - it('.route', () => { - const route = { method: 'GET', path: '/path' } as const - const applied = builder.route(route) - expect(applied).toBeInstanceOf(ContractBuilder) - expect(applied).not.toBe(builder) - expect(applied['~orpc']).toEqual({ - ...def, - route: { ...def.route, ...route }, + it('with meta plugins', () => { + const errors = { + OVERRIDE: { message: 'override' }, + } satisfies ErrorMap + + setHiddenMetaPlugins(errors, [metaPlugin]) + + const applied = builder.errors(errors) + expect(applied).toBeInstanceOf(ContractBuilder) + expect(applied).not.toBe(builder) + + expect(mergeErrorMapSpy).toHaveBeenCalledOnce() + expect(mergeErrorMapSpy).toHaveBeenCalledWith( + builder['~orpc'].errorMap, + errors, + ) + + expect(resolveMetaPluginsSpy).toHaveBeenCalledOnce() + expect(resolveMetaPluginsSpy).toHaveBeenCalledWith( + builder['~orpc'].meta, + builder['~orpc'].metaPlugins, + [metaPlugin], + ) + + expect(applied['~orpc']).toEqual({ + ...builder['~orpc'], + errorMap: mergeErrorMapSpy.mock.results[0]?.value, + meta: resolveMetaPluginsSpy.mock.results[0]?.value[0], + metaPlugins: resolveMetaPluginsSpy.mock.results[0]?.value[1], + }) }) }) - it('.input', () => { - const applied = builder.input(generalSchema) - expect(applied).toBeInstanceOf(ContractBuilder) - expect(applied).not.toBe(builder) - expect(applied['~orpc']).toEqual({ - ...def, - inputSchema: generalSchema, + describe('.input', () => { + it('without meta plugins', () => { + const schema = z.object({ input: z.string() }) + const applied = builder.input(schema) + expect(applied).toBeInstanceOf(ContractBuilder) + expect(applied).not.toBe(builder) + + expect(applied['~orpc']).toEqual({ + ...builder['~orpc'], + inputSchemas: [...builder['~orpc'].inputSchemas!, schema], + }) }) - }) - it('.output', () => { - const applied = builder.output(generalSchema) - expect(applied).toBeInstanceOf(ContractBuilder) - expect(applied).not.toBe(builder) - expect(applied['~orpc']).toEqual({ - ...def, - outputSchema: generalSchema, + it('with meta plugins', () => { + const schema = z.object({ input: z.string() }) + + setHiddenMetaPlugins(schema, [metaPlugin]) + + const applied = builder.input(schema) + expect(applied).toBeInstanceOf(ContractBuilder) + expect(applied).not.toBe(builder) + + expect(resolveMetaPluginsSpy).toHaveBeenCalledOnce() + expect(resolveMetaPluginsSpy).toHaveBeenCalledWith( + builder['~orpc'].meta, + builder['~orpc'].metaPlugins, + [metaPlugin], + ) + + expect(applied['~orpc']).toEqual({ + ...builder['~orpc'], + meta: resolveMetaPluginsSpy.mock.results[0]?.value[0], + metaPlugins: resolveMetaPluginsSpy.mock.results[0]?.value[1], + inputSchemas: [...builder['~orpc'].inputSchemas!, schema], + }) }) }) - it('.prefix', () => { - const applied = builder.prefix('/api') - expect(applied).toBeInstanceOf(ContractBuilder) - expect(applied).not.toBe(builder) - expect(applied['~orpc']).toEqual({ - ...def, - prefix: '/adapt/api', + describe('.output', () => { + it('without meta plugins', () => { + const schema = z.object({ output: z.string() }) + const applied = builder.output(schema) + expect(applied).toBeInstanceOf(ContractBuilder) + expect(applied).not.toBe(builder) + expect(applied['~orpc']).toEqual({ + ...builder['~orpc'], + outputSchemas: [...builder['~orpc'].outputSchemas!, schema], + }) }) - }) - it('.tag', () => { - const applied = builder.tag('tag1', 'tag2') - expect(applied).toBeInstanceOf(ContractBuilder) - expect(applied).not.toBe(builder) - expect(applied['~orpc']).toEqual({ - ...def, - tags: ['adapt', 'tag1', 'tag2'], + it('with meta plugins', () => { + const schema = z.object({ output: z.string() }) + + setHiddenMetaPlugins(schema, [metaPlugin]) + + const applied = builder.output(schema) + expect(applied).toBeInstanceOf(ContractBuilder) + expect(applied).not.toBe(builder) + + expect(resolveMetaPluginsSpy).toHaveBeenCalledOnce() + expect(resolveMetaPluginsSpy).toHaveBeenCalledWith( + builder['~orpc'].meta, + builder['~orpc'].metaPlugins, + [metaPlugin], + ) + + expect(applied['~orpc']).toEqual({ + ...builder['~orpc'], + meta: resolveMetaPluginsSpy.mock.results[0]?.value[0], + metaPlugins: resolveMetaPluginsSpy.mock.results[0]?.value[1], + outputSchemas: [...builder['~orpc'].outputSchemas!, schema], + }) }) }) it('.router', () => { - const router = { ping, pong } + const router = { + ping: builder.output(z.string()), + pong: builder.input(z.string()).output(z.string()), + } + const applied = builder.router(router) - expect(applied).toBe(enhanceContractRouterSpy.mock.results[0]?.value) - expect(enhanceContractRouterSpy).toHaveBeenCalledOnce() - expect(enhanceContractRouterSpy).toHaveBeenCalledWith(router, def) + expect(applied).toBe(augmentContractRouterSpy.mock.results[0]?.value) + expect(augmentContractRouterSpy).toHaveBeenCalledOnce() + expect(augmentContractRouterSpy).toHaveBeenCalledWith(router, builder['~orpc']) }) }) diff --git a/packages/contract/src/builder.ts b/packages/contract/src/builder.ts index 4e04711f0..f94dada3d 100644 --- a/packages/contract/src/builder.ts +++ b/packages/contract/src/builder.ts @@ -1,212 +1,110 @@ -import type { HTTPPath } from '@orpc/client' -import type { ContractProcedureBuilder, ContractProcedureBuilderWithInput, ContractProcedureBuilderWithOutput, ContractRouterBuilder } from './builder-variants' -import type { ErrorMap, MergedErrorMap } from './error' -import type { Meta } from './meta' -import type { ContractProcedureDef } from './procedure' -import type { Route } from './route' -import type { ContractRouter } from './router' -import type { EnhanceContractRouterOptions, EnhancedContractRouter } from './router-utils' +import type { ProcedureContractBuilderWithInput, ProcedureContractBuilderWithOutput } from './builder-variants' +import type { ErrorMap } from './error' +import type { MergedErrorMap } from './error-utils' +import type { MetaPlugin } from './meta' +import type { ProcedureContractDefinition } from './procedure' +import type { RouterContract } from './router' +import type { AugmentedContractRouter } from './router-utils' import type { AnySchema, Schema } from './schema' -import { mergeErrorMap } from './error' -import { mergeMeta } from './meta' -import { ContractProcedure } from './procedure' -import { mergePrefix, mergeRoute, mergeTags } from './route' -import { enhanceContractRouter } from './router-utils' - -export interface ContractBuilderDef< - TInputSchema extends AnySchema, - TOutputSchema extends AnySchema, - TErrorMap extends ErrorMap, - TMeta extends Meta, -> extends ContractProcedureDef, EnhanceContractRouterOptions { -} +import { toArray } from '@orpc/shared' +import { mergeErrorMap } from './error-utils' +import { getHiddenMetaPlugins } from './meta' +import { resolveMetaPlugins } from './meta-utils' +import { ProcedureContract } from './procedure' +import { augmentContractRouter } from './router-utils' + +export type InitialInputSchema = Schema +export type InitialOutputSchema = Schema export class ContractBuilder< - TInputSchema extends AnySchema, - TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, - TMeta extends Meta, -> extends ContractProcedure { +> extends ProcedureContract { /** - * This property holds the defined options for the contract. + * Private constructor to prevent direct instantiation. + * Use the static `create` method to initialize a new instance with a safe initial definition. */ - declare '~orpc': ContractBuilderDef - - constructor(def: ContractBuilderDef) { - super(def) - - this['~orpc'].prefix = def.prefix - this['~orpc'].tags = def.tags + private constructor(definition: ProcedureContractDefinition) { + super(definition) } - /** - * Sets or overrides the initial meta. - * - * @see {@link https://orpc.dev/docs/metadata Metadata Docs} - */ - $meta( - initialMeta: U, - ): ContractBuilder> { - /** - * We need `& Record` to deal with `has no properties in common with type` error - */ - + static create(): ContractBuilder { + // The initial input schema is void for better compatibility with third-party libraries like TanStack Query, + // for example, which allow calling mutations without input, ... return new ContractBuilder({ - ...this['~orpc'], - meta: initialMeta, + errorMap: {}, + meta: {}, }) } - /** - * Sets or overrides the initial route. - * This option is typically relevant when integrating with OpenAPI. - * - * @see {@link https://orpc.dev/docs/openapi/routing OpenAPI Routing Docs} - * @see {@link https://orpc.dev/docs/openapi/input-output-structure OpenAPI Input/Output Structure Docs} - */ - $route( - initialRoute: Route, - ): ContractBuilder { - return new ContractBuilder({ - ...this['~orpc'], - route: initialRoute, - }) - } + meta( + ...plugins: MetaPlugin[] + ): ContractBuilder { + const [meta, metaPlugins] = resolveMetaPlugins( + this['~orpc'].meta, + this['~orpc'].metaPlugins, + plugins, + ) - /** - * Sets or overrides the initial input schema. - * - * @see {@link https://orpc.dev/docs/procedure#initial-configuration Initial Procedure Configuration Docs} - */ - $input( - initialInputSchema?: U, - ): ContractBuilder { return new ContractBuilder({ ...this['~orpc'], - inputSchema: initialInputSchema, - }) + meta, + metaPlugins, + }) as any } - /** - * Adds type-safe custom errors to the contract. - * The provided errors are spared-merged with any existing errors in the contract. - * - * @see {@link https://orpc.dev/docs/error-handling#type%E2%80%90safe-error-handling Type-Safe Error Handling Docs} - */ - errors( - errors: U, - ): ContractBuilder, TMeta> { - return new ContractBuilder({ + errors( + errors: T, + ): ContractBuilder> { + let result = new ContractBuilder({ ...this['~orpc'], errorMap: mergeErrorMap(this['~orpc'].errorMap, errors), }) - } - /** - * Sets or updates the metadata for the contract. - * The provided metadata is spared-merged with any existing metadata in the contract. - * - * @see {@link https://orpc.dev/docs/metadata Metadata Docs} - */ - meta( - meta: TMeta, - ): ContractProcedureBuilder { - return new ContractBuilder({ - ...this['~orpc'], - meta: mergeMeta(this['~orpc'].meta, meta), - }) - } + const plugins = getHiddenMetaPlugins(errors) + if (plugins) { + result = result.meta(...plugins) as any + } - /** - * Sets or updates the route definition for the contract. - * The provided route is spared-merged with any existing route in the contract. - * This option is typically relevant when integrating with OpenAPI. - * - * @see {@link https://orpc.dev/docs/openapi/routing OpenAPI Routing Docs} - * @see {@link https://orpc.dev/docs/openapi/input-output-structure OpenAPI Input/Output Structure Docs} - */ - route( - route: Route, - ): ContractProcedureBuilder { - return new ContractBuilder({ - ...this['~orpc'], - route: mergeRoute(this['~orpc'].route, route), - }) + return result as any } - /** - * Defines the input validation schema for the contract. - * - * @see {@link https://orpc.dev/docs/procedure#input-output-validation Input Validation Docs} - */ - input( - schema: U, - ): ContractProcedureBuilderWithInput { - return new ContractBuilder({ + input( + schema: T, + ): ProcedureContractBuilderWithInput { + let result = new ContractBuilder({ ...this['~orpc'], - inputSchema: schema, + inputSchemas: [...toArray(this['~orpc'].inputSchemas), schema], }) - } - /** - * Defines the output validation schema for the contract. - * - * @see {@link https://orpc.dev/docs/procedure#input-output-validation Output Validation Docs} - */ - output( - schema: U, - ): ContractProcedureBuilderWithOutput { - return new ContractBuilder({ - ...this['~orpc'], - outputSchema: schema, - }) - } + const plugins = getHiddenMetaPlugins(schema) + if (plugins) { + result = result.meta(...plugins) as any + } - /** - * Prefixes all procedures in the contract router. - * The provided prefix is post-appended to any existing router prefix. - * - * @note This option does not affect procedures that do not define a path in their route definition. - * - * @see {@link https://orpc.dev/docs/openapi/routing#route-prefixes OpenAPI Route Prefixes Docs} - */ - prefix(prefix: HTTPPath): ContractRouterBuilder { - return new ContractBuilder({ - ...this['~orpc'], - prefix: mergePrefix(this['~orpc'].prefix, prefix), - }) + return result as any } - /** - * Adds tags to all procedures in the contract router. - * This helpful when you want to group procedures together in the OpenAPI specification. - * - * @see {@link https://orpc.dev/docs/openapi/openapi-specification#operation-metadata OpenAPI Operation Metadata Docs} - */ - tag(...tags: string[]): ContractRouterBuilder { - return new ContractBuilder({ + output( + schema: T, + ): ProcedureContractBuilderWithOutput { + let result = new ContractBuilder({ ...this['~orpc'], - tags: mergeTags(this['~orpc'].tags, tags), + outputSchemas: [...toArray(this['~orpc'].outputSchemas), schema], }) + + const plugins = getHiddenMetaPlugins(schema) + if (plugins) { + result = result.meta(...plugins) as any + } + + return result as any } - /** - * Applies all of the previously defined options to the specified contract router. - * - * @see {@link https://orpc.dev/docs/router#extending-router Extending Router Docs} - */ - router>(router: T): EnhancedContractRouter { - return enhanceContractRouter(router, this['~orpc']) + router( + router: T, + ): AugmentedContractRouter { + return augmentContractRouter(router, this['~orpc']) } } -export const oc = new ContractBuilder< - Schema, - Schema, - Record, - Record ->({ - errorMap: {}, - route: {}, - meta: {}, -}) +export const oc = ContractBuilder.create() diff --git a/packages/contract/src/caller.test-d.ts b/packages/contract/src/caller.test-d.ts new file mode 100644 index 000000000..0a43a3871 --- /dev/null +++ b/packages/contract/src/caller.test-d.ts @@ -0,0 +1,105 @@ +import type { ClientLink, ORPCError } from '@orpc/client' +import type { PromiseWithError } from '@orpc/shared' +import { oc } from './builder' +import { createContractCaller } from './caller' +import { type } from './schema-utils' + +const contract = { + ping: oc, + nested: { + pong: oc + .errors({ BAD_GATEWAY: { data: type(vi.fn()) } }) + .input(type(vi.fn())) + .output(type(vi.fn())), + }, +} + +describe('createContractCaller', () => { + const link = {} as ClientLink<{ cache?: boolean }> + + it('infers interceptor input, output, errors types', () => { + createContractCaller(link, { + contractRef: contract, + interceptors: [ + async ({ context, next, input }) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ cache?: boolean }>() + + const result = next() + + expectTypeOf(result).toEqualTypeOf< + PromiseWithError + >() + + return result + }, + ], + scoped: { + ping: { + interceptors: [ + async ({ context, next, input }) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ cache?: boolean }>() + + const result = next() + + expectTypeOf(result).toEqualTypeOf< + PromiseWithError> + >() + + return result + }, + ], + }, + nested: { + pong: { + interceptors: [ + async ({ context, next, input }) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ cache?: boolean }>() + + const result = next() + + expectTypeOf(result).toEqualTypeOf< + PromiseWithError> + >() + + return result + }, + ], + }, + }, + }, + }) + }) + + it('infers procedure return types', () => { + const caller = createContractCaller(link) + + expectTypeOf( + caller(contract.ping), + ).toEqualTypeOf< + PromiseWithError + >() + + expectTypeOf( + caller(contract.nested.pong, 'string', { context: { cache: true } }), + ).toEqualTypeOf< + PromiseWithError> + >() + }) + + it('rejects invalid input', () => { + const caller = createContractCaller(link) + + // @ts-expect-error - invalid input + caller(contract.nested.pong, 123) + }) + + it('rejects invalid context', () => { + const caller = createContractCaller(link) + + // @ts-expect-error - invalid context + caller(contract.nested.pong, 'string', { context: { cache: 'invalid' } }) + }) +}) diff --git a/packages/contract/src/caller.test.ts b/packages/contract/src/caller.test.ts new file mode 100644 index 000000000..46ae5d9e1 --- /dev/null +++ b/packages/contract/src/caller.test.ts @@ -0,0 +1,140 @@ +import type { ClientContext, ClientLink } from '@orpc/client' +import { createContractCaller } from './caller' +import { ProcedureContract } from './procedure' + +beforeEach(() => { + vi.clearAllMocks() +}) + +function createContract(path?: string[]) { + return new ProcedureContract({ + errorMap: {}, + meta: path ? { '~path': path } : {}, + inputSchemas: [], + outputSchemas: [], + }) +} + +describe('createContractCaller', () => { + const mockedLink: ClientLink = { + call: vi.fn().mockReturnValue('__mocked__'), + } + + it('throws when procedure contract has no meta.path', async () => { + const caller = createContractCaller(mockedLink) + const procedure = createContract() + + await expect(caller(procedure as any, { value: 'hello' })).rejects.toThrow( + 'ContractCaller: procedure contract must define `meta.path` that matches its path in the root router contract.', + ) + + expect(mockedLink.call).not.toHaveBeenCalled() + }) + + it('calls the link with the procedure path and syncs routerRef', async () => { + const routerRef = {} + const caller = createContractCaller(mockedLink, { contractRef: routerRef }) + const procedure = createContract(['users', 'list']) + const signal = new AbortController().signal + + expect(await caller(procedure as any, { value: 'hello' }, { context: { requestId: 'request_1' }, signal })).toBe('__mocked__') + + expect(routerRef).toEqual({ + users: { + list: { + '~orpc': procedure['~orpc'], + }, + }, + }) + expect((routerRef as any).users.list).toBeInstanceOf(ProcedureContract) + + expect(mockedLink.call).toHaveBeenCalledTimes(1) + expect(mockedLink.call).toHaveBeenCalledWith( + ['users', 'list'], + { value: 'hello' }, + { context: { requestId: 'request_1' }, signal }, + ) + }) + + it('passes interceptors through to the per-procedure client', async () => { + const interceptor = vi.fn(({ path, input, context, next }) => { + expect(path).toEqual(['users', 'find']) + expect(input).toEqual({ value: 'hello' }) + expect(context).toEqual({ requestId: 'request_1' }) + + return next({ + path, + input: { value: 'intercepted' }, + context: { ...context, traceId: 'trace_1' }, + }) + }) + + const caller = createContractCaller(mockedLink, { + interceptors: [interceptor], + }) + + expect(await caller(createContract(['users', 'find']) as any, { value: 'hello' }, { context: { requestId: 'request_1' } })).toBe('__mocked__') + + expect(interceptor).toHaveBeenCalledTimes(1) + expect(mockedLink.call).toHaveBeenCalledTimes(1) + expect(mockedLink.call).toHaveBeenCalledWith( + ['users', 'find'], + { value: 'intercepted' }, + { context: { requestId: 'request_1', traceId: 'trace_1' } }, + ) + }) + + it('throw when scoped option is invalid at given path', async () => { + const caller = createContractCaller(mockedLink, { + scoped: { + users: { + find: '' as any, + list: undefined, + create: { + interceptors: [], + }, + }, + }, + }) + + await expect(caller(createContract(['users', 'find']))).rejects.toThrow( + 'ContractCaller: "scoped" at path "users.find" must be an object or undefined, got "".', + ) + + await expect(caller(createContract(['users', 'list']))).resolves.toEqual('__mocked__') + }) + + it('passes scoped through to the per-procedure client', async () => { + const interceptor = vi.fn(({ path, input, context, next }) => { + expect(path).toEqual(['users', 'find']) + expect(input).toEqual({ value: 'hello' }) + expect(context).toEqual({ requestId: 'request_1' }) + + return next({ + path, + input: { value: 'intercepted' }, + context: { ...context, traceId: 'trace_1' }, + }) + }) + + const caller = createContractCaller(mockedLink, { + scoped: { + users: { + find: { + interceptors: [interceptor], + }, + }, + }, + }) + + expect(await caller(createContract(['users', 'find']) as any, { value: 'hello' }, { context: { requestId: 'request_1' } })).toBe('__mocked__') + + expect(interceptor).toHaveBeenCalledTimes(1) + expect(mockedLink.call).toHaveBeenCalledTimes(1) + expect(mockedLink.call).toHaveBeenCalledWith( + ['users', 'find'], + { value: 'intercepted' }, + { context: { requestId: 'request_1', traceId: 'trace_1' } }, + ) + }) +}) diff --git a/packages/contract/src/caller.ts b/packages/contract/src/caller.ts new file mode 100644 index 000000000..41cbc171f --- /dev/null +++ b/packages/contract/src/caller.ts @@ -0,0 +1,80 @@ +import type { ClientContext, ClientLink, ClientRest, ORPCClientOptions, ThrowableError } from '@orpc/client' +import type { PromiseWithError } from '@orpc/shared' +import type { ErrorMap, ORPCErrorFromErrorMap } from './error' +import type { ProcedureContract } from './procedure' +import type { ProcedureContractClient } from './procedure-client' +import type { RouterContract } from './router' +import type { RouterContractClient } from './router-client' +import type { AnySchema, InferSchemaInput, InferSchemaOutput } from './schema' +import { createORPCClient } from '@orpc/client' +import { get, set } from '@orpc/shared' +import { getPathMeta } from './meta-built-in' + +export interface ContractCaller< + TClientContext extends ClientContext, +> { + < + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + >( + procedure: ProcedureContract, + ...rest: ClientRest> + ): PromiseWithError< + InferSchemaOutput, + ORPCErrorFromErrorMap | ThrowableError + > +} + +export interface ContractCallerOptions< + TClientContext extends ClientContext, +> extends Pick>, 'interceptors' | 'scoped'> { + /** + * An optional reference to the root router-contract. + * When provided, the caller will automatically register the called procedure-contract + * into the router at the path defined by `meta.path`. + */ + contractRef?: undefined | RouterContract +} + +export function createContractCaller< + TClientContext extends ClientContext, +>( + link: ClientLink, + options: ContractCallerOptions = {}, +): ContractCaller { + // Use async here so all errors are rejected through the returned Promise. + return async < + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + >( + procedure: ProcedureContract, + ...rest: ClientRest> + ) => { + const path = getPathMeta(procedure) + + if (!path) { + throw new TypeError( + 'ContractCaller: procedure contract must define `meta.path` that matches its path in the root router contract.', + ) + } + + if (options.contractRef) { + set(options.contractRef, [...path, '~orpc'], procedure['~orpc']) + } + + const scoped = get(options.scoped, path) + + if (scoped !== undefined && (scoped === null || typeof scoped !== 'object')) { + throw new TypeError( + `ContractCaller: "scoped" at path "${path.join('.')}" must be an object or undefined, got "${scoped}".`, + ) + } + + const client: ProcedureContractClient + = createORPCClient(link, { path, interceptors: options.interceptors as any, scoped: scoped as any }) + + return client(...rest) + } +} diff --git a/packages/contract/src/config.test.ts b/packages/contract/src/config.test.ts deleted file mode 100644 index f801c1c9b..000000000 --- a/packages/contract/src/config.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { fallbackContractConfig } from './config' - -it('fallbackConfig', () => { - expect(fallbackContractConfig('defaultMethod', undefined)).toBe('POST') - expect(fallbackContractConfig('defaultMethod', 'GET')).toBe('GET') -}) diff --git a/packages/contract/src/config.ts b/packages/contract/src/config.ts deleted file mode 100644 index fcb3146dc..000000000 --- a/packages/contract/src/config.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { HTTPMethod } from '@orpc/client' -import type { InputStructure, OutputStructure } from './route' - -export interface ContractConfig { - defaultMethod: HTTPMethod - defaultSuccessStatus: number - defaultSuccessDescription: string - defaultInputStructure: InputStructure - defaultOutputStructure: OutputStructure -} - -const DEFAULT_CONFIG: ContractConfig = { - defaultMethod: 'POST', - defaultSuccessStatus: 200, - defaultSuccessDescription: 'OK', - defaultInputStructure: 'compact', - defaultOutputStructure: 'compact', -} - -export function fallbackContractConfig(key: T, value: ContractConfig[T] | undefined): ContractConfig[T] { - if (value === undefined) { - return DEFAULT_CONFIG[key] - } - - return value -} diff --git a/packages/contract/src/error-utils.test-d.ts b/packages/contract/src/error-utils.test-d.ts new file mode 100644 index 000000000..72b5d5692 --- /dev/null +++ b/packages/contract/src/error-utils.test-d.ts @@ -0,0 +1,12 @@ +/* eslint-disable ts/no-empty-object-type */ + +import type { MergedErrorMap } from './error-utils' +import { expectTypeOf, it } from 'vitest' + +it('MergedErrorMap', () => { + expectTypeOf>().toEqualTypeOf<{ BAD_REQUEST: {} } & { NOT_FOUND: {}, INTERNAL_SERVER_ERROR: {} }>() + expectTypeOf>().toEqualTypeOf<{ BAD_REQUEST: {} }>() + expectTypeOf>().toEqualTypeOf<{ BAD_REQUEST: {} }>() + expectTypeOf>().toEqualTypeOf<{ BAD_REQUEST: {} }>() + expectTypeOf>().toEqualTypeOf<{}>() +}) diff --git a/packages/contract/src/error-utils.test.ts b/packages/contract/src/error-utils.test.ts new file mode 100644 index 000000000..2976bd5c6 --- /dev/null +++ b/packages/contract/src/error-utils.test.ts @@ -0,0 +1,283 @@ +import type { ErrorMap } from './error' +import * as ClientModule from '@orpc/client' +import z from 'zod' +import { mergeErrorMap, reconcileORPCError } from './error-utils' + +const ORPCError = ClientModule.ORPCError +const cloneORPCErrorSpy = vi.spyOn(ClientModule, 'cloneORPCError') + +beforeEach(() => { + vi.clearAllMocks() +}) + +it('mergeErrorMap', () => { + const map1 = { + BASE: { message: 'm1' }, + } satisfies ErrorMap + const map2 = { + BASE: { message: 'm2' }, + OVERRIDE: { message: 'm3' }, + } satisfies ErrorMap + + expect(mergeErrorMap(map1, map2)).toEqual({ + BASE: { message: 'm2' }, + OVERRIDE: { message: 'm3' }, + }) + + expect(mergeErrorMap(undefined, map1)).toEqual(map1) + expect(mergeErrorMap(map1, undefined)).toEqual(map1) + expect(mergeErrorMap(undefined, undefined)).toEqual({}) +}) + +describe('reconcileORPCError', () => { + describe('no map error matched', () => { + const map: ErrorMap = { } + + it('should return error itself if error is not defined & inferable', async () => { + const error = new ORPCError('CODE', { message: 'm', data: 'd' }) + expect(await reconcileORPCError(map, error)).toBe(error) + expect(cloneORPCErrorSpy).toHaveBeenCalledTimes(0) + }) + + it('should return modified error if error is defined', async () => { + const error = new ORPCError('CODE', { message: 'm', data: 'd' }) + ;(error.defined as any) = true + + const validated = await reconcileORPCError(map, error) + expect(validated).not.toBe(error) + expect(cloneORPCErrorSpy).toHaveBeenCalledTimes(1) + expect(validated).toBe(cloneORPCErrorSpy.mock.results[0]!.value) + expect(validated.defined).toBe(false) + expect(validated.inferable).toBe(false) + }) + + it('should return error itself if error is inferable', async () => { + const error = new ORPCError('CODE', { message: 'm', data: 'd' }) + ;(error.inferable as any) = true + + const validated = await reconcileORPCError(map, error) + expect(validated).toBe(error) + expect(cloneORPCErrorSpy).toHaveBeenCalledTimes(0) + }) + }) + + describe('code in map but no data schema', () => { + const map: ErrorMap = { CODE: { message: 'm' } } + + it('return error itself if it is defined & inferable', async () => { + const error = new ORPCError('CODE', { message: 'm', data: 'd' }) + ;(error.defined as any) = true + ;(error.inferable as any) = true + + const validated = await reconcileORPCError(map, error) + expect(validated).toBe(error) + expect(validated.code).toBe('CODE') + expect(validated.data).toBe('d') + expect(validated.defined).toBe(true) + expect(validated.inferable).toBe(true) + + expect(cloneORPCErrorSpy).not.toHaveBeenCalled() + }) + + it('return modified error if it is not defined & inferable', async () => { + const error = new ORPCError('CODE', { message: 'm', data: 'd' }) + + const validated = await reconcileORPCError(map, error) + expect(validated).not.toBe(error) + expect(cloneORPCErrorSpy).toHaveBeenCalledTimes(1) + expect(validated).toBe(cloneORPCErrorSpy.mock.results[0]!.value) + expect(validated.code).toBe('CODE') + expect(validated.data).toBe('d') + expect(validated.defined).toBe(true) + expect(validated.inferable).toBe(true) + }) + + it('return modified error if it is not defined', async () => { + const error = new ORPCError('CODE', { message: 'm', data: 'd' }) + ;(error.inferable as any) = true + + const validated = await reconcileORPCError(map, error) + expect(validated).not.toBe(error) + expect(cloneORPCErrorSpy).toHaveBeenCalledTimes(1) + expect(validated).toBe(cloneORPCErrorSpy.mock.results[0]!.value) + expect(validated.code).toBe('CODE') + expect(validated.data).toBe('d') + expect(validated.defined).toBe(true) + expect(validated.inferable).toBe(true) + }) + + it('return modified error if it is not inferable', async () => { + const error = new ORPCError('CODE', { message: 'm', data: 'd' }) + ;(error.inferable as any) = false + + const validated = await reconcileORPCError(map, error) + expect(validated).not.toBe(error) + expect(cloneORPCErrorSpy).toHaveBeenCalledTimes(1) + expect(validated).toBe(cloneORPCErrorSpy.mock.results[0]!.value) + expect(validated.code).toBe('CODE') + expect(validated.data).toBe('d') + expect(validated.defined).toBe(true) + expect(validated.inferable).toBe(true) + }) + }) + + describe('code in map but validation failed', () => { + const map: ErrorMap = { CODE: { data: z.boolean() } } + + it('return error itself if it is not defined & inferable', async () => { + const error = new ORPCError('CODE', { message: 'm', data: 'invalid' }) + + const validated = await reconcileORPCError(map, error) + expect(validated).toBe(error) + expect(validated.code).toBe('CODE') + expect(validated.data).toBe('invalid') + expect(validated.defined).toBe(false) + expect(validated.inferable).toBe(false) + + expect(cloneORPCErrorSpy).not.toHaveBeenCalled() + }) + + it('return modified error if it is defined', async () => { + const error = new ORPCError('CODE', { message: 'm', data: 'invalid' }) + ;(error.defined as any) = true + + const validated = await reconcileORPCError(map, error) + expect(validated).not.toBe(error) + expect(cloneORPCErrorSpy).toHaveBeenCalledTimes(1) + expect(validated).toBe(cloneORPCErrorSpy.mock.results[0]!.value) + expect(validated.code).toBe('CODE') + expect(validated.data).toBe('invalid') + expect(validated.defined).toBe(false) + expect(validated.inferable).toBe(false) + }) + + it('return error itself if it is inferable', async () => { + const error = new ORPCError('CODE', { message: 'm', data: 'invalid' }) + ;(error.inferable as any) = true + + const validated = await reconcileORPCError(map, error) + expect(validated).toBe(error) + expect(cloneORPCErrorSpy).toHaveBeenCalledTimes(0) + }) + }) + + describe('code in map and validation success (without transform data)', () => { + const map: ErrorMap = { CODE: { message: 'm', data: z.number() } } + + it('return error itself if it is defined & inferable', async () => { + const error = new ORPCError('CODE', { message: 'm', data: 1 }) + ;(error.defined as any) = true + ;(error.inferable as any) = true + + const validated = await reconcileORPCError(map, error) + expect(validated).toBe(error) + expect(validated.code).toBe('CODE') + expect(validated.data).toBe(1) + expect(validated.defined).toBe(true) + expect(validated.inferable).toBe(true) + + expect(cloneORPCErrorSpy).not.toHaveBeenCalled() + }) + + it('return modified error if it is not defined', async () => { + const error = new ORPCError('CODE', { message: 'm', data: 1 }) + ;(error.inferable as any) = true + + const validated = await reconcileORPCError(map, error) + expect(validated).not.toBe(error) + expect(cloneORPCErrorSpy).toHaveBeenCalledTimes(1) + expect(validated).toBe(cloneORPCErrorSpy.mock.results[0]!.value) + expect(validated.code).toBe('CODE') + expect(validated.data).toBe(1) + expect(validated.defined).toBe(true) + expect(validated.inferable).toBe(true) + }) + + it('return modified error if it is not inferable', async () => { + const error = new ORPCError('CODE', { message: 'm', data: 1 }) + ;(error.defined as any) = true + + const validated = await reconcileORPCError(map, error) + expect(validated).not.toBe(error) + expect(cloneORPCErrorSpy).toHaveBeenCalledTimes(1) + expect(validated).toBe(cloneORPCErrorSpy.mock.results[0]!.value) + expect(validated.code).toBe('CODE') + expect(validated.data).toBe(1) + expect(validated.defined).toBe(true) + expect(validated.inferable).toBe(true) + }) + + it('return modified error if it is not defined & inferable', async () => { + const error = new ORPCError('CODE', { message: 'm', data: 1 }) + + const validated = await reconcileORPCError(map, error) + expect(validated).not.toBe(error) + expect(cloneORPCErrorSpy).toHaveBeenCalledTimes(1) + expect(validated).toBe(cloneORPCErrorSpy.mock.results[0]!.value) + expect(validated.code).toBe('CODE') + expect(validated.data).toBe(1) + expect(validated.defined).toBe(true) + expect(validated.inferable).toBe(true) + }) + }) + + describe('code in map and validation success (with transform data)', () => { + const map: ErrorMap = { CODE: { message: 'm', data: z.coerce.number() } } + + it('return error cloned itself if it is defined & inferable', async () => { + const error = new ORPCError('CODE', { message: 'm', data: '123' }) + ;(error.defined as any) = true + ;(error.inferable as any) = true + + const validated = await reconcileORPCError(map, error) + expect(validated).not.toBe(error) + expect(cloneORPCErrorSpy).toHaveBeenCalledTimes(1) + expect(validated).toBe(cloneORPCErrorSpy.mock.results[0]!.value) + expect(validated.code).toBe('CODE') + expect(validated.data).toEqual(123) + expect(validated.defined).toBe(true) + expect(validated.inferable).toBe(true) + }) + + it('return modified error if it is not defined', async () => { + const error = new ORPCError('CODE', { message: 'm', data: '123' }) + ;(error.inferable as any) = true + + const validated = await reconcileORPCError(map, error) + expect(validated).not.toBe(error) + expect(cloneORPCErrorSpy).toHaveBeenCalledTimes(1) + expect(validated).toBe(cloneORPCErrorSpy.mock.results[0]!.value) + expect(validated.code).toBe('CODE') + expect(validated.data).toEqual(123) + expect(validated.defined).toBe(true) + expect(validated.inferable).toBe(true) + }) + + it('return modified error if it is not inferable', async () => { + const error = new ORPCError('CODE', { message: 'm', data: '123' }) + ;(error.defined as any) = true + + const validated = await reconcileORPCError(map, error) + expect(validated).not.toBe(error) + expect(cloneORPCErrorSpy).toHaveBeenCalledTimes(1) + expect(validated).toBe(cloneORPCErrorSpy.mock.results[0]!.value) + expect(validated.code).toBe('CODE') + expect(validated.data).toEqual(123) + expect(validated.defined).toBe(true) + expect(validated.inferable).toBe(true) + }) + + it('return modified error if it is not defined & inferable', async () => { + const error = new ORPCError('CODE', { message: 'm', data: '123' }) + + const validated = await reconcileORPCError(map, error) + expect(validated).not.toBe(error) + expect(cloneORPCErrorSpy).toHaveBeenCalledTimes(1) + expect(validated).toBe(cloneORPCErrorSpy.mock.results[0]!.value) + expect(validated.code).toBe('CODE') + expect(validated.data).toEqual(123) + expect(validated.defined).toBe(true) + expect(validated.inferable).toBe(true) + }) + }) +}) diff --git a/packages/contract/src/error-utils.ts b/packages/contract/src/error-utils.ts new file mode 100644 index 000000000..e64b11a82 --- /dev/null +++ b/packages/contract/src/error-utils.ts @@ -0,0 +1,77 @@ +import type { AnyORPCError } from '@orpc/client' +import type { Writable } from '@orpc/shared' +import type { ErrorMap } from './error' +import { cloneORPCError } from '@orpc/client' + +export type MergedErrorMap + = keyof T1 extends never | keyof T2 + ? T2 + : Omit & T2 + +export function mergeErrorMap(errorMap1: T1 | undefined, errorMap2: T2 | undefined): MergedErrorMap { + return { ...errorMap1, ...errorMap2 } as any +} + +export async function reconcileORPCError( + map: ErrorMap, + error: AnyORPCError, +): Promise { + const config = map[error.code] + + if (!config) { + // Do not check `error.inferable` here, because even when config is undefined, + // the returned error can still be inferred on the client side. + if (!error.defined) { + return error + } + + const cloned = cloneORPCError(error) + + ;(cloned.defined as Writable) = false + ;(cloned.inferable as Writable) = false + + return cloned + } + + if (!config.data) { + if (error.defined && error.inferable) { + return error + } + + const cloned = cloneORPCError(error) + + ;(cloned.defined as Writable) = true + ;(cloned.inferable as Writable) = true + + return cloned + } + + const validated = await config.data['~standard'].validate(error.data) + + if (validated.issues) { + // Do not check `error.inferable` here, because even when validation failed, + // the returned error can still be inferred on the client side. + if (!error.defined) { + return error + } + + const cloned = cloneORPCError(error) + + ;(cloned.defined as Writable) = false + ;(cloned.inferable as Writable) = false + + return cloned + } + + if (error.data === validated.value && error.defined && error.inferable) { + return error + } + + const cloned = cloneORPCError(error) + + cloned.data = validated.value + ;(cloned.defined as Writable) = true + ;(cloned.inferable as Writable) = true + + return cloned +} diff --git a/packages/contract/src/error.test-d.ts b/packages/contract/src/error.test-d.ts index f41377ce2..fe15346ef 100644 --- a/packages/contract/src/error.test-d.ts +++ b/packages/contract/src/error.test-d.ts @@ -1,20 +1,21 @@ import type { ORPCError } from '@orpc/client' -import type { outputSchema } from '../tests/shared' -import type { MergedErrorMap, ORPCErrorFromErrorMap } from './error' -import type { InferSchemaOutput } from './schema' +import type { ORPCErrorFromErrorMap } from './error' +import z from 'zod' -it('MergedErrorMap', () => { - expectTypeOf< - MergedErrorMap<{ BASE: { message: string } }, { INVALID: { message: string } }> - >().toMatchTypeOf<{ BASE: { message: string }, INVALID: { message: string } }>() +describe('ORPCErrorFromErrorMap', () => { + it('converts an error map to an ORPCError union and defaults to unknown when schema is undefined', () => { + const errorMap = { + TEST1: { data: z.string() }, + TEST2: { data: z.number().transform(() => 'string') }, + UNDEFINED_SCHEMA: {}, + } - expectTypeOf< - MergedErrorMap<{ BASE: { message: string }, INVALID: { status: number } }, { INVALID: { message: string } }> - >().toMatchTypeOf<{ BASE: { message: string }, INVALID: { message: string } }>() -}) - -it('ORPCErrorFromErrorMap', () => { - expectTypeOf>().toEqualTypeOf>() - expectTypeOf>() - .toEqualTypeOf | ORPCError<'INVALID', InferSchemaOutput>>() + expectTypeOf< + ORPCErrorFromErrorMap + >().toEqualTypeOf< + | ORPCError<'TEST1', string> + | ORPCError<'TEST2', string> + | ORPCError<'UNDEFINED_SCHEMA', unknown> + >() + }) }) diff --git a/packages/contract/src/error.test.ts b/packages/contract/src/error.test.ts index 071837f95..8bf046d74 100644 --- a/packages/contract/src/error.test.ts +++ b/packages/contract/src/error.test.ts @@ -1,86 +1,16 @@ -import type { ErrorMap } from './error' -import { ORPCError } from '@orpc/client' -import z from 'zod' -import { baseErrorMap } from '../tests/shared' -import { mergeErrorMap, validateORPCError, ValidationError } from './error' +import { ValidationError } from './error' it('validationError', () => { - const error = new ValidationError({ message: 'message', issues: [{ message: 'message' }] }) - expect(error).toBeInstanceOf(Error) - expect(error.issues).toEqual([{ message: 'message' }]) -}) - -it('mergeErrorMap', () => { - expect(mergeErrorMap(baseErrorMap, baseErrorMap)).toEqual(baseErrorMap) - expect(mergeErrorMap(baseErrorMap, { OVERRIDE: {}, INVALID: {} })).toEqual( - { OVERRIDE: {}, INVALID: {}, BASE: baseErrorMap.BASE }, - ) -}) - -describe('validateORPCError', () => { - const errors: ErrorMap = { - BAD_GATEWAY: { - data: z.object({ - value: z.string().transform(v => Number.parseInt(v)), - }), - }, - CONFLICT: { - status: 483, - }, - } - - it('ignore not-match errors when defined=false', async () => { - const e1 = new ORPCError('BAD_GATEWAY', { status: 501, data: { value: '123' } }) - expect(await validateORPCError(errors, e1)).toBe(e1) - - const e2 = new ORPCError('NOT_FOUND') - expect(await validateORPCError(errors, e2)).toBe(e2) - - const e3 = new ORPCError('BAD_GATEWAY', { data: 'invalid' }) - expect(await validateORPCError(errors, e3)).toBe(e3) - - const e4 = new ORPCError('CONFLICT') - expect(await validateORPCError(errors, e4)).toBe(e4) - }) - - it('modify not-match errors when defined=true', async () => { - const e1 = new ORPCError('BAD_GATEWAY', { defined: true, status: 501 }) - const v1 = await validateORPCError(errors, e1) - expect(v1).not.toBe(e1) - expect({ ...v1 }).toEqual({ ...e1, defined: false }) - - const e2 = new ORPCError('NOT_FOUND', { defined: true }) - const v2 = await validateORPCError(errors, e2) - expect(v2).not.toBe(e2) - expect({ ...v2 }).toEqual({ ...e2, defined: false }) - - const e3 = new ORPCError('BAD_GATEWAY', { defined: true, data: 'invalid' }) - const v3 = await validateORPCError(errors, e3) - expect(v3).not.toBe(e3) - expect({ ...v3 }).toEqual({ ...e3, defined: false }) - - const e4 = new ORPCError('CONFLICT', { defined: true }) - const v4 = await validateORPCError(errors, e4) - expect(v4).not.toBe(e4) - expect({ ...v4 }).toEqual({ ...e4, defined: false }) - }) - - it('ignore match errors when defined=true and data schema is undefined', async () => { - const e1 = new ORPCError('CONFLICT', { defined: true, status: 483 }) - expect(await validateORPCError(errors, e1)).toBe(e1) - }) - - it('return new error when defined=true and data schema is undefined with match error', async () => { - const e1 = new ORPCError('CONFLICT', { status: 483 }) - const v1 = await validateORPCError(errors, e1) - expect(v1).not.toBe(e1) - expect({ ...v1 }).toEqual({ ...e1, defined: true }) + const issues = [{ path: ['a'], message: 'invalid' }] as any + const error = new ValidationError({ + message: 'Validation failed', + issues, + invalidData: { a: 1 }, }) - it('return new with defined=true and validated data with match errors', async () => { - const e1 = new ORPCError('BAD_GATEWAY', { data: { value: '123' } }) - const v1 = await validateORPCError(errors, e1) - expect(v1).not.toBe(e1) - expect({ ...v1 }).toEqual({ ...e1, defined: true, data: { value: 123 } }) - }) + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(ValidationError) + expect(error.message).toBe('Validation failed') + expect(error.issues).toBe(issues) + expect(error.invalidData).toEqual({ a: 1 }) }) diff --git a/packages/contract/src/error.ts b/packages/contract/src/error.ts index dc23ebd07..aaa636fb4 100644 --- a/packages/contract/src/error.ts +++ b/packages/contract/src/error.ts @@ -1,36 +1,7 @@ -import type { ORPCErrorCode } from '@orpc/client' -import type { ThrowableError } from '@orpc/shared' +import type { ORPCError, ORPCErrorCode } from '@orpc/client' import type { AnySchema, InferSchemaOutput, Schema, SchemaIssue } from './schema' -import { fallbackORPCErrorStatus, ORPCError } from '@orpc/client' - -export interface ValidationErrorOptions extends ErrorOptions { - message: string - issues: readonly SchemaIssue[] - /** - * @todo require this field in v2 - */ - data?: unknown -} - -/** - * This errors usually used for ORPCError.cause when the error is a validation error. - * - * @see {@link https://orpc.dev/docs/advanced/validation-errors Validation Errors Docs} - */ -export class ValidationError extends Error { - readonly issues: readonly SchemaIssue[] - readonly data: unknown - - constructor(options: ValidationErrorOptions) { - super(options.message, options) - - this.issues = options.issues - this.data = options.data - } -} export interface ErrorMapItem { - status?: number message?: string data?: TDataSchema } @@ -39,45 +10,31 @@ export type ErrorMap = { [key in ORPCErrorCode]?: ErrorMapItem } -export type MergedErrorMap = Omit & T2 - -export function mergeErrorMap(errorMap1: T1, errorMap2: T2): MergedErrorMap { - return { ...errorMap1, ...errorMap2 } -} - export type ORPCErrorFromErrorMap = { [K in keyof TErrorMap]: K extends string - ? TErrorMap[K] extends ErrorMapItem> + ? TErrorMap[K] extends ErrorMapItem> ? ORPCError> : never : never }[keyof TErrorMap] -export type ErrorFromErrorMap = ORPCErrorFromErrorMap | ThrowableError - -export async function validateORPCError(map: ErrorMap, error: ORPCError): Promise> { - const { code, status, message, data, cause, defined } = error - const config = map?.[error.code] - - if (!config || fallbackORPCErrorStatus(error.code, config.status) !== error.status) { - return defined - ? new ORPCError(code, { defined: false, status, message, data, cause }) - : error - } +export interface ValidationErrorOptions extends ErrorOptions { + message: string + issues: readonly SchemaIssue[] + invalidData: unknown +} - if (!config.data) { - return defined - ? error - : new ORPCError(code, { defined: true, status, message, data, cause }) - } +export class ValidationError extends Error { + /** + * This array is readonly because the upstream Standard Schema returns readonly issues. + */ + issues: readonly SchemaIssue[] + invalidData: unknown - const validated = await config.data['~standard'].validate(error.data) + constructor(options: ValidationErrorOptions) { + super(options.message, options) - if (validated.issues) { - return defined - ? new ORPCError(code, { defined: false, status, message, data, cause }) - : error + this.issues = options.issues + this.invalidData = options.invalidData } - - return new ORPCError(code, { defined: true, status, message, data: validated.value, cause }) } diff --git a/packages/contract/src/event-iterator.test.ts b/packages/contract/src/event-iterator.test.ts index ea29752f7..953b7ae6d 100644 --- a/packages/contract/src/event-iterator.test.ts +++ b/packages/contract/src/event-iterator.test.ts @@ -1,18 +1,27 @@ +import type { AnyORPCError } from '@orpc/client' import { ORPCError } from '@orpc/client' -import { getEventMeta, withEventMeta } from '@orpc/standard-server' +import { getEventMeta, withEventMeta } from '@standardserver/core' import * as z from 'zod' import { ValidationError } from './error' import { eventIterator, getEventIteratorSchemaDetails } from './event-iterator' -describe('eventIterator', async () => { +const ORDER_SCHEMA = z.object({ order: z.number() }) + +function assertValidationSuccess(result: T): asserts result is T & { issues: undefined } { + if (result.issues) { + throw new Error('Validation failed') + } +} + +describe('eventIterator', () => { it('expect a async iterator object', async () => { - const schema = eventIterator(z.object({ order: z.number() })) + const schema = eventIterator(ORDER_SCHEMA) const result = await schema['~standard'].validate(123) expect(result.issues).toHaveLength(1) }) - it('can validate yields', async () => { - const schema = eventIterator(z.object({ order: z.number() })) + it('can validate yields and preserve meta', async () => { + const schema = eventIterator(ORDER_SCHEMA) const result = await schema['~standard'].validate((async function* () { yield { order: 1 } @@ -20,79 +29,61 @@ describe('eventIterator', async () => { yield { order: '3' } })()) - if (result.issues) { - throw new Error('Validation failed') - } - - await expect(result.value.next()).resolves.toSatisfy(({ done, value }) => { - expect(done).toBe(false) - expect(value).toEqual({ order: 1 }) - expect(getEventMeta(value)).toEqual(undefined) + assertValidationSuccess(result) - return true - }) + const first = await result.value.next() + expect(first.done).toBe(false) + expect(first.value).toEqual({ order: 1 }) + expect(getEventMeta(first.value)).toEqual(undefined) - await expect(result.value.next()).resolves.toSatisfy(({ done, value }) => { - expect(done).toBe(false) - expect(value).toEqual({ order: 2 }) - expect(getEventMeta(value)).toEqual({ id: 'id-2' }) + const second = await result.value.next() + expect(second.done).toBe(false) + expect(second.value).toEqual({ order: 2 }) + expect(getEventMeta(second.value)).toEqual({ id: 'id-2' }) - return true - }) - - await expect(result.value.next()).rejects.toSatisfy((e) => { - expect(e).toBeInstanceOf(ORPCError) - expect(e.code).toEqual('EVENT_ITERATOR_VALIDATION_FAILED') - expect(e.cause).toBeInstanceOf(ValidationError) - expect(e.cause.issues).toHaveLength(1) - expect(e.cause.data).toEqual({ order: '3' }) - - return true - }) + try { + await result.value.next() + throw new Error('Expected event iterator validation to fail') + } + catch (error) { + expect(error).toBeInstanceOf(ORPCError) + expect((error as AnyORPCError).code).toEqual('EVENT_ITERATOR_VALIDATION_FAILED') + expect((error as AnyORPCError).cause).toBeInstanceOf(ValidationError) + expect(((error as AnyORPCError).cause as ValidationError).issues).toHaveLength(1) + expect(((error as AnyORPCError).cause as ValidationError).invalidData).toEqual({ order: '3' }) + } }) - it('can validate returns', async () => { - const schema = eventIterator(z.object({ order: z.number() }), z.object({ order: z.number() })) + it('can validate returns and preserve meta', async () => { + const schema = eventIterator(ORDER_SCHEMA, ORDER_SCHEMA) const result = await schema['~standard'].validate((async function* () { return { order: 1 } })()) - if (result.issues) { - throw new Error('Validation failed') - } - - await expect(result.value.next()).resolves.toSatisfy(({ done, value }) => { - expect(done).toBe(true) - expect(value).toEqual({ order: 1 }) - expect(getEventMeta(value)).toEqual(undefined) + assertValidationSuccess(result) - return true - }) + const returned = await result.value.next() + expect(returned.done).toBe(true) + expect(returned.value).toEqual({ order: 1 }) + expect(getEventMeta(returned.value)).toEqual(undefined) }) it('not required returns schema', async () => { - const schema = eventIterator(z.object({ order: z.number() })) + const schema = eventIterator(ORDER_SCHEMA) const result = await schema['~standard'].validate((async function* () { return 'anything' })()) - if (result.issues) { - throw new Error('Validation failed') - } - - await expect(result.value.next()).resolves.toSatisfy(({ done, value }) => { - expect(done).toBe(true) - expect(value).toEqual('anything') + assertValidationSuccess(result) - return true - }) + await expect(result.value.next()).resolves.toEqual({ done: true, value: 'anything' }) }) it('cleanup origin when validation fails', async () => { let cleanupCalled = false - const schema = eventIterator(z.object({ order: z.number() })) + const schema = eventIterator(ORDER_SCHEMA) const result = await schema['~standard'].validate((async function* () { try { @@ -105,18 +96,20 @@ describe('eventIterator', async () => { } })()) - await expect((result as any).value.next()).resolves.toEqual({ done: false, value: { order: 1 } }) - await expect((result as any).value.next()).rejects.toThrow('Event iterator validation failed') + assertValidationSuccess(result) + + await expect(result.value.next()).resolves.toEqual({ done: false, value: { order: 1 } }) + await expect(result.value.next()).rejects.toThrow('Event iterator validation failed') expect(cleanupCalled).toBe(true) }) }) it('getEventIteratorSchemaDetails', async () => { - const yieldSchema = z.object({ order: z.number() }) - const returnSchema = z.object({ order: z.number() }) + const yieldSchema = ORDER_SCHEMA + const returnSchema = ORDER_SCHEMA const schema = eventIterator(yieldSchema, returnSchema) - expect(getEventIteratorSchemaDetails(schema)).toEqual({ yields: yieldSchema, returns: returnSchema }) + expect(getEventIteratorSchemaDetails(schema)).toEqual({ yieldSchema, returnSchema }) expect(getEventIteratorSchemaDetails(undefined)).toBeUndefined() expect(getEventIteratorSchemaDetails(z.object({}))).toBeUndefined() }) diff --git a/packages/contract/src/event-iterator.ts b/packages/contract/src/event-iterator.ts index b3f7c7dce..0641c9c72 100644 --- a/packages/contract/src/event-iterator.ts +++ b/packages/contract/src/event-iterator.ts @@ -1,14 +1,14 @@ import type { AsyncIteratorClass } from '@orpc/shared' import type { AnySchema, Schema } from './schema' -import { mapEventIterator, ORPCError } from '@orpc/client' -import { isAsyncIteratorObject } from '@orpc/shared' +import { ORPCError, wrapEventIteratorPreservingMeta } from '@orpc/client' +import { isAsyncIteratorObject, ORPC_NAME } from '@orpc/shared' import { ValidationError } from './error' -const EVENT_ITERATOR_DETAILS_SYMBOL = Symbol('ORPC_EVENT_ITERATOR_DETAILS') +const EVENT_ITERATOR_SCHEMA_DETAILS_SYMBOL = Symbol.for('ORPC_EVENT_ITERATOR_SCHEMA_DETAILS') export interface EventIteratorSchemaDetails { - yields: AnySchema - returns?: AnySchema + yieldSchema: AnySchema + returnSchema?: AnySchema } /** @@ -17,43 +17,42 @@ export interface EventIteratorSchemaDetails { * @see {@link https://orpc.dev/docs/event-iterator#validate-event-iterator Validate Event Iterator Docs} */ export function eventIterator( - yields: Schema, - returns?: Schema, + yieldSchema: Schema, + returnSchema?: Schema, ): Schema, AsyncIteratorClass> { return { '~standard': { - [EVENT_ITERATOR_DETAILS_SYMBOL as any]: { yields, returns } satisfies EventIteratorSchemaDetails, - vendor: 'orpc', + [EVENT_ITERATOR_SCHEMA_DETAILS_SYMBOL as any]: { yieldSchema, returnSchema } satisfies EventIteratorSchemaDetails, + vendor: ORPC_NAME, version: 1, validate(iterator) { if (!isAsyncIteratorObject(iterator)) { return { issues: [{ message: 'Expect event iterator', path: [] }] } } - const mapped = mapEventIterator(iterator, { - async value(value, done) { - const schema = done ? returns : yields + const mapped = wrapEventIteratorPreservingMeta(iterator, { + async mapResult(result) { + const schema = result.done ? returnSchema : yieldSchema if (!schema) { - return value + return result } - const result = await schema['~standard'].validate(value) + const validated = await schema['~standard'].validate(result.value) - if (result.issues) { + if (validated.issues) { throw new ORPCError('EVENT_ITERATOR_VALIDATION_FAILED', { message: 'Event iterator validation failed', cause: new ValidationError({ - issues: result.issues, + issues: validated.issues, message: 'Event iterator validation failed', - data: value, + invalidData: result.value, }), }) } - return result.value + return { done: result.done, value: validated.value } }, - error: async error => error, }) return { value: mapped } @@ -67,5 +66,5 @@ export function getEventIteratorSchemaDetails(schema: AnySchema | undefined): un return undefined } - return (schema['~standard'] as any)[EVENT_ITERATOR_DETAILS_SYMBOL] + return (schema['~standard'] as any)[EVENT_ITERATOR_SCHEMA_DETAILS_SYMBOL] } diff --git a/packages/contract/src/index.test.ts b/packages/contract/src/index.test.ts new file mode 100644 index 000000000..1a98e575e --- /dev/null +++ b/packages/contract/src/index.test.ts @@ -0,0 +1,5 @@ +it('exports oc', async () => { + await expect(import('./index')).resolves.toMatchObject({ + oc: expect.any(Object), + }) +}) diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index 56baab5c6..80af29036 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -1,21 +1,18 @@ export * from './builder' export * from './builder-variants' -export * from './config' +export * from './caller' export * from './error' +export * from './error-utils' export * from './event-iterator' -export * from './link-utils' export * from './meta' +export * from './meta-built-in' +export * from './meta-utils' export * from './procedure' export * from './procedure-client' -export * from './route' export * from './router' export * from './router-client' export * from './router-utils' export * from './schema' export * from './schema-utils' -export * from './types' -export { ORPCError } from '@orpc/client' -export type { HTTPMethod, HTTPPath } from '@orpc/client' -export { AsyncIteratorClass } from '@orpc/shared' export type { Registry, ThrowableError } from '@orpc/shared' diff --git a/packages/contract/src/link-utils.test-d.ts b/packages/contract/src/link-utils.test-d.ts deleted file mode 100644 index 2011d0844..000000000 --- a/packages/contract/src/link-utils.test-d.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { RPCLink } from '@orpc/client/fetch' -import { router as contract } from '../tests/shared' -import { inferRPCMethodFromContractRouter } from './link-utils' - -it('inferRPCMethodFromContractRouter', () => { - const link = new RPCLink({ - url: 'http://localhost:3000/rpc', - method: inferRPCMethodFromContractRouter(contract), - }) -}) diff --git a/packages/contract/src/link-utils.test.ts b/packages/contract/src/link-utils.test.ts deleted file mode 100644 index f76b4692e..000000000 --- a/packages/contract/src/link-utils.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { oc } from './builder' -import { inferRPCMethodFromContractRouter } from './link-utils' -import { minifyContractRouter } from './router-utils' - -it('inferRPCMethodFromContractRouter', () => { - const method = inferRPCMethodFromContractRouter(minifyContractRouter({ - head: oc.route({ method: 'HEAD' }), - get: oc.route({ method: 'GET' }), - post: oc.route({}), - nested: { - get: oc.route({ method: 'GET' }), - delete: oc.route({ method: 'DELETE' }), - }, - })) - - expect(method({}, ['head'])).toBe('GET') - expect(method({}, ['get'])).toBe('GET') - expect(method({}, ['post'])).toBe('POST') - expect(method({}, ['nested', 'get'])).toBe('GET') - expect(method({}, ['nested', 'delete'])).toBe('DELETE') - - expect(() => method({}, ['nested', 'not-exist'])).toThrow(/No valid procedure found at path/) -}) diff --git a/packages/contract/src/link-utils.ts b/packages/contract/src/link-utils.ts deleted file mode 100644 index e5fcb564c..000000000 --- a/packages/contract/src/link-utils.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { HTTPMethod } from '@orpc/client' -import type { AnyContractRouter } from './router' -import { get } from '@orpc/shared' -import { fallbackContractConfig } from './config' -import { isContractProcedure } from './procedure' - -/** - * Help RPCLink automatically send requests using the specified HTTP method in the contract. - * - * @see {@link https://orpc.dev/docs/client/rpc-link#custom-request-method RPCLink Custom Request Method} - */ -export function inferRPCMethodFromContractRouter(contract: AnyContractRouter): (options: unknown, path: readonly string[]) => Exclude { - return (_, path) => { - const procedure = get(contract, path) - - if (!isContractProcedure(procedure)) { - throw new Error( - `[inferRPCMethodFromContractRouter] No valid procedure found at path "${path.join('.')}". ` - + `This may happen when the contract router is not properly configured.`, - ) - } - - const method = fallbackContractConfig('defaultMethod', procedure['~orpc'].route.method) - - return method === 'HEAD' ? 'GET' : method - } -} diff --git a/packages/contract/src/meta-built-in.test.ts b/packages/contract/src/meta-built-in.test.ts new file mode 100644 index 000000000..ff2b72e86 --- /dev/null +++ b/packages/contract/src/meta-built-in.test.ts @@ -0,0 +1,39 @@ +import { getPathMeta, meta } from './meta-built-in' + +describe('meta.path', () => { + it('returns plugin with correct name', () => { + const plugin = meta.path(['users', 'list']) + expect(plugin.name).toBe('~path') + }) + + it('init merges ~path into existing meta', () => { + const plugin = meta.path(['users', 'list']) + const result = plugin.init!({ existing: true } as any) + expect(result).toEqual({ 'existing': true, '~path': ['users', 'list'] }) + }) + + it('init overwrites existing ~path', () => { + const plugin = meta.path(['new']) + const result = plugin.init!({ '~path': ['old'] } as any) + expect(result).toEqual({ '~path': ['new'] }) + }) + + it('init does not mutate the original meta', () => { + const plugin = meta.path(['a']) + const original = { x: 1 } as any + const result = plugin.init!(original) + expect(result).not.toBe(original) + }) +}) + +describe('getPathMeta', () => { + it('returns the path from meta', () => { + const input = { '~orpc': { meta: { '~path': ['a', 'b'] } } } + expect(getPathMeta(input as any)).toEqual(['a', 'b']) + }) + + it('returns undefined when ~path is not set', () => { + const input = { '~orpc': { meta: {} } } + expect(getPathMeta(input as any)).toBeUndefined() + }) +}) diff --git a/packages/contract/src/meta-built-in.ts b/packages/contract/src/meta-built-in.ts new file mode 100644 index 000000000..6dd03ecc8 --- /dev/null +++ b/packages/contract/src/meta-built-in.ts @@ -0,0 +1,35 @@ +import type { ErrorMap } from './error' +import type { Meta, MetaPlugin } from './meta' +import type { AnySchema } from './schema' + +export interface PathMetaPlugin< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, +> extends MetaPlugin { + name: '~path' +} + +export const meta = { + path< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + >( + path: string[], + ): PathMetaPlugin { + return { + name: '~path', + init(meta) { + return { + ...meta, + '~path': path, + } + }, + } + }, +} + +export function getPathMeta(procedureOrLazy: { '~orpc': { meta: Meta } }): string[] | undefined { + return procedureOrLazy['~orpc'].meta['~path'] as string[] | undefined +} diff --git a/packages/contract/src/meta-utils.test.ts b/packages/contract/src/meta-utils.test.ts new file mode 100644 index 000000000..c4db6b707 --- /dev/null +++ b/packages/contract/src/meta-utils.test.ts @@ -0,0 +1,80 @@ +import type { AnyMetaPlugin } from './meta' +import { oc } from './builder' +import { defineMeta, resolveMetaPlugins } from './meta-utils' + +it('resolveMetaPlugins', () => { + const baseMeta = { mode: 'base' } + + const plugin1 = { + name: 'plugin1', + init: vi.fn(m => ({ ...m, p1: true })), + apply: vi.fn(m => ({ ...m, a1: true })), + } satisfies AnyMetaPlugin + const plugin2 = { + name: 'plugin2', + init: vi.fn(m => ({ ...m, p2: true })), + apply: vi.fn(m => ({ ...m, a2: true })), + } satisfies AnyMetaPlugin + const plugin3 = { + name: 'plugin3', + init: vi.fn(m => ({ ...m, p3: true })), + apply: vi.fn(m => ({ ...m, a3: true })), + } satisfies AnyMetaPlugin + const plugin4 = { + name: 'plugin4', + } satisfies AnyMetaPlugin + + const [meta, plugins] = resolveMetaPlugins(baseMeta, [plugin1], [plugin2, plugin3, plugin4]) + + expect(meta).not.toBe(baseMeta) + expect(meta).toEqual({ + mode: 'base', + a1: true, + a2: true, + a3: true, + p2: true, + p3: true, + }) + expect(plugins).toEqual([plugin1, plugin2, plugin3, plugin4]) + + expect(plugin1.init).not.toHaveBeenCalled() // already initialized + expect(plugin2.init).toHaveBeenCalledTimes(1) + expect(plugin2.init).toHaveBeenCalledWith({ mode: 'base' }) + + expect(plugin3.init).toHaveBeenCalledTimes(1) + expect(plugin3.init).toHaveBeenCalledWith({ mode: 'base', p2: true }) + + expect(plugin1.apply).toHaveBeenCalledTimes(1) + expect(plugin1.apply).toHaveBeenCalledWith({ mode: 'base', p2: true, p3: true }) + + expect(plugin2.apply).toHaveBeenCalledTimes(1) + expect(plugin2.apply).toHaveBeenCalledWith({ mode: 'base', a1: true, p2: true, p3: true }) + + expect(plugin3.apply).toHaveBeenCalledTimes(1) + expect(plugin3.apply).toHaveBeenCalledWith({ mode: 'base', a1: true, a2: true, p2: true, p3: true }) + + expect(plugin2.init).toHaveBeenCalledBefore(plugin3.init) + expect(plugin1.apply).toHaveBeenCalledBefore(plugin2.apply) + expect(plugin2.apply).toHaveBeenCalledBefore(plugin3.apply) +}) + +it('defineMeta', () => { + interface AuthMeta { + required?: boolean + scope?: 'user' | 'admin' + } + + const [authMeta, getAuthMeta] = defineMeta( + 'auth', + (incoming: AuthMeta, current) => ({ ...current, ...incoming }), + ) + + const requiredAuthProcedure = oc.meta(authMeta({ required: true })) + const adminAuthProcedure = oc.meta(authMeta({ scope: 'admin' })) + const requiredAndAdminProcedure = oc.meta(authMeta({ required: true, scope: 'user' })).meta(authMeta({ scope: 'admin' })) + + expect(getAuthMeta(oc)).toBeUndefined() + expect(getAuthMeta(requiredAuthProcedure)).toEqual({ required: true }) + expect(getAuthMeta(adminAuthProcedure)).toEqual({ scope: 'admin' }) + expect(getAuthMeta(requiredAndAdminProcedure)).toEqual({ required: true, scope: 'admin' }) +}) diff --git a/packages/contract/src/meta-utils.ts b/packages/contract/src/meta-utils.ts new file mode 100644 index 000000000..357775440 --- /dev/null +++ b/packages/contract/src/meta-utils.ts @@ -0,0 +1,100 @@ +import type { ErrorMap } from './error' +import type { AnyMetaPlugin, Meta, MetaPlugin } from './meta' +import type { AnySchema } from './schema' +import { toArray } from '@orpc/shared' + +export function resolveMetaPlugins< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, +>( + baseMeta: Meta, + existingPlugins: MetaPlugin[] | undefined, + incomingPlugins: MetaPlugin[] | undefined, +): [meta: Meta, plugins: MetaPlugin[]] { + existingPlugins = toArray(existingPlugins) + incomingPlugins = toArray(incomingPlugins) + + let meta = baseMeta + for (const plugin of incomingPlugins) { + if (plugin.init) { + meta = plugin.init(meta) + } + } + + const plugins = [...existingPlugins, ...incomingPlugins] + + for (const plugin of plugins) { + if (plugin.apply) { + meta = plugin.apply(meta) + } + } + + return [meta, plugins] +} + +/** + * Quickly defines a meta plugin factory and reader. + * + * @example Mark a procedure as requiring authentication and read it in middleware. + * ```ts + * interface AuthMeta { + * required?: boolean + * scope?: 'user' | 'admin' + * } + * + * const [authMeta, getAuthMeta] = defineMeta( + * 'auth', + * (incoming: AuthMeta, current) => ({ ...current, ...incoming }), + * ) + * + * const deletePostContract = oc + * .meta(authMeta({ required: true, scope: 'admin' })) + * .input(z.object({ postId: z.string() })) + * .output(z.object({ success: z.boolean() })) + * + * const authMiddleware = os.middleware(async ({ context, procedure, next }) => { + * const auth = getAuthMeta(procedure) + * + * if (auth?.required && !context.user) { + * throw new ORPCError('UNAUTHORIZED') + * } + * + * if (auth?.scope === 'admin' && !context.user?.isAdmin) { + * throw new ORPCError('FORBIDDEN') + * } + * + * return next() + * }) + * ``` + * + * @param name - Unique key for storing this meta entry. + * @param merge - Merges the existing value (or `undefined`) with the incoming value when applied multiple times. + * + * @returns A `[metaPlugin, getMeta]` tuple: + * - `metaPlugin(metadata)` - Attaches metadata to a procedure under `name`. + * - `getMeta(procedureOrLazy)` - Retrieves the metadata, or `undefined` if not set. + */ +export function defineMeta( + name: TName, + merge: (incoming: TData, current: TData | undefined) => TData, +): [ + metaPlugin: (meta: TData) => AnyMetaPlugin & { name: TName }, + getMeta: (procedureOrLazy: { '~orpc': { meta: Meta } }) => TData | undefined, +] { + const metaPlugin = (value: TData): AnyMetaPlugin & { name: TName } => ({ + name, + init: (meta) => { + const current = meta[name] as TData | undefined + + return { + ...meta, + [name]: merge(value, current), + } + }, + }) + + const getMeta = (procedureOrLazy: { '~orpc': { meta: Meta } }) => procedureOrLazy['~orpc'].meta[name] as TData | undefined + + return [metaPlugin, getMeta] +} diff --git a/packages/contract/src/meta.test.ts b/packages/contract/src/meta.test.ts index 684314413..208c22ec9 100644 --- a/packages/contract/src/meta.test.ts +++ b/packages/contract/src/meta.test.ts @@ -1,6 +1,27 @@ -import { mergeMeta } from './meta' +import type { AnyMetaPlugin } from './meta' +import { getHiddenMetaPlugins, setHiddenMetaPlugins } from './meta' -it('mergeMeta', () => { - expect(mergeMeta({}, { a: 2 })).toEqual({ a: 2 }) - expect(mergeMeta({ a: 1, b: 1 }, { a: 2 })).toEqual({ a: 2, b: 1 }) +describe('getHiddenMetaPlugins', () => { + const metaPlugins: AnyMetaPlugin[] = [ + { + name: 'plugin', + init: meta => ({ ...meta, enabled: true }), + }, + ] + + it('returns undefined for non-typescript objects', () => { + expect(getHiddenMetaPlugins(undefined)).toBeUndefined() + expect(getHiddenMetaPlugins(null)).toBeUndefined() + expect(getHiddenMetaPlugins('value')).toBeUndefined() + expect(getHiddenMetaPlugins(123)).toBeUndefined() + expect(getHiddenMetaPlugins(true)).toBeUndefined() + }) + + it('returns previously assigned hidden meta plugins', () => { + const container = {} + + setHiddenMetaPlugins(container, metaPlugins) + + expect(getHiddenMetaPlugins(container)).toBe(metaPlugins) + }) }) diff --git a/packages/contract/src/meta.ts b/packages/contract/src/meta.ts index 2220d191e..67330499d 100644 --- a/packages/contract/src/meta.ts +++ b/packages/contract/src/meta.ts @@ -1,5 +1,57 @@ -export type Meta = Record +import type { ErrorMap } from './error' +import type { AnySchema } from './schema' +import { isTypescriptObject } from '@orpc/shared' -export function mergeMeta(meta1: T, meta2: T): T { - return { ...meta1, ...meta2 } +export interface Meta { + [key: PropertyKey]: unknown +} + +export interface MetaPluginDefinition< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, +> { + __TInputSchema?: { type: TInputSchema } + __TOutputSchema?: { type: TOutputSchema } + __TErrorMap?: { type: TErrorMap } +} + +export interface MetaPlugin< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, +> { + /** This only for types, so it should be optional */ + '~orpc'?: MetaPluginDefinition | undefined + + /** Unique name of the plugin, used for identification. */ + 'name': string + + /** + * Runs once when this plugin is first added to the builder. + * Use this to set up initial metadata values. + */ + 'init'?: (meta: Meta) => Meta + + /** + * Runs every time metadata is updated. + * This is called for all plugins in the chain whenever a new plugin is added. + */ + 'apply'?: (meta: Meta) => Meta +} + +export type AnyMetaPlugin = MetaPlugin + +export const HIDDEN_META_PLUGINS_SYMBOL = Symbol.for('ORPC_HIDDEN_META_PLUGINS') + +export function getHiddenMetaPlugins(container: unknown): AnyMetaPlugin[] | undefined { + if (!isTypescriptObject(container)) { + return undefined + } + + return container[HIDDEN_META_PLUGINS_SYMBOL] as AnyMetaPlugin[] | undefined +} + +export function setHiddenMetaPlugins(container: T, metaPlugins: AnyMetaPlugin[]) { + (container as any)[HIDDEN_META_PLUGINS_SYMBOL] = metaPlugins } diff --git a/packages/contract/src/plugins/index.test.ts b/packages/contract/src/plugins/index.test.ts index 417e91c2b..639bce4fd 100644 --- a/packages/contract/src/plugins/index.test.ts +++ b/packages/contract/src/plugins/index.test.ts @@ -1,3 +1,6 @@ -it('exports something', async () => { - expect(await import('./index')).toHaveProperty('ResponseValidationPlugin') +it('exports RequestValidationLinkPlugin, ResponseValidationLinkPlugin', async () => { + await expect(import('.')).resolves.toMatchObject({ + RequestValidationLinkPlugin: expect.any(Function), + ResponseValidationLinkPlugin: expect.any(Function), + }) }) diff --git a/packages/contract/src/plugins/request-validation.test.ts b/packages/contract/src/plugins/request-validation.test.ts index 2a7d17835..2de42c072 100644 --- a/packages/contract/src/plugins/request-validation.test.ts +++ b/packages/contract/src/plugins/request-validation.test.ts @@ -2,106 +2,139 @@ import { ORPCError } from '@orpc/client' import { StandardLink } from '@orpc/client/standard' import * as z from 'zod' import { ValidationError } from '../error' -import { ContractProcedure } from '../procedure' -import { RequestValidationPlugin, RequestValidationPluginError } from './request-validation' +import { ProcedureContract } from '../procedure' +import { RequestValidationLinkPlugin } from './request-validation' beforeEach(() => { vi.clearAllMocks() }) -describe('requestValidationPlugin', () => { - const schema = z.object({ - value: z.number().transform(v => v.toString()), - }) - - const procedure = new ContractProcedure({ - inputSchema: schema, - errorMap: { - TEST: { - data: schema, - }, - }, +describe('requestValidationLinkPlugin', () => { + const chainedProcedure = new ProcedureContract({ + inputSchemas: [ + z.number().transform(value => value + 1), + z.number().min(2), + ], + outputSchemas: [], + errorMap: {}, meta: {}, - route: {}, }) - const withoutInputSchemaProcedure = new ContractProcedure({ + const withoutInputSchemaProcedure = new ProcedureContract({ + outputSchemas: [], errorMap: {}, meta: {}, - route: {}, }) const contract = { - procedure, + chainedProcedure, nested: { - procedure, + chainedProcedure, }, withoutInputSchema: withoutInputSchemaProcedure, } const codec = { - decode: vi.fn(), - encode: vi.fn(), + encodeInput: vi.fn(), + decodeResponse: vi.fn(), } - const client = { - call: vi.fn(), + const transport = { + send: vi.fn(), } const interceptor = vi.fn(({ next }) => next()) - const link = new StandardLink(codec, client, { + const link = new StandardLink(codec, transport, { plugins: [ - new RequestValidationPlugin(contract), + new RequestValidationLinkPlugin(contract), ], - // RequestValidationPlugin should execute before user defined interceptors interceptors: [interceptor], }) - describe('validate input', async () => { - it('procedure with input schema', async () => { - codec.decode.mockResolvedValueOnce('__output__') - - const output = await link.call(['procedure'], { value: 123 }, { context: {} }) + const linkUsingValidatedInput = new StandardLink(codec, transport, { + plugins: [ + new RequestValidationLinkPlugin(contract, { forwardValidatedInput: true }), + ], + }) - expect(output).toEqual('__output__') - expect(client.call.mock.calls[0]?.[3]).toEqual( - { value: 123 }, - ) + it('forwards the original input by default after local validation succeeds', async () => { + codec.encodeInput.mockResolvedValueOnce({ + method: 'POST', + url: '/chainedProcedure', + headers: {}, + body: '__encoded__', }) + transport.send.mockResolvedValueOnce({ + status: 200, + headers: {}, + resolveBody: () => Promise.resolve('__body__'), + }) + codec.decodeResponse.mockResolvedValueOnce({ kind: 'output', output: '__output__' }) - it('procedure without input schema', async () => { - codec.decode.mockResolvedValueOnce('__output__') + const output = await link.call(['chainedProcedure'], 1, { context: {} }) - const output = await link.call(['withoutInputSchema'], 'anything', { context: {} }) + expect(output).toBe('__output__') + expect(codec.encodeInput).toHaveBeenCalledWith(1, ['chainedProcedure'], { context: {} }) + expect(interceptor).toHaveBeenCalledTimes(1) + }) - expect(output).toEqual('__output__') - expect(client.call.mock.calls[0]?.[3]).toEqual('anything') + it('can replace the downstream input with the validated value when enabled', async () => { + codec.encodeInput.mockResolvedValueOnce({ + method: 'POST', + url: '/chainedProcedure', + headers: {}, + body: '__encoded__', + }) + transport.send.mockResolvedValueOnce({ + status: 200, + headers: {}, + resolveBody: () => Promise.resolve('__body__'), }) + codec.decodeResponse.mockResolvedValueOnce({ kind: 'output', output: '__output__' }) - it('throw if input does not match the expected schema', async () => { - await expect(link.call(['nested', 'procedure'], { value: 'not a number' }, { context: {} })).rejects.toThrow( - new ORPCError('BAD_REQUEST', { - message: 'Input validation failed', - data: { - issues: expect.any(Object), - }, - cause: new ValidationError({ - message: 'Input validation failed', - issues: expect.any(Array), - data: { value: 'not a number' }, - }), - }), - ) + const output = await linkUsingValidatedInput.call(['chainedProcedure'], 1, { context: {} }) + + expect(output).toBe('__output__') + expect(codec.encodeInput).toHaveBeenCalledWith(2, ['chainedProcedure'], { context: {} }) + }) + + it('skips validation when the procedure has no input schemas', async () => { + codec.encodeInput.mockResolvedValueOnce({ + method: 'POST', + url: '/withoutInputSchema', + headers: {}, + body: '__encoded__', + }) + transport.send.mockResolvedValueOnce({ + status: 200, + headers: {}, + resolveBody: () => Promise.resolve('__body__'), }) + codec.decodeResponse.mockResolvedValueOnce({ kind: 'output', output: '__output__' }) + + const output = await link.call(['withoutInputSchema'], 'anything', { context: {} }) + + expect(output).toBe('__output__') + expect(codec.encodeInput).toHaveBeenCalledWith('anything', ['withoutInputSchema'], { context: {} }) }) - it('throw if not find matching contract', async () => { - await expect(link.call(['not', 'found'], {}, { context: {} })).rejects.toThrow( - new RequestValidationPluginError('No valid procedure found at path "not.found", this may happen when the contract router is not properly configured.'), - ) - await expect(interceptor.mock.results[0]?.value).rejects.toThrow( - new RequestValidationPluginError('No valid procedure found at path "not.found", this may happen when the contract router is not properly configured.'), + it('throws a BAD_REQUEST error when any validation step fails', async () => { + await expect(link.call(['nested', 'chainedProcedure'], 0, { context: {} })).rejects.toThrow( + new ORPCError('BAD_REQUEST', { + message: 'Input validation failed', + data: { + issues: expect.any(Array), + }, + cause: new ValidationError({ + message: 'Input validation failed', + issues: expect.any(Array), + invalidData: 1, + }), + }), ) + + expect(codec.encodeInput).not.toHaveBeenCalled() + expect(transport.send).not.toHaveBeenCalled() }) }) diff --git a/packages/contract/src/plugins/request-validation.ts b/packages/contract/src/plugins/request-validation.ts index 0fcb75730..f8f9759a8 100644 --- a/packages/contract/src/plugins/request-validation.ts +++ b/packages/contract/src/plugins/request-validation.ts @@ -1,64 +1,73 @@ import type { ClientContext } from '@orpc/client' import type { StandardLinkOptions, StandardLinkPlugin } from '@orpc/client/standard' -import type { AnyContractRouter } from '../router' +import type { RouterContract } from '../router' import { ORPCError } from '@orpc/client' -import { get } from '@orpc/shared' +import { toArray } from '@orpc/shared' import { ValidationError } from '../error' -import { isContractProcedure } from '../procedure' +import { getProcedureContractOrThrow } from '../router-utils' -export class RequestValidationPluginError extends Error {} +export interface RequestValidationLinkPluginOptions<_T extends ClientContext> { + /** + * Forwards the locally validated/transformed input downstream. + * + * Disabled by default because some schema transforms produce a locally valid + * value that cannot be validated successfully again by the server. + * Keeping the original input as the flow input is the safer default. + * + * @default false + */ + forwardValidatedInput?: boolean | undefined +} /** - * A link plugin that validates client requests against your contract schema, - * ensuring that data sent to your server matches the expected types defined in your contract. - * - * @throws {ORPCError} with code `BAD_REQUEST` (same as server side) if input doesn't match the expected schema - * @see {@link https://orpc.dev/docs/plugins/request-validation Request Validation Plugin Docs} + * Validates client request input against contract schemas before the request is encoded. */ -export class RequestValidationPlugin implements StandardLinkPlugin { - constructor( - private readonly contract: AnyContractRouter, - ) {} +export class RequestValidationLinkPlugin implements StandardLinkPlugin { + name = '~request-validation' + + private readonly forwardValidatedInput: boolean - init(options: StandardLinkOptions): void { - options.interceptors ??= [] + constructor( + private readonly contract: RouterContract, + options: RequestValidationLinkPluginOptions = {}, + ) { + this.forwardValidatedInput = options.forwardValidatedInput ?? false + } - options.interceptors.push(async ({ next, path, input }) => { - const procedure = get(this.contract, path) + init(options: StandardLinkOptions): StandardLinkOptions { + return { + ...options, + interceptors: [...toArray(options.interceptors), async ({ next, ...interceptorOptions }) => { + const procedure = getProcedureContractOrThrow(this.contract, interceptorOptions.path) - if (!isContractProcedure(procedure)) { - throw new RequestValidationPluginError(`No valid procedure found at path "${path.join('.')}", this may happen when the contract router is not properly configured.`) - } + let currentInput = interceptorOptions.input - const inputSchema = procedure['~orpc'].inputSchema + if (procedure['~orpc'].inputSchemas) { + for (const schema of procedure['~orpc'].inputSchemas) { + const result = await schema['~standard'].validate(currentInput) - if (inputSchema) { - const result = await inputSchema['~standard'].validate(input) + if (result.issues) { + throw new ORPCError('BAD_REQUEST', { + message: 'Input validation failed', + data: { + issues: result.issues, + }, + cause: new ValidationError({ + message: 'Input validation failed', + issues: result.issues, + invalidData: currentInput, + }), + }) + } - if (result.issues) { - /** - * This error should be same as server side when input validation fails. - */ - throw new ORPCError('BAD_REQUEST', { - message: 'Input validation failed', - data: { - issues: result.issues, - }, - cause: new ValidationError({ - message: 'Input validation failed', - issues: result.issues, - data: input, - }), - }) + currentInput = result.value + } } - } - /** - * we should not use validated input here, - * because validated input maybe is transformed by schema - * leading input no longer matching expected schema - */ - return await next() - }) + return this.forwardValidatedInput + ? next({ ...interceptorOptions, input: currentInput }) + : next() + }], + } } } diff --git a/packages/contract/src/plugins/response-validation.test.ts b/packages/contract/src/plugins/response-validation.test.ts index e57d46a5c..ea8280fa7 100644 --- a/packages/contract/src/plugins/response-validation.test.ts +++ b/packages/contract/src/plugins/response-validation.test.ts @@ -1,39 +1,43 @@ import { ORPCError } from '@orpc/client' import { StandardLink } from '@orpc/client/standard' import * as z from 'zod' -import { validateORPCError, ValidationError } from '../error' -import { ContractProcedure } from '../procedure' -import { ResponseValidationPlugin } from './response-validation' +import { ValidationError } from '../error' +import { reconcileORPCError } from '../error-utils' +import { ProcedureContract } from '../procedure' +import { ResponseValidationLinkPlugin } from './response-validation' -vi.mock('../error', async original => ({ +vi.mock('../error-utils', async original => ({ ...await original(), - validateORPCError: vi.fn(), + reconcileORPCError: vi.fn(), })) beforeEach(() => { vi.clearAllMocks() }) -describe('responseValidationPlugin', () => { - const schema = z.object({ - value: z.string().or(z.number()).transform(v => Number.parseInt(v.toString())), - }) - - const procedure = new ContractProcedure({ - outputSchema: schema, +describe('responseValidationLinkPlugin', () => { + const procedure = new ProcedureContract({ + outputSchemas: [ + z.object({ + value: z.number().transform(value => value + 1), + }), + z.object({ + value: z.string().transform(value => Number.parseInt(value)), + }), + ], errorMap: { TEST: { - data: schema, + data: z.object({ + value: z.string().transform(value => Number.parseInt(value)), + }), }, }, meta: {}, - route: {}, }) - const withoutOutputSchemaProcedure = new ContractProcedure({ + const withoutOutputSchemaProcedure = new ProcedureContract({ errorMap: {}, meta: {}, - route: {}, }) const contract = { @@ -45,93 +49,151 @@ describe('responseValidationPlugin', () => { } const codec = { - decode: vi.fn(), - encode: vi.fn(), + encodeInput: vi.fn(), + decodeResponse: vi.fn(), } - const client = { - call: vi.fn(), + const transport = { + send: vi.fn(), } const interceptor = vi.fn(({ next }) => next()) - const link = new StandardLink(codec, client, { + const link = new StandardLink(codec, transport, { plugins: [ - new ResponseValidationPlugin(contract), + new ResponseValidationLinkPlugin(contract), ], - // ResponseValidationPlugin should execute before user defined interceptors interceptors: [interceptor], }) - describe('validate output', async () => { - it('procedure with output schema', async () => { - codec.decode.mockResolvedValueOnce({ value: '123' }) + it('validates output using the output schema pipeline', async () => { + codec.encodeInput.mockResolvedValueOnce({ + method: 'POST', + url: '/procedure', + headers: {}, + body: '__encoded__', + }) + transport.send.mockResolvedValueOnce({ + status: 200, + headers: {}, + resolveBody: () => Promise.resolve('__body__'), + }) + codec.decodeResponse.mockResolvedValueOnce({ + kind: 'output', + output: { value: '123' }, + }) + + const output = await link.call(['procedure'], {}, { context: {} }) - const output = await link.call(['procedure'], {}, { context: {} }) + expect(output).toEqual({ value: 124 }) + expect(await interceptor.mock.results[0]?.value).toEqual({ value: 124 }) + }) - expect(output).toEqual({ value: 123 }) - expect(await interceptor.mock.results[0]?.value).toEqual({ value: 123 }) + it('skips validation when the procedure has no output schemas', async () => { + codec.encodeInput.mockResolvedValueOnce({ + method: 'POST', + url: '/withoutOutputSchema', + headers: {}, + body: '__encoded__', + }) + transport.send.mockResolvedValueOnce({ + status: 200, + headers: {}, + resolveBody: () => Promise.resolve('__body__'), + }) + codec.decodeResponse.mockResolvedValueOnce({ + kind: 'output', + output: 'anything', }) - it('procedure without output schema', async () => { - codec.decode.mockResolvedValueOnce('anything') + const output = await link.call(['withoutOutputSchema'], {}, { context: {} }) - const output = await link.call(['withoutOutputSchema'], {}, { context: {} }) + expect(output).toBe('anything') + expect(await interceptor.mock.results[0]?.value).toBe('anything') + }) - expect(output).toEqual('anything') - expect(await interceptor.mock.results[0]?.value).toEqual('anything') + it('throws INTERNAL_SERVER_ERROR when output validation fails', async () => { + codec.encodeInput.mockResolvedValueOnce({ + method: 'POST', + url: '/procedure', + headers: {}, + body: '__encoded__', + }) + transport.send.mockResolvedValueOnce({ + status: 200, + headers: {}, + resolveBody: () => Promise.resolve('__body__'), + }) + codec.decodeResponse.mockResolvedValueOnce({ + kind: 'output', + output: 'invalid', }) - it('on error case', async () => { - codec.decode.mockResolvedValueOnce('invalid') + const expectedError = new ORPCError('INTERNAL_SERVER_ERROR', { + message: 'Output validation failed', + cause: expect.any(ValidationError), + }) - await expect(link.call(['procedure'], {}, { context: {} })).rejects.toSatisfy((e) => { - expect(e).toBeInstanceOf(ValidationError) - expect(e.message).toBe('Server response output does not match expected schema') - expect(e.issues).toBeDefined() - expect(e.data).toEqual('invalid') + vi.mocked(reconcileORPCError).mockImplementationOnce(async (map, error) => { + expect(map).toBe(contract.procedure['~orpc'].errorMap) + expect(error).toBeInstanceOf(ORPCError) + expect(error.code).toBe('INTERNAL_SERVER_ERROR') + expect(error.message).toBe('Output validation failed') + expect(error.cause).toBeInstanceOf(ValidationError) + expect((error.cause as ValidationError).invalidData).toBe('invalid') - return true - }) + return expectedError + }) - await expect(interceptor.mock.results[0]?.value).rejects.toSatisfy((e) => { - expect(e).toBeInstanceOf(ValidationError) - expect(e.message).toBe('Server response output does not match expected schema') - expect(e.issues).toBeDefined() - expect(e.data).toEqual('invalid') + await expect(link.call(['procedure'], {}, { context: {} })).rejects.toBe(expectedError) + await expect(interceptor.mock.results[0]?.value).rejects.toEqual(expectedError) + }) - return true - }) + it('reconciles thrown ORPCError instances against the contract', async () => { + codec.encodeInput.mockResolvedValueOnce({ + method: 'POST', + url: '/nested/procedure', + headers: {}, + body: '__encoded__', + }) + transport.send.mockResolvedValueOnce({ + status: 400, + headers: {}, + resolveBody: () => Promise.resolve('__body__'), }) - }) - describe('validate error', () => { - it('with ORPCError', async () => { - const error = new ORPCError('TEST', { message: 'test', defined: true }) - codec.decode.mockRejectedValueOnce(error) + const error = new ORPCError('TEST', { message: 'test', data: { value: '123' } }) + const reconciled = new ORPCError('TEST', { message: 'test', data: { value: 123 } }) - const error2 = new ORPCError('TEST', { message: 'test' }) - vi.mocked(validateORPCError).mockResolvedValueOnce(error2) + codec.decodeResponse.mockResolvedValueOnce({ kind: 'error', error }) + vi.mocked(reconcileORPCError).mockResolvedValueOnce(reconciled) - await expect(link.call(['nested', 'procedure'], {}, { context: {} })).rejects.toBe(error2) - await expect(interceptor.mock.results[0]?.value).rejects.toBe(error2) + await expect(link.call(['nested', 'procedure'], {}, { context: {} })).rejects.toBe(reconciled) + await expect(interceptor.mock.results[0]?.value).rejects.toBe(error) - expect(validateORPCError).toHaveBeenCalledWith(contract.nested.procedure['~orpc'].errorMap, error) + expect(reconcileORPCError).toHaveBeenCalledWith(contract.nested.procedure['~orpc'].errorMap, error) + }) + + it('rethrows non-ORPCError failures without reconciliation', async () => { + codec.encodeInput.mockResolvedValueOnce({ + method: 'POST', + url: '/nested/procedure', + headers: {}, + body: '__encoded__', + }) + transport.send.mockResolvedValueOnce({ + status: 500, + headers: {}, + resolveBody: () => Promise.resolve('__body__'), }) - it('without ORPCError', async () => { - const error = new Error('test') - codec.decode.mockRejectedValueOnce(error) + const error = new Error('plain failure') - await expect(link.call(['nested', 'procedure'], {}, { context: {} })).rejects.toBe(error) - await expect(interceptor.mock.results[0]?.value).rejects.toBe(error) + codec.decodeResponse.mockRejectedValueOnce(error) - expect(validateORPCError).not.toHaveBeenCalled() - }) - }) + await expect(link.call(['nested', 'procedure'], {}, { context: {} })).rejects.toBe(error) + await expect(interceptor.mock.results[0]?.value).rejects.toBe(error) - it('throw if not find matching contract', async () => { - await expect(link.call(['not', 'found'], {}, { context: {} })).rejects.toThrow('[ResponseValidationPlugin] no valid procedure found at path "not.found", this may happen when the contract router is not properly configured.') - await expect(interceptor.mock.results[0]?.value).rejects.toThrow('[ResponseValidationPlugin] no valid procedure found at path "not.found", this may happen when the contract router is not properly configured.') + expect(reconcileORPCError).not.toHaveBeenCalled() }) }) diff --git a/packages/contract/src/plugins/response-validation.ts b/packages/contract/src/plugins/response-validation.ts index 7c38eff5b..318393f44 100644 --- a/packages/contract/src/plugins/response-validation.ts +++ b/packages/contract/src/plugins/response-validation.ts @@ -1,68 +1,72 @@ import type { ClientContext } from '@orpc/client' import type { StandardLinkOptions, StandardLinkPlugin } from '@orpc/client/standard' -import type { AnyContractRouter } from '../router' +import type { RouterContract } from '../router' import { ORPCError } from '@orpc/client' -import { get } from '@orpc/shared' -import { validateORPCError, ValidationError } from '../error' -import { isContractProcedure } from '../procedure' +import { toArray } from '@orpc/shared' +import { ValidationError } from '../error' +import { reconcileORPCError } from '../error-utils' +import { getProcedureContractOrThrow } from '../router-utils' + +export class ResponseValidationLinkPlugin implements StandardLinkPlugin { + name = '~response-validation' -/** - * A link plugin that validates server responses against your contract schema, - * ensuring that data returned from your server matches the expected types defined in your contract. - * - * - Throws `ValidationError` if output doesn't match the expected schema - * - Converts mismatched defined errors to normal `ORPCError` instances - * - * @see {@link https://orpc.dev/docs/plugins/response-validation Response Validation Plugin Docs} - */ -export class ResponseValidationPlugin implements StandardLinkPlugin { constructor( - private readonly contract: AnyContractRouter, - ) {} + private readonly contract: RouterContract, + ) { + } - /** - * run before (validate after) retry plugin, because validation failed can't be retried - * run before (validate after) durable iterator plugin, because we expect durable iterator to validation (if user use it) - */ - order = 1_200_000 + init(options: StandardLinkOptions): StandardLinkOptions { + return { + ...options, + interceptors: [ + async ({ next, path }) => { + const procedure = getProcedureContractOrThrow(this.contract, path) - init(options: StandardLinkOptions): void { - options.interceptors ??= [] + try { + return await next() + } + catch (error) { + if (error instanceof ORPCError) { + /** + * Even if the error is inferable (returned), we still need to apply `reconcileError`. + * Defined errors take priority over inferable errors. + * `reconcileError` attempts to mark the error as defined, or keeps it inferable if that's not possible. + */ + throw await reconcileORPCError(procedure['~orpc'].errorMap, error) + } - options.interceptors.push(async ({ next, path }) => { - const procedure = get(this.contract, path) + throw error + } + }, + ...toArray(options.interceptors), + async ({ next, path }) => { + const procedure = getProcedureContractOrThrow(this.contract, path) - if (!isContractProcedure(procedure)) { - throw new Error(`[ResponseValidationPlugin] no valid procedure found at path "${path.join('.')}", this may happen when the contract router is not properly configured.`) - } + const outputSchemas = toArray(procedure['~orpc'].outputSchemas) - try { - const output = await next() - const outputSchema = procedure['~orpc'].outputSchema + let output = await next() - if (!outputSchema) { - return output - } + for (let i = outputSchemas.length - 1; i >= 0; i--) { + const schema = outputSchemas[i]! + const result = await schema['~standard'].validate(output) - const result = await outputSchema['~standard'].validate(output) + if (result.issues) { + throw new ORPCError('INTERNAL_SERVER_ERROR', { + message: 'Output validation failed', + cause: new ValidationError({ + message: 'Output validation failed', + issues: result.issues, + invalidData: output, + }), + }) + } - if (result.issues) { - throw new ValidationError({ - message: 'Server response output does not match expected schema', - issues: result.issues, - data: output, - }) - } + output = result.value + } - return result.value - } - catch (e) { - if (e instanceof ORPCError) { - throw await validateORPCError(procedure['~orpc'].errorMap, e) - } - - throw e - } - }) + return output + }, + ], + } } } diff --git a/packages/contract/src/procedure-client.test-d.ts b/packages/contract/src/procedure-client.test-d.ts index 607e6ad90..0bdec157b 100644 --- a/packages/contract/src/procedure-client.test-d.ts +++ b/packages/contract/src/procedure-client.test-d.ts @@ -1,17 +1,28 @@ -import type { Client, ORPCError } from '@orpc/client' -import type { baseErrorMap, inputSchema, outputSchema } from '../tests/shared' -import type { ContractProcedureClient } from './procedure-client' +import type { Client, ORPCError, ThrowableError } from '@orpc/client' +import type { ProcedureContractClient } from './procedure-client' +import { z } from 'zod' -describe('ContractProcedureClient', () => { +// Schemas should have distinct TInput and TOutput types to ensure correct inference. +const inputSchema = z.object({ input: z.number().transform(n => `${n}`) }) +const outputSchema = z.object({ output: z.string().transform(s => Number(s)) }) + +const errorMap = { + BASE: { + data: z.object({ id: z.string().transform(s => Number(s)) }), + message: 'base', + }, +} + +describe('ProcedureContractClient', () => { it('is a client', () => { expectTypeOf< - ContractProcedureClient<{ cache?: boolean }, typeof inputSchema, typeof outputSchema, typeof baseErrorMap> + ProcedureContractClient<{ cache?: boolean }, typeof inputSchema, typeof outputSchema, typeof errorMap> >().toEqualTypeOf< Client< { cache?: boolean }, { input: number }, - { output: string }, - Error | ORPCError<'BASE', { output: string }> | ORPCError<'OVERRIDE', unknown> + { output: number }, + ThrowableError | ORPCError<'BASE', { id: number }> > >() }) diff --git a/packages/contract/src/procedure-client.ts b/packages/contract/src/procedure-client.ts index f3f6db4bf..fb62402f5 100644 --- a/packages/contract/src/procedure-client.ts +++ b/packages/contract/src/procedure-client.ts @@ -1,10 +1,16 @@ import type { Client, ClientContext } from '@orpc/client' -import type { ErrorFromErrorMap, ErrorMap } from './error' +import type { ThrowableError } from '@orpc/shared' +import type { ErrorMap, ORPCErrorFromErrorMap } from './error' import type { AnySchema, InferSchemaInput, InferSchemaOutput } from './schema' -export type ContractProcedureClient< +export type ProcedureContractClient< TClientContext extends ClientContext, TInputSchema extends AnySchema, TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, -> = Client, InferSchemaOutput, ErrorFromErrorMap> +> = Client< + TClientContext, + InferSchemaInput, + InferSchemaOutput, + ORPCErrorFromErrorMap | ThrowableError +> diff --git a/packages/contract/src/procedure.test.ts b/packages/contract/src/procedure.test.ts index 8aaf17efb..449b8305e 100644 --- a/packages/contract/src/procedure.test.ts +++ b/packages/contract/src/procedure.test.ts @@ -1,77 +1,46 @@ -import * as ClientModule from '@orpc/client' -import { ping, pong } from '../tests/shared' -import { ContractProcedure, isContractProcedure } from './procedure' - -const isORPCErrorStatusSpy = vi.spyOn(ClientModule, 'isORPCErrorStatus') - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('contractProcedure', () => { - it('throws error when route.successStatus is invalid', () => { - isORPCErrorStatusSpy.mockReturnValueOnce(true) - - expect( - () => new ContractProcedure({ ...ping['~orpc'], route: { successStatus: 1999 } }), - ).toThrowError() - - expect(isORPCErrorStatusSpy).toHaveBeenCalledTimes(1) - expect(isORPCErrorStatusSpy).toHaveBeenCalledWith(1999) - - isORPCErrorStatusSpy.mockClear() - isORPCErrorStatusSpy.mockReturnValueOnce(false) - - expect( - () => new ContractProcedure({ ...ping['~orpc'], route: { successStatus: 2000 } }), - ).not.toThrowError() - - expect(isORPCErrorStatusSpy).toHaveBeenCalledTimes(1) - expect(isORPCErrorStatusSpy).toHaveBeenCalledWith(2000) - }) - - it('throws error when errorMap has invalid status code', () => { - isORPCErrorStatusSpy.mockReturnValueOnce(false) - - expect( - () => new ContractProcedure({ - ...ping['~orpc'], - errorMap: { BAD_GATEWAY: { status: 200 } }, - }), - ).toThrowError() - - expect(isORPCErrorStatusSpy).toHaveBeenCalledTimes(1) - expect(isORPCErrorStatusSpy).toHaveBeenCalledWith(200) - - isORPCErrorStatusSpy.mockClear() - isORPCErrorStatusSpy.mockReturnValueOnce(true) - - expect( - () => new ContractProcedure({ - ...ping['~orpc'], - errorMap: { - BAD_GATEWAY: { status: 500 }, - }, - }), - ).not.toThrowError() - - expect(isORPCErrorStatusSpy).toHaveBeenCalledTimes(1) - expect(isORPCErrorStatusSpy).toHaveBeenCalledWith(500) - }) -}) - -describe('isContractProcedure', () => { - it('works', () => { - expect(ping).toSatisfy(isContractProcedure) - expect(pong).toSatisfy(isContractProcedure) - expect({}).not.toSatisfy(isContractProcedure) - expect(true).not.toSatisfy(isContractProcedure) - expect(1).not.toSatisfy(isContractProcedure) - expect({ '~orpc': {} }).not.toSatisfy(isContractProcedure) +import { ProcedureContract } from './procedure' + +describe('procedureContract', () => { + const procedure = new ProcedureContract({ + errorMap: {}, + meta: {}, + inputSchemas: [], + outputSchemas: [], }) - it('works with raw object', () => { - expect(Object.assign({}, ping)).toSatisfy(isContractProcedure) - expect(Object.assign({}, pong)).toSatisfy(isContractProcedure) + describe('instanceof', () => { + it('support both instanceof and structural check', () => { + expect(procedure).toBeInstanceOf(ProcedureContract) + expect({ '~orpc': procedure['~orpc'] }).toBeInstanceOf(ProcedureContract) + + expect({}).not.toBeInstanceOf(ProcedureContract) + expect({ '~orpc': {} }).not.toBeInstanceOf(ProcedureContract) + expect({ '~orpc': { + ...procedure['~orpc'], + errorMap: 'invalid', + } }).not.toBeInstanceOf(ProcedureContract) + expect({ '~orpc': { + ...procedure['~orpc'], + meta: 'invalid', + } }).not.toBeInstanceOf(ProcedureContract) + }) + + it('not support structural for extended class', () => { + class ExtendedProcedureContract extends ProcedureContract { + constructor() { + super({ + ...procedure['~orpc'], + errorMap: {}, + meta: {}, + }) + } + } + + expect(new ExtendedProcedureContract()).toBeInstanceOf(ProcedureContract) + expect(new ExtendedProcedureContract()).toBeInstanceOf(ExtendedProcedureContract) + + expect({ '~orpc': new ExtendedProcedureContract()['~orpc'] }).toBeInstanceOf(ProcedureContract) + expect({ '~orpc': new ExtendedProcedureContract()['~orpc'] }).not.toBeInstanceOf(ExtendedProcedureContract) + }) }) }) diff --git a/packages/contract/src/procedure.ts b/packages/contract/src/procedure.ts index c39d9d54f..19ee5fbc6 100644 --- a/packages/contract/src/procedure.ts +++ b/packages/contract/src/procedure.ts @@ -1,66 +1,60 @@ import type { ErrorMap } from './error' -import type { Meta } from './meta' -import type { Route } from './route' +import type { AnyMetaPlugin, Meta } from './meta' import type { AnySchema } from './schema' -import { isORPCErrorStatus } from '@orpc/client' +import { getConstructor, isTypescriptObject } from '@orpc/shared' -export interface ContractProcedureDef< +export interface ProcedureContractDefinition< TInputSchema extends AnySchema, TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, - TMeta extends Meta, > { - meta: TMeta - route: Route - inputSchema?: TInputSchema - outputSchema?: TOutputSchema + __TInputSchema?: { type: TInputSchema } + __TOutputSchema?: { type: TOutputSchema } + + /** + * Non-serializable should be optional + */ + inputSchemas?: AnySchema[] | undefined + outputSchemas?: AnySchema[] | undefined + metaPlugins?: AnyMetaPlugin[] | undefined errorMap: TErrorMap + meta: Meta } -/** - * This class represents a contract procedure. - * - * @see {@link https://orpc.dev/docs/contract-first/define-contract#procedure-contract Contract Procedure Docs} - */ -export class ContractProcedure< +export class ProcedureContract< TInputSchema extends AnySchema, TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, - TMeta extends Meta, > { + '~orpc': ProcedureContractDefinition + + constructor(def: ProcedureContractDefinition) { + this['~orpc'] = def + } + /** - * This property holds the defined options for the contract procedure. + * Checks if the given instance satisfies the {@see ProcedureContract} class/interface. */ - '~orpc': ContractProcedureDef - - constructor(def: ContractProcedureDef) { - if (def.route?.successStatus && isORPCErrorStatus(def.route.successStatus)) { - throw new Error('[ContractProcedure] Invalid successStatus.') + static [Symbol.hasInstance](instance: unknown): boolean { + if (this !== ProcedureContract) { + // fallback to default instanceof check if this is extended class + return Function.prototype[Symbol.hasInstance].call(this, instance) } - if (Object.values(def.errorMap).some(val => val && val.status && !isORPCErrorStatus(val.status))) { - throw new Error('[ContractProcedure] Invalid error status code.') + const constructor = getConstructor(instance) + if (constructor === ProcedureContract) { + return true } - this['~orpc'] = def + return ( + isTypescriptObject(instance) + && isTypescriptObject(instance['~orpc']) + && isTypescriptObject(instance['~orpc'].errorMap) + && isTypescriptObject(instance['~orpc'].meta) + && (instance['~orpc'].inputSchemas === undefined || Array.isArray(instance['~orpc'].inputSchemas)) + && (instance['~orpc'].outputSchemas === undefined || Array.isArray(instance['~orpc'].outputSchemas)) + ) } } -export type AnyContractProcedure = ContractProcedure - -export function isContractProcedure(item: unknown): item is AnyContractProcedure { - if (item instanceof ContractProcedure) { - return true - } - - return ( - (typeof item === 'object' || typeof item === 'function') - && item !== null - && '~orpc' in item - && typeof item['~orpc'] === 'object' - && item['~orpc'] !== null - && 'errorMap' in item['~orpc'] - && 'route' in item['~orpc'] - && 'meta' in item['~orpc'] - ) -} +export type AnyProcedureContract = ProcedureContract diff --git a/packages/contract/src/route.test.ts b/packages/contract/src/route.test.ts deleted file mode 100644 index 4e0a9eb22..000000000 --- a/packages/contract/src/route.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { enhanceRoute, mergePrefix, mergeRoute, mergeTags, prefixRoute, unshiftTagRoute } from './route' - -it('mergeRoute', () => { - expect(mergeRoute({ path: '/api' }, { path: '/v1' })).toEqual({ path: '/v1' }) - expect(mergeRoute({ path: '/api' }, { path: '/v1', method: 'GET' })).toEqual({ path: '/v1', method: 'GET' }) - expect(mergeRoute({ path: '/api', method: 'GET' }, { path: '/v1' })).toEqual({ path: '/v1', method: 'GET' }) -}) - -it('prefixRoute', () => { - expect(prefixRoute({ tags: ['tag'] }, '/api')).toEqual({ tags: ['tag'] }) - expect(prefixRoute({ path: '/api' }, '/v1')).toEqual({ path: '/v1/api' }) - expect(prefixRoute({ path: '/api', method: 'GET' }, '/v1')).toEqual({ path: '/v1/api', method: 'GET' }) -}) - -it('unshiftTagRoute', () => { - expect(unshiftTagRoute({ path: '/api' }, ['tag2'])).toEqual({ path: '/api', tags: ['tag2'] }) - expect(unshiftTagRoute({ tags: ['tag'] }, ['tag2'])).toEqual({ tags: ['tag2', 'tag'] }) - expect(unshiftTagRoute({ tags: ['tag'] }, ['tag', 'tag3'])).toEqual({ tags: ['tag', 'tag3', 'tag'] }) -}) - -it('mergePrefix', () => { - expect(mergePrefix(undefined, '/v1')).toEqual('/v1') - expect(mergePrefix('/api', '/v1')).toEqual('/api/v1') -}) - -it('mergeTags', () => { - expect(mergeTags(undefined, ['tag'])).toEqual(['tag']) - expect(mergeTags(['tag'], ['tag2'])).toEqual(['tag', 'tag2']) - expect(mergeTags(['tag'], ['tag', 'tag2'])).toEqual(['tag', 'tag', 'tag2']) -}) - -it('enhanceRoute', () => { - const route = { - path: '/api/v1', - tags: ['tag'], - description: 'description', - } as const - - expect(enhanceRoute(route, { - prefix: '/adapt', - tags: ['adapt'], - })).toEqual({ - path: '/adapt/api/v1', - tags: ['adapt', 'tag'], - description: 'description', - }) - - expect(enhanceRoute(route, { - prefix: '/adapt', - })).toEqual({ - path: '/adapt/api/v1', - tags: ['tag'], - description: 'description', - }) - - expect(enhanceRoute(route, { - tags: ['adapt'], - })).toEqual({ - path: '/api/v1', - tags: ['adapt', 'tag'], - description: 'description', - }) - - expect(enhanceRoute(route, {})).toBe(route) -}) diff --git a/packages/contract/src/route.ts b/packages/contract/src/route.ts deleted file mode 100644 index f4fb5bda4..000000000 --- a/packages/contract/src/route.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { HTTPMethod, HTTPPath } from '@orpc/client' -import type { OpenAPI } from './types' - -export type InputStructure = 'compact' | 'detailed' -export type OutputStructure = 'compact' | 'detailed' - -export interface Route { - /** - * The HTTP method of the procedure. - * This option is typically relevant when integrating with OpenAPI. - * - * @see {@link https://orpc.dev/docs/openapi/routing OpenAPI Routing Docs} - */ - method?: HTTPMethod - - /** - * The HTTP path of the procedure. - * This option is typically relevant when integrating with OpenAPI. - * - * @see {@link https://orpc.dev/docs/openapi/routing OpenAPI Routing Docs} - */ - path?: HTTPPath - - /** - * The operation ID of the endpoint. - * This option is typically relevant when integrating with OpenAPI. - * - * @default Concatenation of router segments - */ - operationId?: string - - /** - * The summary of the procedure. - * This option is typically relevant when integrating with OpenAPI. - * - * @see {@link https://orpc.dev/docs/openapi/openapi-specification#operation-metadata OpenAPI Operation Metadata Docs} - */ - summary?: string - - /** - * The description of the procedure. - * This option is typically relevant when integrating with OpenAPI. - * - * @see {@link https://orpc.dev/docs/openapi/openapi-specification#operation-metadata OpenAPI Operation Metadata Docs} - */ - description?: string - - /** - * Marks the procedure as deprecated. - * This option is typically relevant when integrating with OpenAPI. - * - * @see {@link https://orpc.dev/docs/openapi/openapi-specification#operation-metadata OpenAPI Operation Metadata Docs} - */ - deprecated?: boolean - - /** - * The tags of the procedure. - * This option is typically relevant when integrating with OpenAPI. - * - * @see {@link https://orpc.dev/docs/openapi/openapi-specification#operation-metadata OpenAPI Operation Metadata Docs} - */ - tags?: readonly string[] - - /** - * The status code of the response when the procedure is successful. - * The status code must be in the 200-399 range. - * This option is typically relevant when integrating with OpenAPI. - * - * @see {@link https://orpc.dev/docs/openapi/routing OpenAPI Routing Docs} - * @default 200 - */ - successStatus?: number - - /** - * The description of the response when the procedure is successful. - * This option is typically relevant when integrating with OpenAPI. - * - * @see {@link https://orpc.dev/docs/openapi/openapi-specification#operation-metadata OpenAPI Operation Metadata Docs} - * @default 'OK' - */ - successDescription?: string - - /** - * Determines how the input should be structured based on `params`, `query`, `headers`, and `body`. - * - * @option 'compact' - * Combines `params` and either `query` or `body` (depending on the HTTP method) into a single object. - * - * @option 'detailed' - * Keeps each part of the request (`params`, `query`, `headers`, and `body`) as separate fields in the input object. - * - * Example: - * ```ts - * const input = { - * params: { id: 1 }, - * query: { search: 'hello' }, - * headers: { 'Content-Type': 'application/json' }, - * body: { name: 'John' }, - * } - * ``` - * - * @see {@link https://orpc.dev/docs/openapi/input-output-structure OpenAPI Input/Output Structure Docs} - * @default 'compact' - */ - inputStructure?: InputStructure - - /** - * Determines how the response should be structured based on the output. - * - * @option 'compact' - * The output data is directly returned as the response body. - * - * @option 'detailed' - * Return an object with optional properties: - * - `status`: The response status (must be in 200-399 range) if not set fallback to `successStatus`. - * - `headers`: Custom headers to merge with the response headers (`Record`) - * - `body`: The response body. - * - * Example: - * ```ts - * const output = { - * status: 201, - * headers: { 'x-custom-header': 'value' }, - * body: { message: 'Hello, world!' }, - * }; - * ``` - * - * @see {@link https://orpc.dev/docs/openapi/input-output-structure OpenAPI Input/Output Structure Docs} - * @default 'compact' - */ - outputStructure?: OutputStructure - - /** - * Override entire auto-generated OpenAPI Operation Object Specification. - * - * @see {@link https://orpc.dev/docs/openapi/openapi-specification#operation-metadata Operation Metadata Docs} - */ - spec?: OpenAPI.OperationObject | ((current: OpenAPI.OperationObject) => OpenAPI.OperationObject) -} - -export function mergeRoute(a: Route, b: Route): Route { - return { ...a, ...b } -} - -export function prefixRoute(route: Route, prefix: HTTPPath): Route { - if (!route.path) { - return route - } - - return { - ...route, - path: `${prefix}${route.path}`, - } -} -export function unshiftTagRoute(route: Route, tags: readonly string[]): Route { - return { - ...route, - tags: [...tags, ...route.tags ?? []], - } -} - -export function mergePrefix(a: HTTPPath | undefined, b: HTTPPath): HTTPPath { - return a ? `${a}${b}` : b -} - -export function mergeTags(a: readonly string[] | undefined, b: readonly string[]): readonly string[] { - return a ? [...a, ...b] : b -} - -export interface EnhanceRouteOptions { - prefix?: HTTPPath - tags?: readonly string[] -} - -export function enhanceRoute(route: Route, options: EnhanceRouteOptions): Route { - let router = route - - if (options.prefix) { - router = prefixRoute(router, options.prefix) - } - - if (options.tags?.length) { - router = unshiftTagRoute(router, options.tags) - } - - return router -} diff --git a/packages/contract/src/router-client.test-d.ts b/packages/contract/src/router-client.test-d.ts index 4dad0eeb7..5ffbc347d 100644 --- a/packages/contract/src/router-client.test-d.ts +++ b/packages/contract/src/router-client.test-d.ts @@ -1,6 +1,15 @@ -import type { ClientContext, NestedClient } from '@orpc/client' -import type { ContractRouterClient } from './router-client' -import { ping, pong } from '../tests/shared' +import type { Client, ClientContext, NestedClient, ORPCError, ThrowableError } from '@orpc/client' +import type { RouterContractClient } from './router-client' +import { z } from 'zod' +import { oc } from './builder' + +// Schemas should have distinct TInput and TOutput types to ensure correct inference. +const ping = oc.input(z.object({ input: z.number().transform(n => `${n}`) })) +const pong = oc.output(z.object({ output: z.string().transform(s => Number(s)) })).errors({ + INTERNAL_SERVER_ERROR: { + data: z.object({ id: z.string().transform(s => Number(s)) }), + }, +}) const router = { ping, @@ -11,9 +20,33 @@ const router = { }, } -describe('ContractRouterClient', () => { +describe('RouterContractClient', () => { it('is a NestedClient', () => { - expectTypeOf>().toMatchTypeOf>() - expectTypeOf>().not.toMatchTypeOf>() + expectTypeOf>().toExtend>() + }) + + it('maps to ProcedureContractClient', () => { + type ClientType = RouterContractClient + + expectTypeOf().toEqualTypeOf< + Client<{ cache?: boolean }, { input: number }, unknown, ThrowableError> + >() + + expectTypeOf().toEqualTypeOf< + Client< + { cache?: boolean }, + void, + { output: number }, + ThrowableError | ORPCError<'INTERNAL_SERVER_ERROR', { id: number }> + > + >() + + expectTypeOf().toEqualTypeOf< + ClientType['ping'] + >() + + expectTypeOf().toEqualTypeOf< + ClientType['pong'] + >() }) }) diff --git a/packages/contract/src/router-client.ts b/packages/contract/src/router-client.ts index 68c1aa5a6..e462227ae 100644 --- a/packages/contract/src/router-client.ts +++ b/packages/contract/src/router-client.ts @@ -1,11 +1,11 @@ import type { ClientContext } from '@orpc/client' -import type { ContractProcedure } from './procedure' -import type { ContractProcedureClient } from './procedure-client' -import type { AnyContractRouter } from './router' +import type { ProcedureContract } from './procedure' +import type { ProcedureContractClient } from './procedure-client' +import type { RouterContract } from './router' -export type ContractRouterClient> - = TRouter extends ContractProcedure - ? ContractProcedureClient +export type RouterContractClient + = TRouter extends ProcedureContract + ? ProcedureContractClient : { - [K in keyof TRouter]: TRouter[K] extends AnyContractRouter ? ContractRouterClient : never + [K in keyof TRouter]: TRouter[K] extends RouterContract ? RouterContractClient : never } diff --git a/packages/contract/src/router-utils.test-d.ts b/packages/contract/src/router-utils.test-d.ts index dc446a71c..de44e6720 100644 --- a/packages/contract/src/router-utils.test-d.ts +++ b/packages/contract/src/router-utils.test-d.ts @@ -1,68 +1,71 @@ -import type { BaseMeta } from '../tests/shared' -import type { MergedErrorMap } from './error' -import type { Meta } from './meta' -import type { ContractProcedure } from './procedure' -import type { EnhancedContractRouter, PopulatedContractRouterPaths } from './router-utils' +import type { MergedErrorMap } from './error-utils' +import type { ProcedureContract } from './procedure' +import type { AugmentedContractRouter } from './router-utils' import type { Schema } from './schema' -import { baseErrorMap, inputSchema, outputSchema, router } from '../tests/shared' +import { describe, expectTypeOf, it } from 'vitest' +import { z } from 'zod' import { oc } from './builder' -it('EnhancedContractRouter', () => { - const enhanced = {} as EnhancedContractRouter +// Schemas should have distinct TInput and TOutput types to ensure correct inference. +const inputSchema = z.object({ input: z.number().transform(n => `${n}`) }) +const outputSchema = z.object({ output: z.string().transform(s => Number(s)) }) - expectTypeOf(enhanced.ping).toEqualTypeOf< - ContractProcedure< - typeof inputSchema, - typeof outputSchema, - MergedErrorMap<{ INVALID: { status: number }, BASE2: { message: string } }, typeof baseErrorMap>, - BaseMeta - > - >() +const ping = oc.input(inputSchema).output(outputSchema) +const pong = oc.errors({ PONG: { message: 'pong' } }) - expectTypeOf(enhanced.nested.ping).toEqualTypeOf< - ContractProcedure< - typeof inputSchema, - typeof outputSchema, - MergedErrorMap<{ INVALID: { status: number }, BASE2: { message: string } }, typeof baseErrorMap>, - BaseMeta - > - >() +const router = { + ping, + pong, + nested: { + ping, + pong, + }, +} - expectTypeOf(enhanced.pong).toEqualTypeOf< - ContractProcedure< - Schema, - Schema, - MergedErrorMap<{ INVALID: { status: number }, BASE2: { message: string } }, Record>, - Meta - > - >() +const errorMap = { + BASE: { + message: 'base', + }, +} - expectTypeOf(enhanced.nested.pong).toEqualTypeOf< - ContractProcedure< - Schema, - Schema, - MergedErrorMap<{ INVALID: { status: number }, BASE2: { message: string } }, Record>, - Meta - > - >() -}) +describe('AugmentedContractRouter', () => { + it('merges error maps', () => { + type Augmented = AugmentedContractRouter -it('PopulatedContractRouterPaths', () => { - expectTypeOf>().toEqualTypeOf(router) + expectTypeOf().toEqualTypeOf< + ProcedureContract< + typeof inputSchema, + typeof outputSchema, + MergedErrorMap + > + >() - const ping = oc - .$meta({ meta: true }) - .input(inputSchema) - .errors(baseErrorMap) - .output(outputSchema) - .route({ path: '/ping' }) + expectTypeOf().toEqualTypeOf< + ProcedureContract< + Schema, + Schema, + MergedErrorMap + > + >() + }) - expectTypeOf>().toEqualTypeOf< - ContractProcedure< + it('preserves nested structure', () => { + type Augmented = AugmentedContractRouter + + expectTypeOf().toEqualTypeOf< + ProcedureContract< typeof inputSchema, typeof outputSchema, - typeof baseErrorMap & Record, - { meta: boolean } & Record - > - >() + MergedErrorMap + > + >() + + expectTypeOf().toEqualTypeOf< + ProcedureContract< + Schema, + Schema, + MergedErrorMap + > + >() + }) }) diff --git a/packages/contract/src/router-utils.test.ts b/packages/contract/src/router-utils.test.ts index 901259d26..2dc069c27 100644 --- a/packages/contract/src/router-utils.test.ts +++ b/packages/contract/src/router-utils.test.ts @@ -1,209 +1,267 @@ -import type { AnyContractProcedure } from './procedure' -import { inputSchema, outputSchema, ping, pong, router } from '../tests/shared' -import { oc } from './builder' -import { isContractProcedure } from './procedure' -import { enhanceRoute } from './route' -import { enhanceContractRouter, getContractRouter, minifyContractRouter, populateContractRouterPaths } from './router-utils' - -it('getContractRouter', () => { - expect(getContractRouter(router, [])).toEqual(router) - expect(getContractRouter(router, ['ping'])).toEqual(router.ping) - expect(getContractRouter(router, ['nested', 'pong'])).toEqual(router.nested.pong) - - expect(getContractRouter(router, ['not-exist'])).toBeUndefined() - expect(getContractRouter(router, ['nested', 'not-exist', 'not-exist'])).toBeUndefined() - - expect(getContractRouter(router, ['pong', '~orpc'])).toBeUndefined() - expect(getContractRouter(router, ['ping', '~orpc'])).toBeUndefined() +import type { AnyMetaPlugin } from './meta' +import { z } from 'zod' +import * as ErrorUtilsModule from './error-utils' +import * as MetaUtilsModule from './meta-utils' +import { ProcedureContract } from './procedure' +import { augmentContractRouter, getProcedureContractOrThrow, getRouterContract, minifyRouterContract } from './router-utils' + +const mergeErrorMapSpy = vi.spyOn(ErrorUtilsModule, 'mergeErrorMap') +const resolveMetaPluginsSpy = vi.spyOn(MetaUtilsModule, 'resolveMetaPlugins') + +beforeEach(() => { + mergeErrorMapSpy.mockClear() + resolveMetaPluginsSpy.mockClear() }) -it('enhanceContractRouter', async () => { - const errorMap = { - INVALID: { message: 'INVALID' }, - OVERRIDE: { message: 'OVERRIDE' }, +const schema1 = z.object({ schema1: z.string() }) +const schema2 = z.object({ schema2: z.string() }) + +function callable(value: T): T { + return Object.assign(() => {}, value) +} + +const meta1: AnyMetaPlugin = { + name: 'meta1', + init(meta) { + return { + ...meta, + meta1: true, + } + }, +} + +const meta2: AnyMetaPlugin = { + name: 'meta2', + init(meta) { + return { + ...meta, + meta2: true, + } + }, +} + +/** + * Router utilities should handle invalid routers/procedures + * and support function-like routers/procedures. + */ +const router = { + ping: new ProcedureContract({ + errorMap: {}, + meta: { ping: true }, + inputSchemas: [schema1], + outputSchemas: [schema2], + }), + pong: new ProcedureContract({ + errorMap: { + PONG_ERROR: { + data: schema1, + message: 'pong error', + }, + }, + meta: { pong: true, meta2: true }, + inputSchemas: [schema1, schema2], + outputSchemas: [], + metaPlugins: [meta2], + }), + invalid: 'invalid' as any, + nested: callable({ + ping: callable(new ProcedureContract({ + errorMap: { + NESTED_PING_ERROR: { + data: schema2, + }, + }, + meta: { nestedPing: true }, + inputSchemas: [], + outputSchemas: [schema2], + })), + invalid: 'invalid' as any, + }), +} + +describe('augmentContractRouter', () => { + function createAugmentOptions() { + return { + meta: { base: 'augmentContractRouter' }, + errorMap: { + OVERRIDE: { + data: schema1, + }, + }, + metaPlugins: [meta1], + } } - const options = { errorMap, prefix: '/enhanced', tags: ['enhanced'] } as const - const enhanced = enhanceContractRouter(router, options) + function expectAugmentedProcedure( + callIndex: number, + actual: any, + original: any, + options: ReturnType, + ) { + const resolved = resolveMetaPluginsSpy.mock.results[callIndex - 1]?.value + + expect(actual).toBeInstanceOf(ProcedureContract) + expect(actual).not.toBe(original) + expect(mergeErrorMapSpy).toHaveBeenNthCalledWith(callIndex, options.errorMap, original['~orpc'].errorMap) + expect(resolveMetaPluginsSpy).toHaveBeenNthCalledWith( + callIndex, + options.meta, + options.metaPlugins, + original['~orpc'].metaPlugins, + ) + expect(actual['~orpc']).toEqual({ + ...original['~orpc'], + errorMap: mergeErrorMapSpy.mock.results[callIndex - 1]?.value, + meta: resolved?.[0], + metaPlugins: resolved?.[1], + }) + } - expect(enhanced.ping['~orpc'].errorMap).toEqual({ ...errorMap, ...ping['~orpc'].errorMap }) - expect(enhanced.ping['~orpc'].route).toEqual(enhanceRoute(ping['~orpc'].route, options)) + it('augments every procedure in nested routers', () => { + const options = createAugmentOptions() + const augmented = augmentContractRouter(router, options) - expect(enhanced.pong['~orpc'].errorMap).toEqual({ ...errorMap, ...pong['~orpc'].errorMap }) - expect(enhanced.pong['~orpc'].route).toEqual(enhanceRoute(pong['~orpc'].route, options)) + expect(augmented).not.toBe(router) + expect(augmented.nested).not.toBe(router.nested) + expect(augmented.invalid).toBe('invalid') + expect(augmented.nested.invalid).toBe('invalid') - expect(enhanced.nested.ping['~orpc'].errorMap).toEqual({ ...errorMap, ...ping['~orpc'].errorMap }) - expect(enhanced.nested.ping['~orpc'].route).toEqual(enhanceRoute(ping['~orpc'].route, options)) + expect(mergeErrorMapSpy).toHaveBeenCalledTimes(3) + expect(resolveMetaPluginsSpy).toHaveBeenCalledTimes(3) - expect(enhanced.nested.pong['~orpc'].errorMap).toEqual({ ...errorMap, ...pong['~orpc'].errorMap }) - expect(enhanced.nested.pong['~orpc'].route).toEqual(enhanceRoute(pong['~orpc'].route, options)) -}) + expectAugmentedProcedure(1, augmented.ping, router.ping, options) + expectAugmentedProcedure(2, augmented.pong, router.pong, options) + expectAugmentedProcedure(3, augmented.nested.ping, router.nested.ping, options) + }) -it('minifyContractRouter', () => { - const minified = minifyContractRouter(router) + it('augments a procedure passed as the root router', () => { + const options = createAugmentOptions() + const augmented = augmentContractRouter(router.pong, options) - const minifiedPing = { - '~orpc': { - errorMap: {}, - meta: { - mode: 'dev', - }, - route: { - path: '/base', - }, - }, - } + expect(mergeErrorMapSpy).toHaveBeenCalledTimes(1) + expect(resolveMetaPluginsSpy).toHaveBeenCalledTimes(1) - const minifiedPong = { - '~orpc': { - errorMap: {}, - meta: {}, - route: {}, - }, - } + expectAugmentedProcedure(1, augmented, router.pong, options) + }) - expect((minified as any).ping).toSatisfy(isContractProcedure) - expect((minified as any).ping).toEqual(minifiedPing) + it('supports function-like routers passed as the root router', () => { + const options = createAugmentOptions() + const functionRouter = callable(router.nested) + const augmented = augmentContractRouter(functionRouter, options) - expect((minified as any).pong).toSatisfy(isContractProcedure) - expect((minified as any).pong).toEqual(minifiedPong) + expect(augmented).not.toBe(functionRouter) + expect(augmented.invalid).toBe('invalid') - expect((minified as any).nested.ping).toSatisfy(isContractProcedure) - expect((minified as any).nested.ping).toEqual(minifiedPing) + expect(mergeErrorMapSpy).toHaveBeenCalledTimes(1) + expect(resolveMetaPluginsSpy).toHaveBeenCalledTimes(1) - expect((minified as any).nested.pong).toSatisfy(isContractProcedure) - expect((minified as any).nested.pong).toEqual(minifiedPong) -}) + expectAugmentedProcedure(1, augmented.ping, router.nested.ping, options) + }) -describe('contract modules that export primitives alongside procedures', () => { - // Simulates: import * as userContract from './contracts/user' - // where the module exports contract procedures AND constants like: - // export const getUser = oc.input(userSchema) - // export const listUsers = oc.input(listSchema) - // export const API_VERSION = 'v2' - // export const MAX_PAGE_SIZE = 100 - // export const ENABLE_CACHE = true - - const moduleWithPrimitives = { - getUser: ping, - listUsers: pong, - API_VERSION: 'v2', - MAX_PAGE_SIZE: 100, - ENABLE_CACHE: true, - DEPRECATED: null, - OPTIONAL_FEATURE: undefined, - } as any - - describe('enhanceContractRouter', () => { - const options = { errorMap: {}, prefix: '/api', tags: ['api'] } as const - - it('enhances procedures and passes through primitive exports', () => { - const enhanced = enhanceContractRouter(moduleWithPrimitives, options) as unknown as { - getUser: AnyContractProcedure - listUsers: AnyContractProcedure - API_VERSION: string - MAX_PAGE_SIZE: number - ENABLE_CACHE: boolean - } - expect(isContractProcedure(enhanced.getUser)).toBe(true) - expect(isContractProcedure(enhanced.listUsers)).toBe(true) - expect(enhanced.API_VERSION).toBe('v2') - expect(enhanced.MAX_PAGE_SIZE).toBe(100) - expect(enhanced.ENABLE_CACHE).toBe(true) - }) + it('returns non-object router values as-is', () => { + const options = createAugmentOptions() + const invalid = 'invalid' as any - it('handles single-character string exports without stack overflow', () => { - // Single-char strings are the worst case: for...in on 'v' yields key '0', - // and 'v'[0] === 'v' creates an infinite loop - const moduleWithFlag = { getUser: ping, v: 'v' } as any - expect(() => enhanceContractRouter(moduleWithFlag, options)).not.toThrow() - }) + expect(augmentContractRouter(invalid, options)).toBe(invalid) + expect(mergeErrorMapSpy).not.toHaveBeenCalled() + expect(resolveMetaPluginsSpy).not.toHaveBeenCalled() }) +}) - describe('minifyContractRouter', () => { - it('minifies procedures and passes through primitive exports', () => { - const minified = minifyContractRouter(moduleWithPrimitives) - expect(isContractProcedure((minified as any).getUser)).toBe(true) - expect(isContractProcedure((minified as any).listUsers)).toBe(true) - expect((minified as any).API_VERSION).toBe('v2') - expect((minified as any).MAX_PAGE_SIZE).toBe(100) - }) +describe('getRouterContract', () => { + it('returns routers and procedures for valid paths', () => { + expect(getRouterContract(router, [])).toBe(router) + expect(getRouterContract(router, ['ping'])).toBe(router.ping) + expect(getRouterContract(router, ['nested'])).toBe(router.nested) + expect(getRouterContract(router, ['nested', 'ping'])).toBe(router.nested.ping) - it('handles single-character string exports without stack overflow', () => { - const moduleWithFlag = { getUser: ping, v: 'v' } as any - expect(() => minifyContractRouter(moduleWithFlag)).not.toThrow() - }) + expect(getRouterContract(router.ping, [])).toBe(router.ping) + expect(getRouterContract(router.nested, [])).toBe(router.nested) + expect(getRouterContract(router.nested, ['ping'])).toBe(router.nested.ping) }) - describe('populateContractRouterPaths', () => { - it('populates procedure paths and passes through primitive exports', () => { - const moduleForPaths = { - getUser: oc.input(inputSchema), - listUsers: oc.output(outputSchema), - API_VERSION: 'v2', - MAX_PAGE_SIZE: 100, - ENABLE_CACHE: true, - } as any - const populated = populateContractRouterPaths(moduleForPaths) as unknown as { - getUser: AnyContractProcedure - listUsers: AnyContractProcedure - API_VERSION: string - MAX_PAGE_SIZE: number - ENABLE_CACHE: boolean - } - expect(isContractProcedure(populated.getUser)).toBe(true) - expect(populated.getUser['~orpc'].route.path).toBe('/getUser') - expect(isContractProcedure(populated.listUsers)).toBe(true) - expect(populated.listUsers['~orpc'].route.path).toBe('/listUsers') - expect(populated.API_VERSION).toBe('v2') - expect(populated.MAX_PAGE_SIZE).toBe(100) - }) + it('returns undefined for invalid paths', () => { + expect(getRouterContract(router, ['notExist'])).toBeUndefined() + expect(getRouterContract(router, ['notExist', 'notExist'])).toBeUndefined() - it('handles single-character string exports without stack overflow', () => { - const moduleWithFlag = { getUser: oc.input(inputSchema), v: 'v' } as any - expect(() => populateContractRouterPaths(moduleWithFlag)).not.toThrow() - }) + expect(getRouterContract(router, ['invalid'])).toBeUndefined() + expect(getRouterContract(router, ['invalid', 'notExist'])).toBeUndefined() + expect(getRouterContract(router, ['nested', 'invalid'])).toBeUndefined() + + expect(getRouterContract(router, ['nested', 'ping', '~orpc'])).toBeUndefined() + expect(getRouterContract(router, ['nested', 'ping', '~orpc', 'invalid'])).toBeUndefined() + expect(getRouterContract(router.ping, ['invalid'])).toBeUndefined() + + expect(getRouterContract('invalid' as any, [])).toBeUndefined() + expect(getRouterContract('invalid' as any, ['invalid'])).toBeUndefined() }) +}) - describe('getContractRouter', () => { - it('returns undefined when path traverses past a primitive export', () => { - expect(getContractRouter(moduleWithPrimitives, ['API_VERSION', 'length'])).toBeUndefined() - expect(getContractRouter(moduleWithPrimitives, ['MAX_PAGE_SIZE', 'toFixed'])).toBeUndefined() - expect(getContractRouter(moduleWithPrimitives, ['ENABLE_CACHE', 'valueOf'])).toBeUndefined() - }) +describe('getProcedureContractOrThrow', () => { + it('returns procedures for valid paths', () => { + expect(getProcedureContractOrThrow(router, ['ping'])).toBe(router.ping) + expect(getProcedureContractOrThrow(router, ['nested', 'ping'])).toBe(router.nested.ping) + expect(getProcedureContractOrThrow(router.ping, [])).toBe(router.ping) + expect(getProcedureContractOrThrow(router.nested, ['ping'])).toBe(router.nested.ping) + }) - it('returns undefined for single-character string exports instead of indexed characters', () => { - // Without the typeof guard, getContractRouter(['v', '0']) returns 'v' - // because 'v'[0] === 'v', walking character indices instead of bailing out. - const moduleWithFlag = { getUser: ping, v: 'v' } as any - expect(getContractRouter(moduleWithFlag, ['v', '0'])).toBeUndefined() - expect(getContractRouter(moduleWithFlag, ['v', '0', '0', '0'])).toBeUndefined() - }) + it('throws for non-procedure or invalid paths', () => { + function noProcedureError(path: readonly string[]) { + return new TypeError(`No valid procedure found at path "${path.join('.')}", this may happen when the router contract is not properly configured.`) + } + + expect(() => getProcedureContractOrThrow(router, [])).toThrow(noProcedureError([])) + expect(() => getProcedureContractOrThrow(router, ['nested'])).toThrow(noProcedureError(['nested'])) + expect(() => getProcedureContractOrThrow(router, ['notExist'])).toThrow(noProcedureError(['notExist'])) + expect(() => getProcedureContractOrThrow(router, ['invalid'])).toThrow(noProcedureError(['invalid'])) + expect(() => getProcedureContractOrThrow('invalid' as any, [])).toThrow(noProcedureError([])) }) }) -it('populateContractRouterPaths', () => { - const contract = { - ping: oc.input(inputSchema), - pong: oc.route({ - path: '/pong/{id}', - }), - nested: { - ping: oc.output(outputSchema), - pong: oc.route({ - path: '/pong2/{id}', - }), - }, +describe('minifyRouterContract', () => { + function expectMinifiedProcedure(actual: any, original: any) { + expect(actual).toBeInstanceOf(ProcedureContract) + expect(actual).not.toBe(original) + expect(actual).toEqual({ + '~orpc': { + errorMap: {}, + meta: original['~orpc'].meta, + }, + }) } - const populated = populateContractRouterPaths(contract) + it('minifies every procedure in nested routers', () => { + const minified = minifyRouterContract(router) as any + + expect(minified).not.toBe(router) + expect(minified.nested).not.toBe(router.nested) + + expectMinifiedProcedure(minified.ping, router.ping) + expectMinifiedProcedure(minified.pong, router.pong) + expectMinifiedProcedure(minified.nested.ping, router.nested.ping) - expect(populated.pong['~orpc'].route.path).toBe('/pong/{id}') - expect(populated.nested.pong['~orpc'].route.path).toBe('/pong2/{id}') + expect(minified.invalid).toBe('invalid') + expect(minified.nested.invalid).toBe('invalid') + }) + + it('minifies a procedure passed as the root router', () => { + const minified = minifyRouterContract(router.pong) - expect(populated.ping['~orpc'].route.path).toBe('/ping') - expect(populated.ping['~orpc'].inputSchema).toBe(inputSchema) + expectMinifiedProcedure(minified, router.pong) + }) - expect(populated.nested.ping['~orpc'].route.path).toBe('/nested/ping') - expect(populated.nested.ping['~orpc'].outputSchema).toBe(outputSchema) + it('supports function-like routers passed as the root router', () => { + const functionRouter = callable(router.nested) + const minified = minifyRouterContract(functionRouter) as any + + expect(minified).not.toBe(functionRouter) + expectMinifiedProcedure(minified.ping, router.nested.ping) + expect(minified.invalid).toBe('invalid') + }) + + it('returns non-object router values as-is', () => { + const invalid = 'invalid' as any + + expect(minifyRouterContract(invalid)).toBe(invalid) + }) }) diff --git a/packages/contract/src/router-utils.ts b/packages/contract/src/router-utils.ts index a1b1edc17..d5972a650 100644 --- a/packages/contract/src/router-utils.ts +++ b/packages/contract/src/router-utils.ts @@ -1,154 +1,118 @@ -import type { ErrorMap, MergedErrorMap } from './error' -import type { AnyContractProcedure } from './procedure' -import type { EnhanceRouteOptions } from './route' -import type { AnyContractRouter } from './router' -import { toHttpPath } from '@orpc/client/standard' -import { toArray } from '@orpc/shared' -import { mergeErrorMap } from './error' -import { ContractProcedure, isContractProcedure } from './procedure' -import { enhanceRoute } from './route' - -export function getContractRouter(router: AnyContractRouter, path: readonly string[]): AnyContractRouter | undefined { - let current: AnyContractRouter | undefined = router - - for (let i = 0; i < path.length; i++) { - const segment = path[i]! - - if (!current) { - return undefined - } - - if (isContractProcedure(current)) { - return undefined - } - - if (typeof current !== 'object') { - return undefined - } - - current = current[segment] - } - - return current -} - -export type EnhancedContractRouter - = T extends ContractProcedure - ? ContractProcedure, UMeta> +import type { ErrorMap } from './error' +import type { MergedErrorMap } from './error-utils' +import type { AnyMetaPlugin, Meta } from './meta' +import type { AnyProcedureContract } from './procedure' +import type { RouterContract } from './router' +import { isTypescriptObject } from '@orpc/shared' +import { mergeErrorMap } from './error-utils' +import { resolveMetaPlugins } from './meta-utils' +import { ProcedureContract } from './procedure' + +export type AugmentedContractRouter + = T extends ProcedureContract + ? ProcedureContract<$InputSchema, $OutputSchema, MergedErrorMap> : { - [K in keyof T]: T[K] extends AnyContractRouter ? EnhancedContractRouter : never + [K in keyof T]: T[K] extends RouterContract ? AugmentedContractRouter : never } -export interface EnhanceContractRouterOptions extends EnhanceRouteOptions { +export interface AugmentContractRouterOptions { + meta: Meta + metaPlugins?: AnyMetaPlugin[] | undefined errorMap: TErrorMap } -export function enhanceContractRouter( +/** + * Add capabilities without changing identity of the router contract + */ +export function augmentContractRouter( router: T, - options: EnhanceContractRouterOptions, -): EnhancedContractRouter { - if (isContractProcedure(router)) { - const enhanced = new ContractProcedure({ + options: AugmentContractRouterOptions, +): AugmentedContractRouter { + if (router instanceof ProcedureContract) { + const [meta, metaPlugins] = resolveMetaPlugins( + options.meta, + options.metaPlugins, + router['~orpc'].metaPlugins, + ) + + const enhanced = new ProcedureContract({ ...router['~orpc'], errorMap: mergeErrorMap(options.errorMap, router['~orpc'].errorMap), - route: enhanceRoute(router['~orpc'].route, options), + meta, + metaPlugins, }) return enhanced as any } - if (typeof router !== 'object' || router === null) { + if (!isTypescriptObject(router)) { return router as any } const enhanced: Record = {} for (const key in router) { - enhanced[key] = enhanceContractRouter(router[key]!, options) + enhanced[key] = augmentContractRouter(router[key]!, options) } return enhanced as any } -/** - * Minify a contract router into a smaller object. - * - * You should export the result to a JSON file. On the client side, you can import this JSON file and use it as a contract router. - * This reduces the size of the contract and helps prevent leaking internal details of the router to the client. - * - * @see {@link https://orpc.dev/docs/contract-first/router-to-contract#minify-export-the-contract-router-for-the-client Router to Contract Docs} - */ -export function minifyContractRouter(router: AnyContractRouter): AnyContractRouter { - if (isContractProcedure(router)) { - const procedure: AnyContractProcedure = { - '~orpc': { - errorMap: {}, - meta: router['~orpc'].meta, - route: router['~orpc'].route, - }, +export function getRouterContract(router: RouterContract, path: readonly string[]): RouterContract | undefined { + let current: RouterContract | undefined = router + + for (let i = 0; i < path.length; i++) { + const segment = path[i]! + + if (!isTypescriptObject(current)) { + return undefined } - return procedure - } + if (current instanceof ProcedureContract) { + return undefined + } - if (typeof router !== 'object' || router === null) { - return router as any + current = current[segment] } - const json: Record = {} - - for (const key in router) { - json[key] = minifyContractRouter(router[key]!) + if (!isTypescriptObject(current)) { + return undefined } - return json + return current } -export type PopulatedContractRouterPaths - = T extends ContractProcedure - ? ContractProcedure - : { - [K in keyof T]: T[K] extends AnyContractRouter ? PopulatedContractRouterPaths : never - } +export function getProcedureContractOrThrow(router: RouterContract, path: readonly string[]): AnyProcedureContract { + const procedure = getRouterContract(router, path) + + if (!(procedure instanceof ProcedureContract)) { + throw new TypeError(`No valid procedure found at path "${path.join('.')}", this may happen when the router contract is not properly configured.`) + } -export interface PopulateContractRouterPathsOptions { - path?: readonly string[] + return procedure } -/** - * Automatically populates missing route paths using the router's nested keys. - * - * Constructs paths by joining router keys with `/`. - * Useful for NestJS integration that require explicit route paths. - * - * @see {@link https://orpc.dev/docs/openapi/integrations/implement-contract-in-nest#define-your-contract NestJS Implement Contract Docs} - */ -export function populateContractRouterPaths(router: T, options: PopulateContractRouterPathsOptions = {}): PopulatedContractRouterPaths { - const path = toArray(options.path) - - if (isContractProcedure(router)) { - if (router['~orpc'].route.path === undefined) { - return new ContractProcedure({ - ...router['~orpc'], - route: { - ...router['~orpc'].route, - path: toHttpPath(path), - }, - }) as any +export function minifyRouterContract(router: RouterContract): RouterContract { + if (router instanceof ProcedureContract) { + const procedure: AnyProcedureContract = { + '~orpc': { + errorMap: {}, + meta: router['~orpc'].meta, + }, } - return router as any + return procedure } - if (typeof router !== 'object' || router === null) { - return router as any + if (!isTypescriptObject(router)) { + return router } - const populated: Record = {} + const json: Record = {} for (const key in router) { - populated[key] = populateContractRouterPaths(router[key]!, { ...options, path: [...path, key] }) + json[key] = minifyRouterContract(router[key]!) } - return populated as any + return json } diff --git a/packages/contract/src/router.test-d.ts b/packages/contract/src/router.test-d.ts index 99573ca74..5fc3c879b 100644 --- a/packages/contract/src/router.test-d.ts +++ b/packages/contract/src/router.test-d.ts @@ -1,41 +1,89 @@ -import type { baseErrorMap, BaseMeta } from '../tests/shared' -import type { Meta } from './meta' -import type { ContractRouter, InferContractRouterErrorMap, InferContractRouterInputs, InferContractRouterMeta, InferContractRouterOutputs } from './router' -import { router } from '../tests/shared' +import type { ORPCError } from '@orpc/client' +import type { ThrowableError } from '@orpc/shared' +import type { InferRouterContractError, InferRouterContractErrorMap, InferRouterContractErrors, InferRouterContractInputs, InferRouterContractOutputs } from './router' +import { expectTypeOf, it } from 'vitest' +import { z } from 'zod' +import { oc } from './builder' -describe('ContractRouter', () => { - it('meta', () => { - expectTypeOf(router).toMatchTypeOf>() - expectTypeOf(router).toMatchTypeOf>() +// Schemas should have distinct TInput and TOutput types to ensure correct inference. +const schema1 = z.object({ schema1: z.string().transform(n => Number(n)) }) +const schema2 = z.object({ schema2: z.string().transform(n => Number(n)) }) - expectTypeOf(router).not.toMatchTypeOf & { mode?: number }>>() +const ping = oc + .input(schema1) + .output(schema2) + +const pong = oc + .errors({ + BAD_GATEWAY: { + data: schema1, + }, }) -}) -it('InferContractRouterInputs', () => { - type Inputs = InferContractRouterInputs +const notFound = oc + .errors({ + NOT_FOUND: { + data: schema2, + }, + }) + +const router = { + ping, + pong, + nested: { + ping, + pong, + }, +} + +const errorRouter = { + pong, + nested: { + notFound, + }, +} - expectTypeOf().toEqualTypeOf<{ input: number }>() - expectTypeOf().toEqualTypeOf() +it('InferRouterContractInputs', () => { + type Inputs = InferRouterContractInputs - expectTypeOf().toEqualTypeOf<{ input: number }>() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<{ schema1: string }>() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf<{ schema1: string }>() + expectTypeOf().toEqualTypeOf() }) -it('InferContractRouterOutputs', () => { - type Outputs = InferContractRouterOutputs +it('InferRouterContractOutputs', () => { + type Outputs = InferRouterContractOutputs - expectTypeOf().toEqualTypeOf<{ output: string }>() + expectTypeOf().toEqualTypeOf<{ schema2: number }>() expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf<{ output: string }>() + expectTypeOf().toEqualTypeOf<{ schema2: number }>() expectTypeOf().toEqualTypeOf() }) -it('InferContractRouterErrorMap', () => { - expectTypeOf>().toEqualTypeOf>() +it('InferRouterContractErrorMap', () => { + expectTypeOf>().toExtend<{ + BAD_GATEWAY: { data: typeof schema1 } + }>() + + expectTypeOf<{ + BAD_GATEWAY: { data: typeof schema1 } + }>().toExtend>() +}) + +it('InferRouterContractErrors', () => { + type Errors = InferRouterContractErrors + + expectTypeOf().toEqualTypeOf | ThrowableError>() + expectTypeOf().toEqualTypeOf | ThrowableError>() }) -it('InferContractRouterMeta', () => { - expectTypeOf>().toEqualTypeOf() +it('InferRouterContractError', () => { + expectTypeOf>().toEqualTypeOf< + | ORPCError<'BAD_GATEWAY', { schema1: number }> + | ORPCError<'NOT_FOUND', { schema2: number }> + | ThrowableError + >() }) diff --git a/packages/contract/src/router.ts b/packages/contract/src/router.ts index 6461459d3..8c22c18d0 100644 --- a/packages/contract/src/router.ts +++ b/packages/contract/src/router.ts @@ -1,58 +1,51 @@ -import type { Meta } from './meta' -import type { ContractProcedure } from './procedure' +import type { ThrowableError } from '@orpc/shared' +import type { ORPCErrorFromErrorMap } from './error' +import type { AnyProcedureContract, ProcedureContract } from './procedure' import type { InferSchemaInput, InferSchemaOutput } from './schema' -/** - * Represents a contract router, which defines a hierarchical structure of contract procedures. - * - * @info A contract procedure is a contract router too. - * @see {@link https://orpc.dev/docs/contract-first/define-contract#contract-router Contract Router Docs} - */ -export type ContractRouter - = | ContractProcedure +export type RouterContract + = | AnyProcedureContract | { - [k: string]: ContractRouter + [k: string]: RouterContract } -export type AnyContractRouter = ContractRouter - -/** - * Infer all inputs of the contract router. - * - * @info A contract procedure is a contract router too. - * @see {@link https://orpc.dev/docs/contract-first/define-contract#utilities Contract Utilities Docs} - */ -export type InferContractRouterInputs - = T extends ContractProcedure +export type InferRouterContractInputs + = T extends ProcedureContract ? InferSchemaInput : { - [K in keyof T]: T[K] extends AnyContractRouter ? InferContractRouterInputs : never + [K in keyof T]: T[K] extends RouterContract ? InferRouterContractInputs : never } -/** - * Infer all outputs of the contract router. - * - * @info A contract procedure is a contract router too. - * @see {@link https://orpc.dev/docs/contract-first/define-contract#utilities Contract Utilities Docs} - */ -export type InferContractRouterOutputs - = T extends ContractProcedure +export type InferRouterContractOutputs + = T extends ProcedureContract ? InferSchemaOutput : { - [K in keyof T]: T[K] extends AnyContractRouter ? InferContractRouterOutputs : never + [K in keyof T]: T[K] extends RouterContract ? InferRouterContractOutputs : never } +export type InferRouterContractErrorMap + = T extends ProcedureContract + ? UErrorMap + : { + [K in keyof T]: T[K] extends RouterContract ? InferRouterContractErrorMap : never + }[keyof T] + /** - * Infer all errors of the contract router. - * - * @info A contract procedure is a contract router too. - * @see {@link https://orpc.dev/docs/contract-first/define-contract#utilities Contract Utilities Docs} + * Infer the union of throwable errors for entire router-contract. */ -export type InferContractRouterErrorMap - = T extends ContractProcedure - ? UErrorMap +export type InferRouterContractError + = T extends ProcedureContract + ? ORPCErrorFromErrorMap | ThrowableError : { - [K in keyof T]: T[K] extends AnyContractRouter ? InferContractRouterErrorMap : never + [K in keyof T]: T[K] extends RouterContract ? InferRouterContractError : never }[keyof T] -export type InferContractRouterMeta = T extends ContractRouter ? UMeta : never +/** + * Infer throwable errors for each procedure-contract, preserving the router-contract shape. + */ +export type InferRouterContractErrors + = T extends ProcedureContract + ? ORPCErrorFromErrorMap | ThrowableError + : { + [K in keyof T]: T[K] extends RouterContract ? InferRouterContractErrors : never + } diff --git a/packages/contract/src/schema-utils.test.ts b/packages/contract/src/schema-utils.test.ts index 015ae92c2..45e4f0402 100644 --- a/packages/contract/src/schema-utils.test.ts +++ b/packages/contract/src/schema-utils.test.ts @@ -1,7 +1,23 @@ -import { type } from 'arktype' +import * as arktype from 'arktype' import * as v from 'valibot' -import * as z from 'zod' -import { isSchemaIssue } from './schema-utils' +import z from 'zod' +import { isSchemaIssue, type } from './schema-utils' + +describe('type', async () => { + it('without map', async () => { + const schema = type() + const val = {} + expect((await schema['~standard'].validate(val) as any).value).toBe(val) + }) + + it('with map', async () => { + const val = {} + const check = vi.fn().mockReturnValueOnce('__mapped__') + const schema = type(check) + expect((await schema['~standard'].validate(val) as any).value).toBe('__mapped__') + expect(check).toHaveBeenCalledWith(val) + }) +}) describe('isSchemaIssue', async () => { it('works', () => { @@ -20,7 +36,7 @@ describe('isSchemaIssue', async () => { it.each([ ['zod', z.object({ a: z.number() })], ['valibot', v.object({ a: v.number() })], - ['arktype', type({ a: 'number' })], + ['arktype', arktype.type({ a: 'number' })], ])('with schema: $0', async (name, schema) => { const { issues } = await schema['~standard'].validate({ a: 'invalid' }) expect(issues?.every(isSchemaIssue)).toBe(true) diff --git a/packages/contract/src/schema-utils.ts b/packages/contract/src/schema-utils.ts index c53190871..33c2de2c8 100644 --- a/packages/contract/src/schema-utils.ts +++ b/packages/contract/src/schema-utils.ts @@ -1,6 +1,44 @@ -import type { SchemaIssue } from './schema' -import { isPropertyKey, isTypescriptObject } from '@orpc/shared' +import type { IsEqual, Promisable } from '@orpc/shared' +import type { Schema, SchemaIssue } from './schema' +import { isPropertyKey, isTypescriptObject, ORPC_NAME } from '@orpc/shared' +export type TypeRest + = | [map: (input: TInput) => Promisable] + | (IsEqual extends true ? [] : never) + +/** + * Create a schema for things can be trust without validation. + * You can optionally pass a map function for mapping + * + * @example + * ```ts + * const normal = type() + * const withMap = type(input => input.toString()) + *``` + * + * @see {@link https://orpc.dev/docs/procedure#type-utility Type Utility Docs} + */ +export function type( + ...[map]: TypeRest +): Schema { + return { + '~standard': { + vendor: ORPC_NAME, + version: 1, + async validate(value) { + if (map) { + return { value: await map(value as TInput) as TOutput } + } + + return { value: value as TOutput } + }, + }, + } +} + +/** + * Check if the given issue is following the standard-schema issue format. + */ export function isSchemaIssue(issue: unknown): issue is SchemaIssue { if (!isTypescriptObject(issue) || typeof issue.message !== 'string') { return false diff --git a/packages/contract/src/schema.test-d.ts b/packages/contract/src/schema.test-d.ts index 492eb29e9..60b33c21b 100644 --- a/packages/contract/src/schema.test-d.ts +++ b/packages/contract/src/schema.test-d.ts @@ -1,68 +1,82 @@ -import type { AnySchema, InferSchemaInput, InferSchemaOutput } from './schema' +import type { AnySchema, InferSchemaInput, InferSchemaOutput, MergedSchema, Schema } from './schema' import { type as arktypeType } from 'arktype' import * as v from 'valibot' -import * as z from 'zod' -import { type } from './schema' +import z from 'zod' -const zod = z.object({ - value: z.string().transform(() => 123), -}) - -const valibot = v.object({ - value: v.pipe(v.string(), v.transform(() => 123)), -}) - -// How convert value into number? -const arktype = arktypeType({ - value: 'string', -}) +// Schemas should have distinct TInput and TOutput types to ensure correct inference. +const inputSchema = z.object({ input: z.number().transform(n => `${n}`) }) +const outputSchema = z.object({ output: z.string().transform(s => Number(s)) }) describe('Schema', () => { - it('assignable', () => { - const _zod: AnySchema = zod - const _valibot: AnySchema = valibot - const _arktype: AnySchema = arktype + it('supports any standard schema', () => { + const _zod: AnySchema = z.object({ + value: z.string().transform(() => 123), + }) + const _valibot: AnySchema = v.object({ + value: v.pipe(v.string(), v.transform(() => 123)), + }) + const _arktype: AnySchema = arktypeType({ + value: 'string', + }) }) }) -describe('SchemaInput', () => { - it('inferable', () => { - expectTypeOf>().toEqualTypeOf<{ value: string }>() - expectTypeOf>().toEqualTypeOf<{ value: string }>() - expectTypeOf>().toEqualTypeOf<{ value: string }>() - }) +it('InferSchemaInput', () => { + expectTypeOf>().toEqualTypeOf<{ input: number }>() + expectTypeOf>().toEqualTypeOf<{ output: string }>() }) -describe('SchemaOutput', () => { - it('inferable', () => { - expectTypeOf>().toEqualTypeOf<{ value: number }>() - expectTypeOf>().toEqualTypeOf<{ value: number }>() - expectTypeOf>().toEqualTypeOf<{ value: string }>() - }) +it('InferSchemaOutput', () => { + expectTypeOf>().toEqualTypeOf<{ input: string }>() + expectTypeOf>().toEqualTypeOf<{ output: number }>() }) -describe('type', () => { - it('without map', () => { - const schema = type() +describe('MergedSchema', () => { + it('merges two schemas', () => { + type Schema1 = Schema<{ schema1: number }, { schema1: string }> + type Schema2 = Schema<{ schema2: string }, { schema2: number }> - expectTypeOf>().toEqualTypeOf() - expectTypeOf>().toEqualTypeOf() + type TMerged = MergedSchema + expectTypeOf().toEqualTypeOf< + Schema<{ schema1: number } & { schema2: string }, { schema1: string } & { schema2: number }> + >() }) - it('with map', () => { - const schema2 = type((val) => { - expectTypeOf(val).toEqualTypeOf() + it('merges three schemas', () => { + type Schema1 = Schema<{ schema1: number }, { schema1: string }> + type Schema2 = Schema<{ schema2: string }, { schema2: number }> + type Schema3 = Schema<{ schema3: string }, { schema3: boolean }> + + type TMerged = MergedSchema> + expectTypeOf().toEqualTypeOf< + Schema<{ schema1: number } & { schema2: string } & { schema3: string }, { schema1: string } & { schema2: number } & { schema3: boolean }> + >() + }) - return Number(val) + it('works with zod, valibot, arktype', () => { + const schema1 = z.object({ + schema1: z.number().transform(n => `${n}`), + }) + const schema2 = v.object({ + schema2: v.pipe(v.string(), v.transform(s => Number(s))), + }) + const schema3 = arktypeType({ + schema3: 'string', }) - expectTypeOf>().toEqualTypeOf() - expectTypeOf>().toEqualTypeOf() + type TMerged = MergedSchema> + expectTypeOf().toEqualTypeOf< + Schema<{ schema1: number } & { schema2: string } & { schema3: string }, { schema1: string } & { schema2: number } & { schema3: string }> + >() + }) - // @ts-expect-error - map is required when TInput !== TOutput - type() + it('works with InferSchemaInput and InferSchemaOutput', () => { + type Schema1 = Schema<{ schema1: number }, { schema1: string }> + type Schema2 = Schema<{ schema2: string }, { schema2: number }> + type Schema3 = Schema<{ schema3: string }, { schema3: boolean }> - // @ts-expect-error - output not match number - type(() => '123') + type TMerged = MergedSchema> + expectTypeOf>().toEqualTypeOf<{ schema1: number } & { schema2: string } & { schema3: string }>() + expectTypeOf>().toEqualTypeOf<{ schema1: string } & { schema2: number } & { schema3: boolean }>() }) }) diff --git a/packages/contract/src/schema.test.ts b/packages/contract/src/schema.test.ts deleted file mode 100644 index 34c03617c..000000000 --- a/packages/contract/src/schema.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { type } from './schema' - -describe('type', async () => { - it('without map', async () => { - const schema = type() - const val = {} - expect((await schema['~standard'].validate(val) as any).value).toBe(val) - }) - - it('with map', async () => { - const val = {} - const check = vi.fn().mockReturnValueOnce('__mapped__') - const schema = type(check) - expect((await schema['~standard'].validate(val) as any).value).toBe('__mapped__') - expect(check).toHaveBeenCalledWith(val) - }) -}) diff --git a/packages/contract/src/schema.ts b/packages/contract/src/schema.ts index 3f4b908b9..756a62465 100644 --- a/packages/contract/src/schema.ts +++ b/packages/contract/src/schema.ts @@ -1,10 +1,12 @@ -import type { IsEqual, Promisable } from '@orpc/shared' // eslint-disable-next-line no-restricted-imports import type { StandardSchemaV1 } from '@standard-schema/spec' -export type Schema = StandardSchemaV1 +/** + * TOutput default = TInput for better readability (shorter) in-case both TInput, TOutput is equal + */ +export type Schema = StandardSchemaV1 -export type AnySchema = Schema +export type AnySchema = Schema export type SchemaIssue = StandardSchemaV1.Issue @@ -12,28 +14,9 @@ export type InferSchemaInput = T extends StandardSchemaV1 = T extends StandardSchemaV1 ? UOutput : never -export type TypeRest - = | [map: (input: TInput) => Promisable] - | (IsEqual extends true ? [] : never) - -/** - * The schema for things can be trust without validation. - * If the TInput and TOutput are different, you need pass a map function. - * - * @see {@link https://orpc.dev/docs/procedure#type-utility Type Utility Docs} - */ -export function type(...[map]: TypeRest): Schema { - return { - '~standard': { - vendor: 'custom', - version: 1, - async validate(value) { - if (map) { - return { value: await map(value as TInput) as TOutput } - } - - return { value: value as TOutput } - }, - }, - } -} +export type MergedSchema + = T extends Schema + ? U extends Schema + ? Schema + : never + : never diff --git a/packages/contract/src/types.ts b/packages/contract/src/types.ts deleted file mode 100644 index 98027fff9..000000000 --- a/packages/contract/src/types.ts +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-disable no-restricted-imports */ -export type { OpenAPIV3_1 as OpenAPI } from 'openapi-types' diff --git a/packages/contract/tests/e2e.test-d.ts b/packages/contract/tests/e2e.test-d.ts deleted file mode 100644 index 41b7dd94a..000000000 --- a/packages/contract/tests/e2e.test-d.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { InferContractRouterInputs, InferContractRouterOutputs } from '../src' -import type { router } from './helpers' - -describe('InferContractRouterInputs', () => { - it('works', () => { - type Inputs = InferContractRouterInputs - - expectTypeOf().toEqualTypeOf<{ limit?: number, cursor?: number }>() - expectTypeOf().toEqualTypeOf<{ name: string, description?: string }>() - }) -}) - -describe('InferContractRouterOutputs', () => { - it('works', () => { - type Outputs = InferContractRouterOutputs - - expectTypeOf().toEqualTypeOf<{ id: number, name: string, description?: string, imageUrl?: string }[]>() - expectTypeOf().toEqualTypeOf() - }) -}) diff --git a/packages/contract/tests/helpers.ts b/packages/contract/tests/helpers.ts deleted file mode 100644 index d5609eddb..000000000 --- a/packages/contract/tests/helpers.ts +++ /dev/null @@ -1,76 +0,0 @@ -import * as z from 'zod' -import { oc } from '../src' - -export const NewPlanetSchema = z.object({ - name: z.string(), - description: z.string().optional(), -}) - -export const UpdatePlanetSchema = z.object({ - id: z.number().int().min(1), - name: z.string(), - description: z.string().optional(), -}) - -export const PlanetSchema = z.object({ - id: z.number().int().min(1), - name: z.string(), - description: z.string().optional(), - imageUrl: z.string().url().optional(), -}) - -export const listPlanets = oc - .input( - z.object({ - limit: z.number().int().min(1).max(100).optional(), - cursor: z.number().int().min(0).default(0), - }), - ) - .output(z.array(PlanetSchema)) - -export const createPlanet = oc - .input(NewPlanetSchema) - .output(PlanetSchema) - -export const findPlanet = oc - .route({ - method: 'GET', - path: '/{id}', - summary: 'Find a planet', - }) - .input( - z.object({ - id: z.number().int().min(1), - }), - ) - .output(PlanetSchema) - -export const updatePlanet = oc - .route({ - method: 'PUT', - path: '/{id}', - summary: 'Update a planet', - }) - .input(UpdatePlanetSchema) - .output(PlanetSchema) - -export const deletePlanet = oc - .route({ - method: 'DELETE', - path: '/{id}', - summary: 'Delete a planet', - deprecated: true, - }) - .input( - z.object({ - id: z.number().int().min(1), - }), - ) - -export const router = oc.tag('Planets').prefix('/planets').router({ - list: listPlanets, - create: createPlanet, - find: findPlanet, - update: updatePlanet, - delete: deletePlanet, -}) diff --git a/packages/contract/tests/shared.ts b/packages/contract/tests/shared.ts deleted file mode 100644 index 2d9df3688..000000000 --- a/packages/contract/tests/shared.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Schema } from '../src' -import type { Meta } from '../src/meta' -import * as z from 'zod' -import { ContractProcedure, eventIterator } from '../src' - -export const inputSchema = z.object({ input: z.number().transform(n => `${n}`) }) - -export const outputSchema = z.object({ output: z.number().transform(n => `${n}`) }) - -export const generalSchema = z.object({ general: z.number().transform(n => `${n}`) }) - -export const baseErrorMap = { - BASE: { - data: outputSchema, - }, - OVERRIDE: {}, -} - -export const baseRoute = { path: '/base' } as const - -export type BaseMeta = { mode?: string, log?: boolean } - -export const baseMeta: BaseMeta = { - mode: 'dev', -} - -export const ping = new ContractProcedure< - typeof inputSchema, - typeof outputSchema, - typeof baseErrorMap, - BaseMeta ->({ - inputSchema, - outputSchema, - errorMap: baseErrorMap, - meta: baseMeta, - route: baseRoute, -}) - -export const pong = new ContractProcedure< - Schema, - Schema, - Record, - Meta ->({ - errorMap: {}, - meta: {}, - route: {}, -}) - -export const router = { - ping, - pong, - nested: { - ping, - pong, - }, -} - -export const streamedOutputSchema = eventIterator(outputSchema) - -export const streamed = new ContractProcedure< - typeof inputSchema, - typeof streamedOutputSchema, - typeof baseErrorMap, - Meta ->({ - errorMap: baseErrorMap, - meta: {}, - route: {}, - inputSchema, - outputSchema: streamedOutputSchema, -}) diff --git a/packages/contract/tsconfig.json b/packages/contract/tsconfig.json index 97badece7..db6a40883 100644 --- a/packages/contract/tsconfig.json +++ b/packages/contract/tsconfig.json @@ -4,7 +4,7 @@ { "path": "../client" }, { "path": "../shared" } ], - "include": ["src"], + "include": ["package.json", "src"], "exclude": [ "**/*.test.*", "**/*.test-d.ts", diff --git a/packages/durable-iterator/.gitignore b/packages/durable-iterator/.gitignore deleted file mode 100644 index f3620b55e..000000000 --- a/packages/durable-iterator/.gitignore +++ /dev/null @@ -1,26 +0,0 @@ -# Hidden folders and files -.* -!.gitignore -!.*.example - -# Common generated folders -logs/ -node_modules/ -out/ -dist/ -dist-ssr/ -build/ -coverage/ -temp/ - -# Common generated files -*.log -*.log.* -*.tsbuildinfo -*.vitest-temp.json -vite.config.ts.timestamp-* -vitest.config.ts.timestamp-* - -# Common manual ignore files -*.local -*.pem \ No newline at end of file diff --git a/packages/durable-iterator/README.md b/packages/durable-iterator/README.md deleted file mode 100644 index efae12dca..000000000 --- a/packages/durable-iterator/README.md +++ /dev/null @@ -1,194 +0,0 @@ -
- oRPC logo -
- -

- - - -

Typesafe APIs Made Simple 🪄

- -**oRPC is a powerful combination of RPC and OpenAPI**, makes it easy to build APIs that are end-to-end type-safe and adhere to OpenAPI standards - ---- - -## Highlights - -- **🔗 End-to-End Type Safety**: Ensure type-safe inputs, outputs, and errors from client to server. -- **📘 First-Class OpenAPI**: Built-in support that fully adheres to the OpenAPI standard. -- **📝 Contract-First Development**: Optionally define your API contract before implementation. -- **🔍 First-Class OpenTelemetry**: Seamlessly integrate with OpenTelemetry for observability. -- **⚙️ Framework Integrations**: Seamlessly integrate with TanStack Query (React, Vue, Solid, Svelte, Angular), SWR, Pinia Colada, and more. -- **🚀 Server Actions**: Fully compatible with React Server Actions on Next.js, TanStack Start, and other platforms. -- **🔠 Standard Schema Support**: Works out of the box with Zod, Valibot, ArkType, and other schema validators. -- **🗃️ Native Types**: Supports native types like Date, File, Blob, BigInt, URL, and more. -- **⏱️ Lazy Router**: Enhance cold start times with our lazy routing feature. -- **📡 SSE & Streaming**: Enjoy full type-safe support for SSE and streaming. -- **🌍 Multi-Runtime Support**: Fast and lightweight on Cloudflare, Deno, Bun, Node.js, and beyond. -- **🔌 Extendability**: Easily extend functionality with plugins, middleware, and interceptors. - -## Documentation - -You can find the full documentation [here](https://orpc.dev). - -## Packages - -- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Build your API contract. -- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build your API or implement API contract. -- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API on the client with type-safety. -- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Generate OpenAPI specs and handle OpenAPI requests. -- [@orpc/otel](https://www.npmjs.com/package/@orpc/otel): [OpenTelemetry](https://opentelemetry.io/) integration for observability. -- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Deeply integrate oRPC with [NestJS](https://nestjs.com/). -- [@orpc/react](https://www.npmjs.com/package/@orpc/react): Utilities for integrating oRPC with React and React Server Actions. -- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): [TanStack Query](https://tanstack.com/query/latest) integration. -- [@orpc/experimental-react-swr](https://www.npmjs.com/package/@orpc/experimental-react-swr): [SWR](https://swr.vercel.app/) integration. -- [@orpc/vue-colada](https://www.npmjs.com/package/@orpc/vue-colada): Integration with [Pinia Colada](https://pinia-colada.esm.dev/). -- [@orpc/hey-api](https://www.npmjs.com/package/@orpc/hey-api): [Hey API](https://heyapi.dev/) integration. -- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): More schemas that [Zod](https://zod.dev/) doesn't support yet. -- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): OpenAPI spec generation from [Valibot](https://valibot.dev/). -- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): OpenAPI spec generation from [ArkType](https://arktype.io/). - -## `@orpc/experimental-durable-iterator` - -[Durable Objects](https://developers.cloudflare.com/durable-objects/) integration for oRPC. - -## Sponsors - -If you find oRPC valuable and would like to support its development, you can do so here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). - -### 🏆 Platinum Sponsor - - - - - -
ScreenshotOne.com
ScreenshotOne.com
- -### 🥈 Silver Sponsor - - - - - -
村上さん
村上さん
- -### Generous Sponsors - - - - - -
LN Markets
LN Markets
- -### Sponsors - - - - - - - - - - - - - - - - - - - - - - - - -
Reece McDonald
Reece McDonald
nk
nk
supastarter
supastarter
Dexter Miguel
Dexter Miguel
herrfugbaum
herrfugbaum
Ryota Murakami
Ryota Murakami
David Cramer
David Cramer
Valerii Petryniak
Valerii Petryniak
Valerii Strilets
Valerii Strilets
Kyle Mistele
Kyle Mistele
Andrew Peters
Andrew Peters
Ryan Vogel
Ryan Vogel
christ12938
christ12938
Peter Adam
Peter Adam
Ryan Soderberg
Ryan Soderberg
shota
shota
- -### Backers - - - - - - - - - - - - - - - - - - - - - - - - - -
David Walsh
David Walsh
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
Alex
Alex
nattstack
nattstack
Andrey Gubanov
Andrey Gubanov
- -### Past Sponsors - -

- Maxie - Stijn Timmer - あわわわとーにゅ - Zuplo - motopods - Francisco Hermida - Théo LUDWIG - Abhay Ramesh - shr.ink oü - 0x4e32 - Ryuz - happyboy - yicchi - Saksham - Roman Hrynevych - rokitg - Omar Khatib - Yu-Sabo - Bapusaheb Patil - grim - Nelson Lai - Lê Cao Nguyên - Robert Soriano - SKostyukovich - Fabworks - Novak Antonijevic - Laduni Estu Syalwa - Chen, Zhi-Yuan - Illarion Koperski - Anees Iqbal - Sefa Eyeoglu - Adam Tkaczyk - plancraft -

- -## License - -Distributed under the MIT License. See [LICENSE](https://github.com/middleapi/orpc/blob/main/LICENSE) for more information. diff --git a/packages/durable-iterator/build.config.ts b/packages/durable-iterator/build.config.ts deleted file mode 100644 index 883283d25..000000000 --- a/packages/durable-iterator/build.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineBuildConfig } from 'unbuild' - -export default defineBuildConfig({ - externals: [ - 'cloudflare:workers', - ], -}) diff --git a/packages/durable-iterator/package.json b/packages/durable-iterator/package.json deleted file mode 100644 index e6e427597..000000000 --- a/packages/durable-iterator/package.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "name": "@orpc/experimental-durable-iterator", - "type": "module", - "version": "1.14.6", - "license": "MIT", - "homepage": "https://orpc.dev", - "repository": { - "type": "git", - "url": "git+https://github.com/middleapi/orpc.git", - "directory": "packages/durable-iterator" - }, - "keywords": [ - "orpc" - ], - "sideEffects": false, - "publishConfig": { - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" - }, - "./client": { - "types": "./dist/client/index.d.mts", - "import": "./dist/client/index.mjs", - "default": "./dist/client/index.mjs" - }, - "./durable-object": { - "types": "./dist/durable-object/index.d.mts", - "import": "./dist/durable-object/index.mjs", - "default": "./dist/durable-object/index.mjs" - } - } - }, - "exports": { - ".": "./src/index.ts", - "./client": "./src/client/index.ts", - "./durable-object": "./src/durable-object/index.ts" - }, - "files": [ - "dist" - ], - "scripts": { - "build": "unbuild", - "build:watch": "pnpm run build --watch", - "type:check": "tsc -b", - "type:check:test": "tsc -p tsconfig.test.json --noEmit" - }, - "dependencies": { - "@orpc/client": "workspace:*", - "@orpc/contract": "workspace:*", - "@orpc/server": "workspace:*", - "@orpc/shared": "workspace:*", - "partysocket": "^1.1.16", - "valibot": "^1.2.0" - }, - "devDependencies": { - "@cloudflare/workers-types": "^4.20260313.1", - "@orpc/standard-server-peer": "workspace:*", - "@types/node": "^22.19.3" - } -} diff --git a/packages/durable-iterator/src/client/index.test.ts b/packages/durable-iterator/src/client/index.test.ts deleted file mode 100644 index 370cba1d2..000000000 --- a/packages/durable-iterator/src/client/index.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -it('export something', async () => { - expect(Object.keys(await import('./index'))).toContain('DurableIteratorLinkPlugin') -}) diff --git a/packages/durable-iterator/src/client/index.ts b/packages/durable-iterator/src/client/index.ts deleted file mode 100644 index fbe22ce71..000000000 --- a/packages/durable-iterator/src/client/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './iterator' -export * from './plugin' diff --git a/packages/durable-iterator/src/client/iterator.test-d.ts b/packages/durable-iterator/src/client/iterator.test-d.ts deleted file mode 100644 index 9d0d61785..000000000 --- a/packages/durable-iterator/src/client/iterator.test-d.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Client } from '@orpc/client' -import type { AsyncIteratorClass } from '@orpc/shared' -import type { DurableIteratorObject } from '../object' -import type { ClientDurableIterator, ClientDurableIteratorRpcContext } from './iterator' - -it('ClientDurableIterator', () => { - interface SomeObject extends DurableIteratorObject<{ v: string }> { - sendMessage: (ws: WebSocket) => Client - } - - type ClientIterator = ClientDurableIterator - - expectTypeOf().toExtend>() - - expectTypeOf().toEqualTypeOf< - Client - >() -}) diff --git a/packages/durable-iterator/src/client/iterator.test.ts b/packages/durable-iterator/src/client/iterator.test.ts deleted file mode 100644 index 44553372f..000000000 --- a/packages/durable-iterator/src/client/iterator.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { AsyncIteratorClass, isAsyncIteratorObject } from '@orpc/shared' -import { signDurableIteratorToken } from '../schemas' -import { createClientDurableIterator, getClientDurableIteratorToken } from './iterator' - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('createClientDurableIterator', async () => { - const token = await signDurableIteratorToken('some-secret-key', { - chn: 'some-channel', - rpc: ['method1', 'method2'], - att: { some: 'claims' }, - exp: Math.floor(Date.now() / 1000) + 3600, - iat: Math.floor(Date.now() / 1000), - }) - - const getToken = vi.fn(() => token) - - const next = vi.fn(() => Promise.resolve({ value: '__next__', done: false })) - const cleanup = vi.fn(() => Promise.resolve()) - const call = vi.fn(() => Promise.resolve('__call__')) - - const iterator = createClientDurableIterator(new AsyncIteratorClass(next, cleanup), { - call, - }, { - getToken, - }) - - it('is an async iterator with durable iterator token', async () => { - expect(iterator).toSatisfy(isAsyncIteratorObject) - - expect(await iterator.next()).toEqual({ value: '__next__', done: false }) - expect(next).toHaveBeenCalledTimes(1) - - expect(await iterator.return()).toEqual({ value: undefined, done: true }) - expect(cleanup).toHaveBeenCalledTimes(1) - - expect(getClientDurableIteratorToken(iterator)).toEqual(token) - }) - - it('has methods from the token', async () => { - expect(iterator.method1).toBeInstanceOf(Function) - expect(iterator.method2).toBeInstanceOf(Function) - expect(iterator.random).not.toBeDefined() - - await expect((iterator as any).method1('value1', { context: { context1: true } })).resolves.toEqual('__call__') - await expect((iterator as any).method2.nested.ping('value2')).resolves.toEqual('__call__') - - expect(call).toHaveBeenCalledTimes(2) - expect(call).toHaveBeenNthCalledWith(1, ['method1'], 'value1', { context: { context1: true } }) - expect(call).toHaveBeenNthCalledWith(2, ['method2', 'nested', 'ping'], 'value2', { context: {} }) - - expect(getToken).toHaveBeenCalledTimes(5) - }) - - it('support dynamic token', async () => { - expect(iterator.method1).toBeInstanceOf(Function) - expect(iterator.method2).toBeInstanceOf(Function) - expect(getClientDurableIteratorToken(iterator)).toEqual(token) - - const token2 = await signDurableIteratorToken('some-secret-key', { - chn: 'channel-2', - rpc: ['method2'], - att: { some: 'claims' }, - exp: Math.floor(Date.now() / 1000) + 3600, - iat: Math.floor(Date.now() / 1000), - }) - - getToken.mockReturnValue(token2) - - expect(iterator.method1).not.toBeInstanceOf(Function) // token 2 not have method1 - expect(iterator.method2).toBeInstanceOf(Function) - expect(getClientDurableIteratorToken(iterator)).toEqual(token2) - }) -}) diff --git a/packages/durable-iterator/src/client/iterator.ts b/packages/durable-iterator/src/client/iterator.ts deleted file mode 100644 index 2d4aaa8be..000000000 --- a/packages/durable-iterator/src/client/iterator.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { Client, ClientLink, NestedClient, ThrowableError } from '@orpc/client' -import type { ClientRetryPluginContext } from '@orpc/client/plugins' -import type { AsyncIteratorClass } from '@orpc/shared' -import type { DurableIteratorObject, InferDurableIteratorObjectRPC } from '../object' -import { createORPCClient } from '@orpc/client' -import { isAsyncIteratorObject } from '@orpc/shared' -import { parseDurableIteratorToken } from '../schemas' - -const CLIENT_DURABLE_ITERATOR_TOKEN_SYMBOL = Symbol('ORPC_CLIENT_DURABLE_ITERATOR_TOKEN') - -export interface ClientDurableIteratorRpcContext extends ClientRetryPluginContext { -} - -export type ClientDurableIteratorRpc> - = T extends Client - ? Client - : { - [K in keyof T]: T[K] extends NestedClient - ? ClientDurableIteratorRpc - : never - } - -export type ClientDurableIterator< - T extends DurableIteratorObject, - RPC extends InferDurableIteratorObjectRPC, -> = AsyncIteratorClass ? TPayload : never> & { - [K in RPC]: T[K] extends (...args: any[]) => (infer R extends NestedClient) - ? ClientDurableIteratorRpc - : never -} - -export interface CreateClientDurableIteratorOptions { - /** - * The token used to authenticate the client. - * this is a function because the token is lazy, and dynamic-able - */ - getToken: () => string -} - -export function createClientDurableIterator< - T extends DurableIteratorObject, - RPC extends InferDurableIteratorObjectRPC, ->( - iterator: AsyncIteratorClass, - link: ClientLink, - options: CreateClientDurableIteratorOptions, -): ClientDurableIterator { - const proxy = new Proxy(iterator, { - get(target, prop) { - const token = options.getToken() - const { rpc: allowMethods } = parseDurableIteratorToken(token) - - if (prop === CLIENT_DURABLE_ITERATOR_TOKEN_SYMBOL) { - return token - } - - if (typeof prop === 'string' && allowMethods?.includes(prop)) { - return createORPCClient(link, { path: [prop] }) - } - - const v = Reflect.get(target, prop) - return typeof v === 'function' - ? v.bind(target) // Require .bind itself for calling - : v - }, - }) - - return proxy as any -} - -/** - * If return a token if the client is a Client Durable Iterator. - */ -export function getClientDurableIteratorToken( - client: unknown, -): string | undefined { - if (isAsyncIteratorObject(client)) { - return Reflect.get(client, CLIENT_DURABLE_ITERATOR_TOKEN_SYMBOL) as string | undefined - } -} diff --git a/packages/durable-iterator/src/client/plugin.test.ts b/packages/durable-iterator/src/client/plugin.test.ts deleted file mode 100644 index 8ced4d10c..000000000 --- a/packages/durable-iterator/src/client/plugin.test.ts +++ /dev/null @@ -1,546 +0,0 @@ -import { StandardRPCLink } from '@orpc/client/standard' -import { os } from '@orpc/server' -import { StandardRPCHandler } from '@orpc/server/standard' -import { isAsyncIteratorObject, sleep } from '@orpc/shared' -import { decodeRequestMessage, encodeResponseMessage, MessageType } from '@orpc/standard-server-peer' -import { WebSocket as ReconnectableWebSocket } from 'partysocket' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { DURABLE_ITERATOR_TOKEN_PARAM } from '../consts' -import { DurableIteratorError } from '../error' -import { DurableIterator } from '../iterator' -import { DurableIteratorHandlerPlugin } from '../plugin' -import { parseDurableIteratorToken } from '../schemas' -import { getClientDurableIteratorToken } from './iterator' -import { DurableIteratorLinkPlugin } from './plugin' - -const realSetTimeout = globalThis.setTimeout - -vi.mock('partysocket', () => { - return { - WebSocket: vi.fn(() => ({ - readyState: 1, // OPEN - addEventListener: vi.fn(), - send: vi.fn(), - close: vi.fn(), - reconnect: vi.fn(), - })), - } -}) - -beforeEach(() => { - vi.resetAllMocks() -}) - -describe('durableIteratorLinkPlugin', async () => { - const interceptor = vi.fn(({ next }) => next()) - const durableIteratorHandler = vi.fn( - () => new DurableIterator('some-room', { signingKey: 'signing-key', tags: ['tag'] }).rpc('getUser', 'sendMessage'), - ) - const refreshTokenBeforeExpireInSeconds = vi.fn(() => Number.NaN) - const refreshTokenDelayInSeconds = vi.fn(() => 2) - - const handler = new StandardRPCHandler({ - durableIterator: os.handler(durableIteratorHandler), - regularResponse: os.handler(() => 'regular response'), - }, { - plugins: [ - new DurableIteratorHandlerPlugin(), - ], - }) - - const link = new StandardRPCLink({ - async call(request) { - const result = await handler.handle({ ...request, body: () => Promise.resolve(request.body) }, { - context: {}, - }) - - const response = result.response! - - return { ...response, body: () => Promise.resolve(response.body) } - }, - }, { - url: 'http://localhost', - clientInterceptors: [interceptor], - plugins: [ - new DurableIteratorLinkPlugin({ - url: 'ws://localhost', - refreshTokenBeforeExpireInSeconds, - refreshTokenDelayInSeconds, - }), - ], - }) - - it('should do nothing if not a durable iterator response', async () => { - const output = await link.call(['regularResponse'], {}, { - context: {}, - }) - - expect(output).toEqual('regular response') - }) - - it('should throw error if plugin context is corrupted', async () => { - interceptor.mockImplementationOnce(({ next, ...options }) => next({ ...options, context: {} })) - - await expect(link.call(['regularResponse'], {}, { context: {} })).rejects.toThrow( - new DurableIteratorError('Plugin context has been corrupted or modified by another plugin or interceptor'), - ) - }) - - it('should resolve durable iterator', async () => { - const outputPromise = link.call(['durableIterator'], {}, { context: {}, lastEventId: '__initialEventId__' }) as any - - await vi.waitFor(async () => { - expect(ReconnectableWebSocket).toHaveBeenCalledOnce() - const urlProvider = vi.mocked(ReconnectableWebSocket).mock.calls[0]![0] as any - const url = new URL(await urlProvider()) - - expect(url.toString()).toContain('ws://localhost') - expect(url.toString()).toContain('token=') - expect(url.toString()).toContain('id=') - expect(parseDurableIteratorToken(url.searchParams.get(DURABLE_ITERATOR_TOKEN_PARAM)!)).toBeTypeOf('object') - - const ws = vi.mocked(ReconnectableWebSocket).mock.results[0]!.value - const [id, ,payload] = await decodeRequestMessage(ws.send.mock.calls[0][0]) - - // make sure it use user-provided lastEventId for initial lastEventId - expect((payload as any).headers['last-event-id']).toBe('__initialEventId__') - - const [, onMessage] = vi.mocked(ws.addEventListener).mock.calls.find(([event]: any[]) => event === 'message') as any - - onMessage({ - data: await encodeResponseMessage(id, MessageType.RESPONSE, { - body: (async function* () { }()), - status: 200, - headers: {}, - }), - }) - }) - - const output = await outputPromise - expect(output).toSatisfy(isAsyncIteratorObject) - - expect(output.getUser).toBeInstanceOf(Function) - expect(output.sendMessage).toBeInstanceOf(Function) - expect(output.random).not.toBeDefined() - - // RPC - - const userPromise = output.getUser() - - await vi.waitFor(async () => { - expect(ReconnectableWebSocket).toHaveBeenCalledOnce() - const ws = vi.mocked(ReconnectableWebSocket).mock.results[0]!.value - const [id] = await decodeRequestMessage(ws.send.mock.calls[1][0]) - - const [, onMessage] = vi.mocked(ws.addEventListener).mock.calls.find(([event]: any[]) => event === 'message') as any - - onMessage({ - data: await encodeResponseMessage(id, MessageType.RESPONSE, { - body: { json: 'user' }, - status: 200, - headers: {}, - }), - }) - }) - - const user = await userPromise - expect(user).toEqual('user') - }) - - describe('refresh expired token', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2022-01-01T00:00:00.000Z')) - }) - afterEach(async () => { - await new Promise(resolve => realSetTimeout(resolve, 1000)) // await for all promises resolved - expect(vi.getTimerCount()).toBe(0) // every is cleanup - vi.useRealTimers() - }) - - it('works', async () => { - refreshTokenBeforeExpireInSeconds.mockImplementation(() => 9) - durableIteratorHandler.mockImplementation( - () => new DurableIterator('some-room', { - signingKey: 'signing-key', - tokenTTLSeconds: 10, - }) as any, - ) - - const outputPromise = link.call(['durableIterator'], {}, { context: {} }) as any - - await vi.waitFor(async () => { - expect(ReconnectableWebSocket).toHaveBeenCalledOnce() - - const ws = vi.mocked(ReconnectableWebSocket).mock.results[0]!.value - const [id] = await decodeRequestMessage(ws.send.mock.calls[0][0]) - - const [, onMessage] = vi.mocked(ws.addEventListener).mock.calls.find(([event]: any[]) => event === 'message') as any - - onMessage({ - data: await encodeResponseMessage(id, MessageType.RESPONSE, { - body: (async function* () { }()), - status: 200, - headers: {}, - }), - }) - }) - - const output = await outputPromise - expect(output).toSatisfy(isAsyncIteratorObject) - - expect(vi.getTimerCount()).toBe(1) // refresh token is enabled - - const urlProvider = vi.mocked(ReconnectableWebSocket).mock.calls[0]![0] as any - const ws = vi.mocked(ReconnectableWebSocket).mock.results[0]!.value - ws.send.mockClear() - - const url1 = await urlProvider() - const token1 = getClientDurableIteratorToken(output) - expect(token1).toBeTypeOf('string') - expect(durableIteratorHandler).toHaveBeenCalledTimes(1) - - vi.advanceTimersByTime(500) // not expired yet - expect(vi.getTimerCount()).toBe(1) // no refresh executed - expect(await urlProvider()).toEqual(url1) - expect(getClientDurableIteratorToken(output)).toEqual(token1) - expect(durableIteratorHandler).toHaveBeenCalledTimes(1) - - vi.advanceTimersByTime(500) // expired - expect(vi.getTimerCount()).toBe(0) // refresh token executed - await new Promise(r => realSetTimeout(r, 10)) // wait for token refresh promise - const url2 = await urlProvider() - expect(url2).not.toEqual(url1) - const token2 = getClientDurableIteratorToken(output) - expect(token2).not.toEqual(token1) - expect(durableIteratorHandler).toHaveBeenCalledTimes(2) - expect(ws.send).toHaveBeenCalledTimes(1) // send set token request to durable iterator - expect(vi.getTimerCount()).toBe(1) // new timer started - - vi.advanceTimersByTime(2000) // wait next retry + refreshTokenDelayInSeconds delay - expect(vi.getTimerCount()).toBe(0) // refresh token executed - await new Promise(r => realSetTimeout(r, 10)) // wait for token refresh promise - const url3 = await urlProvider() - expect(url3).not.toEqual(url1) - expect(url3).not.toEqual(url2) - const token3 = getClientDurableIteratorToken(output) - expect(token3).not.toEqual(token1) - expect(token3).not.toEqual(token2) - expect(durableIteratorHandler).toHaveBeenCalledTimes(3) - expect(ws.send).toHaveBeenCalledTimes(2) // send set token request to durable iterator - expect(vi.getTimerCount()).toBe(1) // new timer started - - expect(refreshTokenBeforeExpireInSeconds).toHaveBeenCalledTimes(3) - expect(refreshTokenBeforeExpireInSeconds).toHaveBeenCalledWith( - parseDurableIteratorToken(new URL(url1).searchParams.get(DURABLE_ITERATOR_TOKEN_PARAM)!), - expect.objectContaining({ path: ['durableIterator'] }), - ) - expect(refreshTokenDelayInSeconds).toHaveBeenCalledTimes(3) - expect(refreshTokenDelayInSeconds).toHaveBeenCalledWith( - parseDurableIteratorToken(new URL(url1).searchParams.get(DURABLE_ITERATOR_TOKEN_PARAM)!), - expect.objectContaining({ path: ['durableIterator'] }), - ) - - await output.return() // cleanup - }) - - it('not refresh if option returns NaN', async () => { - refreshTokenBeforeExpireInSeconds.mockImplementation(() => Number.NaN) - durableIteratorHandler.mockImplementation( - () => new DurableIterator('some-room', { - signingKey: 'signing-key', - tokenTTLSeconds: 1, - }) as any, - ) - - const outputPromise = link.call(['durableIterator'], {}, { context: {} }) as any - - await vi.waitFor(async () => { - expect(ReconnectableWebSocket).toHaveBeenCalledOnce() - - const ws = vi.mocked(ReconnectableWebSocket).mock.results[0]!.value - const [id] = await decodeRequestMessage(ws.send.mock.calls[0][0]) - - const [, onMessage] = vi.mocked(ws.addEventListener).mock.calls.find(([event]: any[]) => event === 'message') as any - - onMessage({ - data: await encodeResponseMessage(id, MessageType.RESPONSE, { - body: (async function* () { }()), - status: 200, - headers: {}, - }), - }) - }) - - const output = await outputPromise - expect(output).toSatisfy(isAsyncIteratorObject) - - expect(vi.getTimerCount()).toBe(0) // refresh token is disabled - - await output.return() // cleanup - }) - - it('if refresh token is invalid', async () => { - refreshTokenBeforeExpireInSeconds.mockImplementation(() => 9) - durableIteratorHandler.mockImplementationOnce( - () => new DurableIterator('some-room', { - signingKey: 'signing-key', - tokenTTLSeconds: 10, - }) as any, - ) - - const outputPromise = link.call(['durableIterator'], {}, { context: {} }) as any - - await vi.waitFor(async () => { - expect(ReconnectableWebSocket).toHaveBeenCalledOnce() - - const ws = vi.mocked(ReconnectableWebSocket).mock.results[0]!.value - const [id] = await decodeRequestMessage(ws.send.mock.calls[0][0]) - - const [, onMessage] = vi.mocked(ws.addEventListener).mock.calls.find(([event]: any[]) => event === 'message') as any - - onMessage({ - data: await encodeResponseMessage(id, MessageType.RESPONSE, { - body: (async function* () { }()), - status: 200, - headers: {}, - }), - }) - }) - - const output = await outputPromise - expect(output).toSatisfy(isAsyncIteratorObject) - - expect(vi.getTimerCount()).toBe(1) // refresh token is enabled - - const urlProvider = vi.mocked(ReconnectableWebSocket).mock.calls[0]![0] as any - - const url = await urlProvider() - expect(durableIteratorHandler).toHaveBeenCalledTimes(1) - - durableIteratorHandler.mockResolvedValueOnce('invalid-token' as any) - vi.advanceTimersByTime(1000) // wait first retry trigger - expect(vi.getTimerCount()).toBe(0) // refresh token executed - await new Promise(resolve => realSetTimeout(resolve, 10)) // wait for token refresh promise - await expect(urlProvider()).resolves.toBe(url) // not change url because new token is invalid - expect(durableIteratorHandler).toHaveBeenCalledTimes(2) - expect(vi.getTimerCount()).toBe(1) // timer created by retry helper - - durableIteratorHandler.mockResolvedValueOnce({} as any) - vi.advanceTimersByTime(2000) // wait next retry - expect(vi.getTimerCount()).toBe(0) // refresh token executed - await new Promise(resolve => realSetTimeout(resolve, 10)) // wait for token refresh promise - await expect(urlProvider()).resolves.toBe(url) // not change url because new token is invalid - expect(durableIteratorHandler).toHaveBeenCalledTimes(3) - expect(vi.getTimerCount()).toBe(1) // timer created by retry helper - - // only called once, because it still retrying after invalid token - expect(refreshTokenBeforeExpireInSeconds).toHaveBeenCalledTimes(1) - - const unhandledRejectionHandler = vi.fn() - process.on('unhandledRejection', unhandledRejectionHandler) - afterEach(() => { - process.off('unhandledRejection', unhandledRejectionHandler) - }) - - // .return should stop retry token - in case invalid token returns - durableIteratorHandler.mockImplementation(async () => { - await sleep(2000) - return {} as any - }) - vi.advanceTimersByTime(2000) // wait next retry - expect(vi.getTimerCount()).toBe(0) // refresh token executed - await new Promise(resolve => realSetTimeout(resolve, 10)) // wait for token refresh trigger - await output.return() // cleanup - - vi.advanceTimersByTime(2000) // wait handler throw - await new Promise(resolve => realSetTimeout(resolve, 10)) // wait for token refresh reject - expect(unhandledRejectionHandler).toHaveBeenCalledTimes(1) - expect(unhandledRejectionHandler.mock.calls[0]![0]).toEqual( - new DurableIteratorError(`Expected valid token for procedure durableIterator`), - ) - }) - - it('reconnect if refresh token channel mismatch', async () => { - refreshTokenBeforeExpireInSeconds.mockImplementation(() => 9) - durableIteratorHandler.mockImplementationOnce( - () => new DurableIterator('some-room', { - signingKey: 'signing-key', - tokenTTLSeconds: 10, - }) as any, - ) - - const outputPromise = link.call(['durableIterator'], {}, { context: {} }) as any - - await vi.waitFor(async () => { - expect(ReconnectableWebSocket).toHaveBeenCalledOnce() - - const ws = vi.mocked(ReconnectableWebSocket).mock.results[0]!.value - const [id] = await decodeRequestMessage(ws.send.mock.calls[0][0]) - - const [, onMessage] = vi.mocked(ws.addEventListener).mock.calls.find(([event]: any[]) => event === 'message') as any - - onMessage({ - data: await encodeResponseMessage(id, MessageType.RESPONSE, { - body: (async function* () { }()), - status: 200, - headers: {}, - }), - }) - }) - - const output = await outputPromise - expect(output).toSatisfy(isAsyncIteratorObject) - - expect(vi.getTimerCount()).toBe(1) // refresh token is enabled - - durableIteratorHandler.mockResolvedValueOnce( - new DurableIterator('a-different-channel', { signingKey: 'signing-key' }).rpc('getUser', 'sendMessage') as any, - ) - - vi.advanceTimersByTime(1000) - expect(vi.getTimerCount()).toBe(0) // refresh token executed - await new Promise(resolve => realSetTimeout(resolve, 10)) // wait for token refresh promise - const ws = vi.mocked(ReconnectableWebSocket).mock.results[0]!.value - expect(ws.reconnect).toHaveBeenCalledTimes(1) - expect(await (ReconnectableWebSocket as any).mock.calls[0]![0]()).toContain('a-different-channel') - expect(vi.getTimerCount()).toBe(1) // new refresh token timer created - - await output.return() // cleanup - }) - - it('reconnect if refresh token tags mismatch', async () => { - refreshTokenBeforeExpireInSeconds.mockImplementation(() => 9) - durableIteratorHandler.mockImplementationOnce( - () => new DurableIterator('some-room', { - tags: ['tag'], - signingKey: 'signing-key', - tokenTTLSeconds: 10, - }) as any, - ) - - const outputPromise = link.call(['durableIterator'], {}, { context: {} }) as any - - await vi.waitFor(async () => { - expect(ReconnectableWebSocket).toHaveBeenCalledOnce() - - const ws = vi.mocked(ReconnectableWebSocket).mock.results[0]!.value - const [id] = await decodeRequestMessage(ws.send.mock.calls[0][0]) - - const [, onMessage] = vi.mocked(ws.addEventListener).mock.calls.find(([event]: any[]) => event === 'message') as any - - onMessage({ - data: await encodeResponseMessage(id, MessageType.RESPONSE, { - body: (async function* () { }()), - status: 200, - headers: {}, - }), - }) - }) - - const output = await outputPromise - expect(output).toSatisfy(isAsyncIteratorObject) - - expect(vi.getTimerCount()).toBe(1) // refresh token is enabled - - durableIteratorHandler.mockImplementationOnce( - () => new DurableIterator('some-room', { - tags: ['a-different-tag'], - signingKey: 'signing-key', - tokenTTLSeconds: 10, - }) as any, - ) - - vi.advanceTimersByTime(1000) - expect(vi.getTimerCount()).toBe(0) // refresh token executed - await new Promise(resolve => realSetTimeout(resolve, 10)) // wait for token refresh promise - const ws = vi.mocked(ReconnectableWebSocket).mock.results[0]!.value - expect(ws.reconnect).toHaveBeenCalledTimes(1) - expect(await (ReconnectableWebSocket as any).mock.calls[0]![0]()).toContain('a-different-tag') - expect(vi.getTimerCount()).toBe(1) // new refresh token timer created - - await output.return() // cleanup - }) - }) - - it('throw right away if signal is aborted before establishing websocket connection', async () => { - const controller = new AbortController() - const signal = controller.signal - - durableIteratorHandler.mockImplementationOnce( - () => { - controller.abort() // abort during fetch token before connection is established - return new DurableIterator('some-room', { signingKey: 'signing-key' }) as any - }, - ) - - await expect(link.call(['durableIterator'], {}, { context: {}, signal })).rejects.toThrow(signal.reason) - }) - - it('cancel websocket if signal is aborted', async () => { - const controller = new AbortController() - const signal = controller.signal - - const outputPromise = link.call(['durableIterator'], {}, { context: {}, signal }) as any - - await vi.waitFor(async () => { - const ws = vi.mocked(ReconnectableWebSocket).mock.results[0]!.value - const [id] = await decodeRequestMessage(ws.send.mock.calls[0][0]) - - const [, onMessage] = vi.mocked(ws.addEventListener).mock.calls.find(([event]: any[]) => event === 'message') as any - - onMessage({ - data: await encodeResponseMessage(id, MessageType.RESPONSE, { - body: (async function* () { }()), - status: 200, - headers: {}, - }), - }) - }) - - await outputPromise - - const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout') - controller.abort() - - const ws = vi.mocked(ReconnectableWebSocket).mock.results[0]!.value - expect(ws.close).toHaveBeenCalledTimes(1) - expect(clearTimeoutSpy).toHaveBeenCalledTimes(1) - }) - - it('cancel websocket if iterator return is called', async () => { - const controller = new AbortController() - const signal = controller.signal - const removeEventListenerSpy = vi.spyOn(signal, 'removeEventListener') - - const outputPromise = link.call(['durableIterator'], {}, { context: {}, signal }) as any - - await vi.waitFor(async () => { - const ws = vi.mocked(ReconnectableWebSocket).mock.results[0]!.value - const [id] = await decodeRequestMessage(ws.send.mock.calls[0][0]) - - const [, onMessage] = vi.mocked(ws.addEventListener).mock.calls.find(([event]: any[]) => event === 'message') as any - - onMessage({ - data: await encodeResponseMessage(id, MessageType.RESPONSE, { - body: (async function* () { }()), - status: 200, - headers: {}, - }), - }) - }) - - const output = await outputPromise as any - - const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout') - await output.return() - - const ws = vi.mocked(ReconnectableWebSocket).mock.results[0]!.value - expect(ws.close).toHaveBeenCalledTimes(1) - - expect(removeEventListenerSpy).toHaveBeenCalledTimes(1) - expect(removeEventListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function)) // ensure it cleanups the event listener - - expect(clearTimeoutSpy).toHaveBeenCalledTimes(1) - }) -}) diff --git a/packages/durable-iterator/src/client/plugin.ts b/packages/durable-iterator/src/client/plugin.ts deleted file mode 100644 index 9df73d315..000000000 --- a/packages/durable-iterator/src/client/plugin.ts +++ /dev/null @@ -1,273 +0,0 @@ -import type { ClientContext, ClientLink } from '@orpc/client' -import type { ClientRetryPluginContext } from '@orpc/client/plugins' -import type { StandardLinkInterceptorOptions, StandardLinkOptions, StandardLinkPlugin } from '@orpc/client/standard' -import type { RPCLinkOptions } from '@orpc/client/websocket' -import type { ContractRouterClient } from '@orpc/contract' -import type { Promisable, Value } from '@orpc/shared' -import type { durableIteratorContract } from '../contract' -import type { DurableIteratorTokenPayload } from '../schemas' -import { createORPCClient } from '@orpc/client' -import { ClientRetryPlugin } from '@orpc/client/plugins' -import { RPCLink } from '@orpc/client/websocket' -import { AsyncIteratorClass, fallback, retry, stringifyJSON, toArray, value } from '@orpc/shared' -import { WebSocket as ReconnectableWebSocket } from 'partysocket' -import { DURABLE_ITERATOR_ID_PARAM, DURABLE_ITERATOR_PLUGIN_HEADER_KEY, DURABLE_ITERATOR_PLUGIN_HEADER_VALUE, DURABLE_ITERATOR_TOKEN_PARAM } from '../consts' -import { DurableIteratorError } from '../error' -import { parseDurableIteratorToken } from '../schemas' -import { createClientDurableIterator } from './iterator' - -export interface DurableIteratorLinkPluginContext { - isDurableIteratorResponse?: boolean -} - -export interface DurableIteratorLinkPluginOptions extends Omit, 'websocket'> { - /** - * The WebSocket URL to connect to the Durable Iterator Object. - */ - url: Value, [tokenPayload: DurableIteratorTokenPayload, options: StandardLinkInterceptorOptions]> - - /** - * Generates a unique, unguessable websocket identifier. - * - * This ID is attached to the WebSocket connection so the server can - * recognize the same client across reconnects. It is called **once per client** - * - * @remarks - * - Use a strong random generator to avoid collisions or predictable IDs. - * - * @default (() => crypto.randomUUID()) - */ - createId?: (tokenPayload: DurableIteratorTokenPayload, options: StandardLinkInterceptorOptions) => Promisable - - /** - * Refresh the token this many seconds before it expires. - * - * @remarks - * - Pick a value larger than the expected token refresh time, network latency, and retry on failure - * to ensure a seamless refresh without reconnecting the WebSocket. - * - 300 seconds (5 minutes) is typically enough; 600 seconds (10 minutes) is safer - * - Use a infinite value to disable refreshing - * - * @default NaN (disabled) - */ - refreshTokenBeforeExpireInSeconds?: Value, [tokenPayload: DurableIteratorTokenPayload, options: StandardLinkInterceptorOptions]> - - /** - * Minimum delay between token refresh attempts. - * - * @default 2 (seconds) - */ - refreshTokenDelayInSeconds?: Value, [tokenPayload: DurableIteratorTokenPayload, options: StandardLinkInterceptorOptions]> -} - -/** - * @see {@link https://orpc.dev/docs/integrations/durable-iterator Durable Iterator Integration} - */ -export class DurableIteratorLinkPlugin implements StandardLinkPlugin { - readonly CONTEXT_SYMBOL = Symbol('ORPC_DURABLE_ITERATOR_LINK_PLUGIN_CONTEXT') - - /** - * run before (modify result after) retry plugin because it can break the special iterator - */ - order = 1_500_000 - - private readonly url: DurableIteratorLinkPluginOptions['url'] - private readonly createId: Exclude['createId'], undefined> - private readonly refreshTokenBeforeExpireInSeconds: Exclude['refreshTokenBeforeExpireInSeconds'], undefined> - private readonly refreshTokenDelayInSeconds: Exclude['refreshTokenDelayInSeconds'], undefined> - private readonly linkOptions: Omit, 'websocket'> - - constructor({ url, refreshTokenBeforeExpireInSeconds, refreshTokenDelayInSeconds, ...options }: DurableIteratorLinkPluginOptions) { - this.url = url - this.createId = fallback(options.createId, () => crypto.randomUUID()) - this.refreshTokenBeforeExpireInSeconds = fallback(refreshTokenBeforeExpireInSeconds, Number.NaN) - this.refreshTokenDelayInSeconds = fallback(refreshTokenDelayInSeconds, 2) - this.linkOptions = options - } - - init(options: StandardLinkOptions): void { - options.interceptors ??= [] - options.clientInterceptors ??= [] - - options.interceptors.push(async (options) => { - const pluginContext: DurableIteratorLinkPluginContext = {} - - const next = () => options.next({ - ...options, - context: { - [this.CONTEXT_SYMBOL]: pluginContext, - ...options.context, - }, - }) - - const output = await next() - - if (!pluginContext.isDurableIteratorResponse) { - return output - } - - /** - * Estimate a websocket connection take time, and `abort` is not fire if signal already aborted - * So we should throw if signal already aborted here - */ - options?.signal?.throwIfAborted() - - let isFinished = false // use this for cleanup logic - - let tokenAndPayload = this.validateToken(output, options.path) - const id = await this.createId(tokenAndPayload.payload, options) - const websocket = new ReconnectableWebSocket(async () => { - const url = new URL(await value(this.url, tokenAndPayload.payload, options)) - url.searchParams.append(DURABLE_ITERATOR_ID_PARAM, id) - url.searchParams.append(DURABLE_ITERATOR_TOKEN_PARAM, tokenAndPayload.token) - return url.toString() - }) - - const durableClient: ContractRouterClient - = createORPCClient(new RPCLink({ - ...this.linkOptions, - websocket, - plugins: [ - ...toArray(this.linkOptions.plugins), - new ClientRetryPlugin(), - ], - })) - - let refreshTokenBeforeExpireTimeoutId: ReturnType | undefined - const refreshTokenBeforeExpire = async () => { - const beforeSeconds = await value(this.refreshTokenBeforeExpireInSeconds, tokenAndPayload.payload, options) - const delayMilliseconds = await value(this.refreshTokenDelayInSeconds, tokenAndPayload.payload, options) * 1000 - - // stop refreshing if already finished - if (isFinished || !Number.isFinite(beforeSeconds)) { - return - } - - refreshTokenBeforeExpireTimeoutId = setTimeout( - async () => { - // retry until success or finished - const newTokenAndPayload = await retry({ times: Number.POSITIVE_INFINITY, delay: delayMilliseconds }, async (exit) => { - try { - const output = await next() - return this.validateToken(output, options.path) - } - catch (err) { - if (isFinished) { - exit(err) - } - - throw err - } - }) - - const canProactivelyUpdateToken - = newTokenAndPayload.payload.chn === tokenAndPayload.payload.chn - && stringifyJSON(newTokenAndPayload.payload.tags) === stringifyJSON(tokenAndPayload.payload.tags) - - tokenAndPayload = newTokenAndPayload - await refreshTokenBeforeExpire() // recursively call - - /** - * The next refresh cycle doesn't depend on the logic below, - * so we place it last to avoid interfering with recursion. - */ - if (canProactivelyUpdateToken) { - /** - * Proactively update the token before expiration - * to avoid reconnecting when the old token expires. - */ - await durableClient.updateToken({ token: tokenAndPayload.token }) - } - else { - /** - * Proactive update requires the same channel and tags. - * If they differ, we must reconnect instead to make new token effective. - */ - websocket.reconnect() - } - }, - Math.max( - refreshTokenBeforeExpireTimeoutId === undefined ? 0 : delayMilliseconds, - ((tokenAndPayload.payload.exp - beforeSeconds) * 1000) - Date.now(), - ), - ) - } - refreshTokenBeforeExpire() - - const closeConnection = () => { - isFinished = true - clearTimeout(refreshTokenBeforeExpireTimeoutId) - websocket.close() - } - - options?.signal?.addEventListener('abort', closeConnection, { once: true }) - - const iterator_ = await durableClient.subscribe(undefined, { - context: { - retry: Number.POSITIVE_INFINITY, - }, - lastEventId: options.lastEventId, // we can use user provided lastEventId for initial connection - }) - const cancelableIterator = new AsyncIteratorClass( - () => iterator_.next(), - async () => { - /** - * Durable iterator design for long-lived connections - * so if user trying to abort the iterator, we should close entire connection - */ - closeConnection() - /** - * prevent memory leak in case signal is reused for another purpose - */ - options.signal?.removeEventListener('abort', closeConnection) - }, - ) - - const link: ClientLink = { - call(path, input, options) { - return durableClient.call({ - path: path as [string, ...string[]], // safely cast, server will validate later - input, - }, options) - }, - } - - const durableIterator = createClientDurableIterator( - cancelableIterator, - link, - { - getToken: () => tokenAndPayload.token, - }, - ) - - return durableIterator - }) - - options.clientInterceptors.push(async (options) => { - const pluginContext = options.context[this.CONTEXT_SYMBOL] as DurableIteratorLinkPluginContext | undefined - - if (!pluginContext) { - throw new DurableIteratorError('Plugin context has been corrupted or modified by another plugin or interceptor') - } - - const response = await options.next() - - pluginContext.isDurableIteratorResponse = response.headers[DURABLE_ITERATOR_PLUGIN_HEADER_KEY] === DURABLE_ITERATOR_PLUGIN_HEADER_VALUE - - return response - }) - } - - private validateToken(token: unknown, path: readonly string[]): { token: string, payload: DurableIteratorTokenPayload } { - if (typeof token !== 'string') { - throw new DurableIteratorError(`Expected valid token for procedure ${path.join('.')}`) - } - - try { - return { token, payload: parseDurableIteratorToken(token) } - } - catch (error) { - throw new DurableIteratorError(`Expected valid token for procedure ${path.join('.')}`, { cause: error }) - } - } -} diff --git a/packages/durable-iterator/src/consts.ts b/packages/durable-iterator/src/consts.ts deleted file mode 100644 index 321e741ee..000000000 --- a/packages/durable-iterator/src/consts.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const DURABLE_ITERATOR_TOKEN_PARAM = 'token' as const -export const DURABLE_ITERATOR_ID_PARAM = 'id' as const -export const DURABLE_ITERATOR_PLUGIN_HEADER_KEY = 'x-orpc-durable-iterator' as const -export const DURABLE_ITERATOR_PLUGIN_HEADER_VALUE = '1' as const diff --git a/packages/durable-iterator/src/contract.ts b/packages/durable-iterator/src/contract.ts deleted file mode 100644 index 619b9e454..000000000 --- a/packages/durable-iterator/src/contract.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { AsyncIteratorClass } from '@orpc/shared' -import { oc, type } from '@orpc/contract' -import * as v from 'valibot' - -export const durableIteratorContract = { - updateToken: oc - .input(v.object({ token: v.string() })) - .route({ summary: 'Update old token with a new one, usually before it expires' }), - subscribe: oc - .route({ summary: 'Listen to durable iterator events' }) - .output(type>()), - call: oc - .route({ summary: 'Call a remote method' }) - .input( - v.object({ - path: v.tupleWithRest([v.string()], v.string()), - input: v.unknown(), - }), - ), -} diff --git a/packages/durable-iterator/src/durable-object/handler.ts b/packages/durable-iterator/src/durable-object/handler.ts deleted file mode 100644 index 9cfea2919..000000000 --- a/packages/durable-iterator/src/durable-object/handler.ts +++ /dev/null @@ -1,339 +0,0 @@ -import type { Client } from '@orpc/client' -import type { RPCHandlerOptions } from '@orpc/server/websocket' -import type { DurableObject } from 'cloudflare:workers' -import type { DurableIteratorObjectDef } from '../object' -import type { DurableIteratorObjectState } from './object-state' -import type { EventResumeStorageOptions } from './resume-storage' -import type { DurableIteratorWebsocket } from './websocket' -import { implement, ORPCError } from '@orpc/server' -import { encodeHibernationRPCEvent, HibernationEventIterator, HibernationPlugin } from '@orpc/server/hibernation' -import { RPCHandler } from '@orpc/server/websocket' -import { get, stringifyJSON, toArray } from '@orpc/shared' -import { DURABLE_ITERATOR_ID_PARAM, DURABLE_ITERATOR_TOKEN_PARAM } from '../consts' -import { durableIteratorContract } from '../contract' -import { verifyDurableIteratorToken } from '../schemas' -import { toDurableIteratorObjectState } from './object-state' -import { EventResumeStorage } from './resume-storage' -import { toDurableIteratorWebsocket } from './websocket' - -const os = implement(durableIteratorContract) - -type DurableIteratorObjectRouterContext = { - object: DurableObject - resumeStorage: EventResumeStorage - websocket: DurableIteratorWebsocket - options: DurableIteratorObjectHandlerOptions -} - -const base = os.$context() - -const router = base.router({ - updateToken: base.updateToken.handler(async ({ context, input }) => { - const payload = await verifyDurableIteratorToken(context.options.signingKey, input.token) - - if (!payload) { - throw new ORPCError('UNAUTHORIZED', { message: 'Invalid Token' }) - } - - const old = context.websocket['~orpc'].deserializeTokenPayload() - - if (payload.chn !== old.chn) { - throw new ORPCError('UNAUTHORIZED', { message: 'Updated token must have the same channel with the original token' }) - } - - if (stringifyJSON(payload.tags) !== stringifyJSON(old.tags)) { - throw new ORPCError('UNAUTHORIZED', { message: 'Updated token must have the exact same tags with the original token' }) - } - - context.websocket['~orpc'].serializeTokenPayload(payload) - }), - subscribe: base.subscribe.handler(({ context, lastEventId }) => { - return new HibernationEventIterator((hibernationId) => { - context.websocket['~orpc'].serializeHibernationId(hibernationId) - - if (typeof lastEventId === 'string') { - const resumePayloads = context.resumeStorage.get(context.websocket, lastEventId) - - try { - for (const payload of resumePayloads) { - context.websocket.send( - encodeHibernationRPCEvent(hibernationId, payload, context.options), - ) - } - } - catch { - // ignore sending errors (probably already closed or expired) - } - } - - context.options.onSubscribed?.(context.websocket, lastEventId) - }) - }), - - call: base.call.handler(({ context, input, signal, lastEventId }) => { - const allowMethods = context.websocket['~orpc'].deserializeTokenPayload().rpc - const [method, ...path] = input.path - - if (!allowMethods?.includes(method)) { - throw new ORPCError('FORBIDDEN', { - message: `Method "${method}" is not allowed.`, - }) - } - - const nestedClient = (context.object as any)[method](context.websocket) - - const client = get(nestedClient, path) as Client - - return client(input.input, { signal, lastEventId }) - }), -}) - -export interface PublishEventOptions { - /** - * Deliver the event only to websockets that have the specified tags. - */ - tags?: readonly string[] - - /** - * Restrict the event to a specific set of websockets. - * - * Accept a list of websockets or a filter function. - * - * Use this when security is important — only the listed websockets - * will ever receive the event. Newly connected websockets are not - * included unless explicitly added here. - */ - targets?: readonly WebSocket[] | ((ws: DurableIteratorWebsocket) => boolean) - - /** - * Exclude certain websockets from receiving the event. - * - * Accept a list of websockets or a filter function. - * - * Use this when broadcasting widely but skipping a few clients - * (e.g., the sender). Newly connected websockets may still receive - * the event if not listed here, so this is less strict than `targets`. - */ - exclude?: readonly WebSocket[] | ((ws: DurableIteratorWebsocket) => boolean) -} - -export interface DurableIteratorObjectHandlerOptions extends RPCHandlerOptions, EventResumeStorageOptions { - /** - * The signing key to use verify the token. - */ - signingKey: string - - /** - * Called after a client successfully subscribes to the main iterator. - * You can start sending events to the client here. - * - * @param websocket Corresponding WebSocket connection. - * @param lastEventId Can be `undefined` if this is the first connection (not a resumed session). - */ - onSubscribed?: (websocket: DurableIteratorWebsocket, lastEventId: string | undefined) => void -} - -export class DurableIteratorObjectHandler< - T extends object, - TProps, -> implements DurableIteratorObjectDef { - '~eventPayloadType'?: { type: T } // Helps DurableIteratorObjectDef infer the type - - private readonly handler: RPCHandler - private readonly resumeStorage: EventResumeStorage - - /** - * Proxied, ensure you don't accidentally change internal state, and auto close if expired websockets before .send is called - */ - ctx: DurableIteratorObjectState - - constructor( - ctx: DurableObjectState, - private readonly object: DurableObject, - private readonly options: DurableIteratorObjectHandlerOptions, - ) { - this.ctx = toDurableIteratorObjectState(ctx) - - this.resumeStorage = new EventResumeStorage(ctx, options) - - this.handler = new RPCHandler(router, { - ...options, - plugins: [ - ...toArray(options.plugins), - new HibernationPlugin(), - ], - }) - - /** - * Optional, but this is a good place to close expired websockets - * since it happens before anything else is processed. - */ - this.ctx.getWebSockets().forEach(ws => ws['~orpc'].closeIfExpired()) - } - - /** - * Publish an event to a set of clients. - */ - publishEvent(payload: T, options: PublishEventOptions = {}): void { - let targets = Array.isArray(options.targets) - ? (options.targets as readonly WebSocket[]).map(toDurableIteratorWebsocket) - : undefined - - const websocketsFilteredByTags = (() => { - if (targets) { - const uniqueTargets = targets.filter((ws, index) => { - const id = ws['~orpc'].deserializeId() - return targets?.findIndex(ws => ws['~orpc'].deserializeId() === id) === index - }) - - if (!options.tags) { - return uniqueTargets - } - - return uniqueTargets.filter( - ws => ws['~orpc'].deserializeTokenPayload().tags?.some(tag => options.tags?.includes(tag)), - ) - } - - if (options.tags) { - const websockets = options.tags - .map(tag => this.ctx.getWebSockets(tag)) - .flat() - - const uniqueWebsockets = websockets.filter((ws, index) => { - const id = ws['~orpc'].deserializeId() - return websockets.findIndex(ws => ws['~orpc'].deserializeId() === id) === index - }) - - return uniqueWebsockets - } - else { - return this.ctx.getWebSockets() - } - })() - - if (typeof options.targets === 'function') { - targets = websocketsFilteredByTags.filter(options.targets) - } - - const exclude = Array.isArray(options.exclude) - ? (options.exclude as readonly WebSocket[]).map(toDurableIteratorWebsocket) - : typeof options.exclude === 'function' - ? websocketsFilteredByTags.filter(options.exclude) - : undefined - - // update payload metadata - payload = this.resumeStorage.store(payload, { tags: options.tags, targets, exclude }) - - const targetIds = targets?.map(ws => ws['~orpc'].deserializeId()) - const excludeIds = exclude?.map(ws => ws['~orpc'].deserializeId()) - - for (const ws of websocketsFilteredByTags) { - const wsId = ws['~orpc'].deserializeId() - - if (targetIds && !targetIds.includes(wsId)) { - continue - } - - if (excludeIds?.includes(wsId)) { - continue - } - - const hibernationId = ws['~orpc'].deserializeHibernationId() - - // Maybe the connection not finished the subscription process yet - if (typeof hibernationId !== 'string') { - continue - } - - const data = encodeHibernationRPCEvent(hibernationId, payload, this.options) - - try { - ws.send(data) - } - catch { - // ignore sending errors (probably already closed or expired) - } - } - } - - /** - * This method is called when a HTTP request is received for upgrading to a WebSocket connection. - * Should mapping with corresponding `fetch` inside durable object - */ - async fetch(request: Request): Promise { - const url = new URL(request.url) - const token = url.searchParams.getAll(DURABLE_ITERATOR_TOKEN_PARAM).at(-1) - const id = url.searchParams.getAll(DURABLE_ITERATOR_ID_PARAM).at(-1) - - if (typeof id !== 'string') { - return new Response('ID is required', { status: 401 }) - } - - if (typeof token !== 'string') { - return new Response('Token is required', { status: 401 }) - } - - const payload = await verifyDurableIteratorToken(this.options.signingKey, token) - - if (!payload) { - return new Response('Invalid Token', { status: 401 }) - } - - const { '0': client, '1': server } = new WebSocketPair() - - if (payload.tags) { - this.ctx.acceptWebSocket(server, [...payload.tags]) - } - else { - this.ctx.acceptWebSocket(server) - } - - toDurableIteratorWebsocket(server)['~orpc'].serializeId(id) - toDurableIteratorWebsocket(server)['~orpc'].serializeTokenPayload(payload) - - return new Response(null, { - status: 101, - webSocket: client, - }) - } - - /** - * This method is called when a WebSocket message is received. - * Should mapping with corresponding `webSocketMessage` inside durable object - */ - async webSocketMessage(websocket_: WebSocket, message: string | ArrayBuffer): Promise { - const websocket = toDurableIteratorWebsocket(websocket_) - - websocket['~orpc'].closeIfExpired() - if (websocket.readyState !== WebSocket.OPEN) { - return - } - - // `websocket` auto close if expired on every send - await this.handler.message(websocket, message, { - context: { - websocket, - object: this.object, - resumeStorage: this.resumeStorage, - options: this.options, - }, - }) - } - - /** - * This method is called when a WebSocket connection is closed. - * Should mapping with corresponding `webSocketClose` inside durable object - */ - webSocketClose(ws_: WebSocket, _code: number, _reason: string, _wasClean: boolean): void | Promise { - const ws = toDurableIteratorWebsocket(ws_) - /** - * Since `webSocketMessage` operates on DurableIteratorWebSocket, - * we must also use DurableIteratorWebSocket to guarantee reference equality. - * The WebSocket adapter relies on this reference consistency. - * - * @info toDurableIteratorWebsocket will ensure the same input WebSocket always maps to the same DurableIteratorWebSocket - */ - this.handler.close(ws) - } -} diff --git a/packages/durable-iterator/src/durable-object/index.test.ts b/packages/durable-iterator/src/durable-object/index.test.ts deleted file mode 100644 index 61b2951e6..000000000 --- a/packages/durable-iterator/src/durable-object/index.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -vi.mock('cloudflare:workers', () => ({ - DurableObject: class { - constructor( - protected readonly ctx: any, - protected readonly env: unknown, - ) { } - }, -})) - -it('export something', async () => { - expect(Object.keys(await import('./index'))).toContain('DurableIteratorObject') -}) diff --git a/packages/durable-iterator/src/durable-object/index.ts b/packages/durable-iterator/src/durable-object/index.ts deleted file mode 100644 index efcb366f5..000000000 --- a/packages/durable-iterator/src/durable-object/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from './handler' -export * from './object' -export * from './object-state' -export * from './resume-storage' -export * from './upgrade' -export * from './websocket' - -export { withEventMeta } from '@orpc/server' diff --git a/packages/durable-iterator/src/durable-object/object-state.test.ts b/packages/durable-iterator/src/durable-object/object-state.test.ts deleted file mode 100644 index 315ed2ac3..000000000 --- a/packages/durable-iterator/src/durable-object/object-state.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { createCloudflareWebsocket, createDurableObjectState } from '../../tests/shared' -import { toDurableIteratorObjectState } from './object-state' -import * as websocketModule from './websocket' - -const toDurableIteratorWebsocketSpy = vi.spyOn(websocketModule, 'toDurableIteratorWebsocket') - -describe('toDurableIteratorObjectState', () => { - const ctx = createDurableObjectState() - const proxied = toDurableIteratorObjectState(ctx) as any - - it('proxy with additional ~orpc', () => { - expect('storage' in proxied).toBe(true) - expect(proxied.storage).toBeInstanceOf(Object) - - expect('waitUntil' in proxied).toBe(true) - expect(proxied.waitUntil).toBeInstanceOf(Function) - - expect('getWebSockets' in proxied).toBe(true) - expect(proxied.getWebSockets).toBeInstanceOf(Function) - - expect('~orpc' in proxied).toBe(true) - expect(proxied['~orpc']).toBeInstanceOf(Object) - expect(proxied['~orpc'].original).toBe(ctx) - }) - - it('proxied getWebSockets result', () => { - const ws1 = createCloudflareWebsocket() - const ws2 = createCloudflareWebsocket() - vi.mocked(ctx.getWebSockets as () => any).mockReturnValue([ws1, ws2]) - - expect(proxied.getWebSockets(1, 2, 3)).toEqual([ - toDurableIteratorWebsocketSpy.mock.results[0]?.value, - toDurableIteratorWebsocketSpy.mock.results[1]?.value, - ]) - - expect(ctx.getWebSockets).toHaveBeenCalledTimes(1) - expect(ctx.getWebSockets).toHaveBeenCalledWith(1, 2, 3) - - expect(toDurableIteratorWebsocketSpy).toHaveBeenCalledTimes(2) - expect(toDurableIteratorWebsocketSpy).toHaveBeenCalledWith(ws1) - expect(toDurableIteratorWebsocketSpy).toHaveBeenCalledWith(ws2) - }) - - it('not proxy again if already proxied', () => { - expect(toDurableIteratorObjectState(proxied)).toBe(proxied) - }) -}) diff --git a/packages/durable-iterator/src/durable-object/object-state.ts b/packages/durable-iterator/src/durable-object/object-state.ts deleted file mode 100644 index e045025a0..000000000 --- a/packages/durable-iterator/src/durable-object/object-state.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { DurableIteratorWebsocket } from './websocket' -import { toDurableIteratorWebsocket } from './websocket' - -export interface DurableIteratorObjectStateInternal { - /** - * The original DurableObjectState - * - * @warning Be careful when using original because you can accidentally modifying internal state. - */ - original: DurableObjectState -} - -export interface DurableIteratorObjectState extends DurableObjectState { - /** - * DurableIteratorObjectState internal apis - */ - '~orpc': DurableIteratorObjectStateInternal - - /** - * Get all WebSockets connected to this Durable Object - * And convert them to DurableIteratorWebsocket to avoid accidentally modifying internal state - */ - 'getWebSockets'(...args: Parameters): DurableIteratorWebsocket[] -} - -export function toDurableIteratorObjectState(original: DurableObjectState): DurableIteratorObjectState { - if ('~orpc' in original) { - return original as DurableIteratorObjectState - } - - const internal: DurableIteratorObjectStateInternal = { - original, - } - - const getWebSockets: DurableObjectState['getWebSockets'] = (...args) => { - return original.getWebSockets(...args).map(ws => toDurableIteratorWebsocket(ws)) - } - - const proxy = new Proxy(original, { - get(_, prop) { - if (prop === '~orpc') { - return internal - } - - if (prop === 'getWebSockets') { - return getWebSockets - } - - const v = Reflect.get(original, prop) - return typeof v === 'function' - ? v.bind(original) // Require .bind itself for calling - : v - }, - has(_, p) { - return p === '~orpc' || Reflect.has(original, p) - }, - }) - - return proxy as any -} diff --git a/packages/durable-iterator/src/durable-object/object.test.ts b/packages/durable-iterator/src/durable-object/object.test.ts deleted file mode 100644 index 83cc0b95c..000000000 --- a/packages/durable-iterator/src/durable-object/object.test.ts +++ /dev/null @@ -1,603 +0,0 @@ -import type { DurableIteratorTokenPayload } from '../schemas' -import { os } from '@orpc/server' -import { sleep } from '@orpc/shared' -import { encodeRequestMessage, encodeResponseMessage, MessageType } from '@orpc/standard-server-peer' -import { createCloudflareWebsocket, createDurableObjectState } from '../../tests/shared' -import { DURABLE_ITERATOR_ID_PARAM, DURABLE_ITERATOR_TOKEN_PARAM } from '../consts' -import { signDurableIteratorToken } from '../schemas' -import { DurableIteratorObject } from './object' -import { toDurableIteratorWebsocket } from './websocket' - -vi.mock('cloudflare:workers', () => ({ - DurableObject: class { - constructor( - protected readonly ctx: any, - protected readonly env: unknown, - ) { } - }, -})) - -beforeAll(() => { - (globalThis as any).WebSocketPair = vi.fn(() => ({ - 0: createCloudflareWebsocket(), - 1: createCloudflareWebsocket(), - })) - - const globalResponse = globalThis.Response - - ; (globalThis as any).Response = class extends globalResponse { - readonly __init: any - - constructor(input: any, init?: any) { - super(input, { - ...init, - status: 200, // avoid invalid status error - }) - - this.__init = init - } - } - - afterAll(() => { - ; (globalThis as any).WebSocketPair = undefined - ; (globalThis as any).Response = globalResponse - }) -}) - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('class DurableIteratorObject & DurableIteratorObjectHandler', async () => { - const ctx = createDurableObjectState() - const env = {} - const onSubscribed = vi.fn() - - const object = new DurableIteratorObject(ctx, env, { signingKey: 'secret', onSubscribed }) - - const resumeStoreStoreSpy = vi.spyOn((object['~orpc'] as any).resumeStorage, 'store') - const resumeStoreGetSpy = vi.spyOn((object['~orpc'] as any).resumeStorage, 'get') - - const baseTokenPayload = { - chn: 'channel', - exp: Math.floor(Date.now() / 1000) + 3600, - iat: Math.floor(Date.now() / 1000), - att: 'att', - rpc: ['method'], - tags: ['tag1', 'tag2'], - } satisfies DurableIteratorTokenPayload - - it('throw if not passed 3rd argument', () => { - expect(() => new (DurableIteratorObject as any)(ctx, env)).toThrow('Missing options') - }) - - it('auto close expired websockets on init', async () => { - const wsNormal = createCloudflareWebsocket() - toDurableIteratorWebsocket(wsNormal)['~orpc'].serializeTokenPayload(baseTokenPayload) - const wsExpired = createCloudflareWebsocket() - toDurableIteratorWebsocket(wsExpired)['~orpc'].serializeTokenPayload({ ...baseTokenPayload, exp: Math.floor(Date.now() / 1000) - 3600 }) - - ctx.getWebSockets.mockReturnValue([wsNormal, wsExpired]) - void new DurableIteratorObject(ctx, env, { signingKey: 'secret' }) - - expect(wsNormal.close).toHaveBeenCalledTimes(0) - expect(wsExpired.close).toHaveBeenCalledTimes(1) - }) - - describe('publishEvent', () => { - const ws1 = toDurableIteratorWebsocket(createCloudflareWebsocket()) - ws1['~orpc'].serializeId('ws-1') - ws1['~orpc'].serializeTokenPayload(baseTokenPayload) - ws1['~orpc'].serializeHibernationId('1') - - const ws2 = toDurableIteratorWebsocket(createCloudflareWebsocket()) - ws2['~orpc'].serializeId('ws-2') - ws2['~orpc'].serializeTokenPayload({ ...baseTokenPayload, tags: ['tag2'] }) - ws2['~orpc'].serializeHibernationId('1') - - const ws3 = toDurableIteratorWebsocket(createCloudflareWebsocket()) - ws3['~orpc'].serializeId('ws-3') - ws3['~orpc'].serializeTokenPayload({ ...baseTokenPayload, tags: undefined }) - ws3['~orpc'].serializeHibernationId('1') - - it('works', async () => { - const wsMissingHibernationId = toDurableIteratorWebsocket(createCloudflareWebsocket()) - wsMissingHibernationId['~orpc'].serializeId('wsMissingHibernationId') - wsMissingHibernationId['~orpc'].serializeTokenPayload(baseTokenPayload) - - ctx.getWebSockets.mockReturnValue([ws1, ws2, wsMissingHibernationId]) - - resumeStoreStoreSpy.mockReturnValue({ order: '__fromResumeStore__' }) - object.publishEvent({ order: 1 }) - - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws2['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(wsMissingHibernationId['~orpc'].original.send).toHaveBeenCalledTimes(0) - - expect(resumeStoreStoreSpy).toHaveBeenCalledTimes(1) - expect(resumeStoreStoreSpy).toHaveBeenCalledWith({ order: 1 }, {}) - - // send result from resume store - expect((ws1 as any)['~orpc'].original.send.mock.calls[0][0]).toContain(JSON.stringify({ order: '__fromResumeStore__' })) - expect((ws2 as any)['~orpc'].original.send.mock.calls[0][0]).toContain(JSON.stringify({ order: '__fromResumeStore__' })) - }) - - it('tags, targets, exclude options', async () => { - ctx.getWebSockets.mockImplementation((tag: string | undefined) => { - const wss = [ws1, ws2, ws3] - if (tag === undefined) { - return wss - } - return wss.filter(ws => ws['~orpc'].deserializeTokenPayload().tags?.includes(tag)) - }) - - object.publishEvent({ order: 1 }, { targets: [ws1] }) - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws2['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(ws3['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(resumeStoreStoreSpy).toHaveBeenCalledTimes(1) - expect(resumeStoreStoreSpy).toHaveBeenCalledWith({ order: 1 }, { targets: [ws1] }) - - vi.clearAllMocks() - object.publishEvent({ order: 2 }, { exclude: [ws1] }) - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(ws2['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws3['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(resumeStoreStoreSpy).toHaveBeenCalledTimes(1) - expect(resumeStoreStoreSpy).toHaveBeenCalledWith({ order: 2 }, { exclude: [ws1] }) - - vi.clearAllMocks() - object.publishEvent({ order: 3 }, { targets: [ws1], exclude: [ws2] }) - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws2['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(ws3['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(resumeStoreStoreSpy).toHaveBeenCalledTimes(1) - expect(resumeStoreStoreSpy).toHaveBeenCalledWith({ order: 3 }, { targets: [ws1], exclude: [ws2] }) - - vi.clearAllMocks() - object.publishEvent({ order: 4 }, { tags: ['tag1'] }) - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws2['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(ws3['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(resumeStoreStoreSpy).toHaveBeenCalledWith({ order: 4 }, { tags: ['tag1'] }) - - vi.clearAllMocks() - object.publishEvent({ order: 5 }, { tags: ['tag2'] }) - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws2['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws3['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(resumeStoreStoreSpy).toHaveBeenCalledWith({ order: 5 }, { tags: ['tag2'] }) - - vi.clearAllMocks() - object.publishEvent({ order: 6 }, { tags: ['tag2'], exclude: [ws1] }) - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(ws2['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws3['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(resumeStoreStoreSpy).toHaveBeenCalledWith({ order: 6 }, { tags: ['tag2'], exclude: [ws1] }) - - vi.clearAllMocks() - object.publishEvent({ order: 6 }, { tags: ['tag2'], targets: [ws1] }) - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws2['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(ws3['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(resumeStoreStoreSpy).toHaveBeenCalledWith({ order: 6 }, { tags: ['tag2'], targets: [ws1] }) - - vi.clearAllMocks() - const targets = vi.fn(ws => ws === ws1) - object.publishEvent({ order: 6 }, { tags: ['tag2'], targets }) - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws2['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(ws3['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(resumeStoreStoreSpy).toHaveBeenCalledWith({ order: 6 }, { tags: ['tag2'], targets: [ws1] }) - expect(targets).toHaveBeenCalledTimes(2) - - vi.clearAllMocks() - const exclude = vi.fn(ws => true) - object.publishEvent({ order: 6 }, { tags: ['tag2'], exclude }) - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(ws2['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(ws3['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(resumeStoreStoreSpy).toHaveBeenCalledWith({ order: 6 }, { tags: ['tag2'], exclude: [ws1, ws2] }) - expect(exclude).toHaveBeenCalledTimes(2) - - // unique before send - vi.clearAllMocks() - object.publishEvent({ order: 7 }, { tags: ['tag1', 'tag1', 'tag2', 'tag2', 'tag3', 'tag3'] }) - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws2['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws3['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(resumeStoreStoreSpy).toHaveBeenCalledWith({ order: 7 }, { tags: ['tag1', 'tag1', 'tag2', 'tag2', 'tag3', 'tag3'] }) - - // unique before send - vi.clearAllMocks() - object.publishEvent({ order: 8 }, { tags: ['tag1', 'tag1', 'tag2', 'tag2', 'tag3', 'tag3'], targets: [ws1, ws1, ws2, ws2, ws3, ws3], exclude: [ws2] }) - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws2['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(ws3['~orpc'].original.send).toHaveBeenCalledTimes(0) - expect(resumeStoreStoreSpy).toHaveBeenCalledWith({ order: 8 }, { tags: ['tag1', 'tag1', 'tag2', 'tag2', 'tag3', 'tag3'], targets: [ws1, ws1, ws2, ws2, ws3, ws3], exclude: [ws2] }) - }) - - it('ignore sending errors', async () => { - ctx.getWebSockets.mockReturnValue([ws1, ws2]) - - vi.mocked(ws1['~orpc'].original.send).mockImplementationOnce(() => { - throw new Error('error') - }) - - object.publishEvent({ order: 1 }) - - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws2['~orpc'].original.send).toHaveBeenCalledTimes(1) - }) - }) - - describe('upgrade websocket connection', async () => { - it('with tags', async () => { - const token = await signDurableIteratorToken('secret', baseTokenPayload) - - const url = new URL('https://example.com') - url.searchParams.set(DURABLE_ITERATOR_TOKEN_PARAM, token) - url.searchParams.set(DURABLE_ITERATOR_ID_PARAM, 'some-id') - - const response = await object.fetch(new Request(url)) - - expect((globalThis as any).WebSocketPair).toHaveBeenCalledTimes(1) - const { 0: client, 1: server } = (globalThis as any).WebSocketPair.mock.results[0].value - expect(ctx.acceptWebSocket).toHaveBeenCalledTimes(1) - expect(ctx.acceptWebSocket).toHaveBeenCalledWith(server, ['tag1', 'tag2']) - - expect(response).instanceOf(Response) - expect((response as any).__init.status).toBe(101) - expect((response as any).__init.webSocket).toBe(client) - - expect(toDurableIteratorWebsocket(server)['~orpc'].deserializeTokenPayload()).toEqual(baseTokenPayload) - }) - - it('without tags', async () => { - const payload = { ...baseTokenPayload, tags: undefined } - const token = await signDurableIteratorToken('secret', payload) - - const url = new URL('https://example.com') - url.searchParams.set(DURABLE_ITERATOR_TOKEN_PARAM, token) - url.searchParams.set(DURABLE_ITERATOR_ID_PARAM, 'some-id') - - const response = await object.fetch(new Request(url)) - - expect((globalThis as any).WebSocketPair).toHaveBeenCalledTimes(1) - const { 0: client, 1: server } = (globalThis as any).WebSocketPair.mock.results[0].value - expect(ctx.acceptWebSocket).toHaveBeenCalledTimes(1) - expect(ctx.acceptWebSocket).toHaveBeenCalledWith(server) - - expect(response).instanceOf(Response) - expect((response as any).__init.status).toBe(101) - expect((response as any).__init.webSocket).toBe(client) - - expect(toDurableIteratorWebsocket(server)['~orpc'].deserializeTokenPayload()).toEqual(payload) - }) - - it('reject if missing id', async () => { - const token = await signDurableIteratorToken('secret', baseTokenPayload) - const url = new URL('https://example.com') - url.searchParams.set(DURABLE_ITERATOR_TOKEN_PARAM, token) - - const response = await object.fetch(new Request(url)) - - expect((globalThis as any).WebSocketPair).toHaveBeenCalledTimes(0) - expect(ctx.acceptWebSocket).toHaveBeenCalledTimes(0) - - expect(response).instanceOf(Response) - expect((response as any).__init.status).toBe(401) - }) - - it('reject if missing token', async () => { - const url = new URL('https://example.com') - url.searchParams.set(DURABLE_ITERATOR_ID_PARAM, 'some-id') - const response = await object.fetch(new Request(url)) - - expect((globalThis as any).WebSocketPair).toHaveBeenCalledTimes(0) - expect(ctx.acceptWebSocket).toHaveBeenCalledTimes(0) - - expect(response).instanceOf(Response) - expect((response as any).__init.status).toBe(401) - }) - - it('reject if invalid token', async () => { - const url = new URL('https://example.com') - url.searchParams.set(DURABLE_ITERATOR_TOKEN_PARAM, 'invalid') - url.searchParams.set(DURABLE_ITERATOR_ID_PARAM, 'some-id') - - const response = await object.fetch(new Request(url)) - - expect((globalThis as any).WebSocketPair).toHaveBeenCalledTimes(0) - expect(ctx.acceptWebSocket).toHaveBeenCalledTimes(0) - - expect(response).instanceOf(Response) - expect((response as any).__init.status).toBe(401) - }) - }) - - it('auto close if expired on every websocket message arrive', async () => { - const ws1 = toDurableIteratorWebsocket(createCloudflareWebsocket()) - ws1['~orpc'].serializeTokenPayload({ ...baseTokenPayload, exp: -1 }) - vi.mocked(ws1['~orpc'].original.close).mockImplementationOnce(() => { - (ws1 as any).readyState = WebSocket.CLOSING - }) - - await object.webSocketMessage(ws1, 'message') - expect(ws1['~orpc'].original.close).toHaveBeenCalledTimes(1) - }) - - describe('update token', () => { - const ws1 = toDurableIteratorWebsocket(createCloudflareWebsocket()) - ws1['~orpc'].serializeTokenPayload({ ...baseTokenPayload, rpc: ['signalClient'] }) - - it('works', async () => { - const payload1 = { ...baseTokenPayload, rpc: ['rpc-1'] } - const token1 = await signDurableIteratorToken('secret', payload1) - - await object.webSocketMessage(ws1, await encodeRequestMessage('id1', MessageType.REQUEST, { - url: new URL('http://localhost/updateToken'), - body: { json: { token: token1 } }, - headers: { }, - method: 'POST', - })) - - expect(ws1['~orpc'].original.send).toHaveBeenCalledWith(expect.toSatisfy((s: string) => { - return !s.includes('"code"') // code only exists in reject message - })) - expect(ws1['~orpc'].deserializeTokenPayload()).toEqual(payload1) - expect((ws1 as any)['~orpc'].original.serializeAttachment).toHaveBeenCalledTimes(1) - }) - - it('reject if invalid token', async () => { - const token = 'invalid' - await object.webSocketMessage(ws1, await encodeRequestMessage('id1', MessageType.REQUEST, { - url: new URL('http://localhost/updateToken'), - body: { json: { token } }, - headers: { }, - method: 'POST', - })) - - expect(ws1['~orpc'].original.send).toHaveBeenCalledWith(expect.toSatisfy((s: string) => { - return s.includes('"code":"UNAUTHORIZED"') - })) - expect((ws1 as any)['~orpc'].original.serializeAttachment).toHaveBeenCalledTimes(0) - }) - - it('reject if mismatched channel', async () => { - const payload = { ...baseTokenPayload, chn: 'a-different-one' } - const token = await signDurableIteratorToken('secret', payload) - - await object.webSocketMessage(ws1, await encodeRequestMessage('id1', MessageType.REQUEST, { - url: new URL('http://localhost/updateToken'), - body: { json: { token } }, - headers: {}, - method: 'POST', - })) - - expect(ws1['~orpc'].original.send).toHaveBeenCalledWith(expect.toSatisfy((s: string) => { - return s.includes('"code":"UNAUTHORIZED"') - })) - expect((ws1 as any)['~orpc'].original.serializeAttachment).toHaveBeenCalledTimes(0) - }) - - it('reject if mismatched tags', async () => { - const payload = { ...baseTokenPayload, tags: ['a-different-one'] } - const token = await signDurableIteratorToken('secret', payload) - - await object.webSocketMessage(ws1, await encodeRequestMessage('id1', MessageType.REQUEST, { - url: new URL('http://localhost/updateToken'), - body: { json: { token } }, - headers: {}, - method: 'POST', - })) - - expect(ws1['~orpc'].original.send).toHaveBeenCalledWith(expect.toSatisfy((s: string) => { - return s.includes('"code":"UNAUTHORIZED"') - })) - expect((ws1 as any)['~orpc'].original.serializeAttachment).toHaveBeenCalledTimes(0) - }) - }) - - describe('subscribe', () => { - const event1 = { order: 1 } - const event2 = { order: 2 } - - const ws1 = toDurableIteratorWebsocket(createCloudflareWebsocket()) - ws1['~orpc'].serializeTokenPayload({ ...baseTokenPayload, rpc: ['signalClient'] }) - - it('works and resume events', async () => { - resumeStoreGetSpy.mockReturnValue([event1, event2]) - - await object.webSocketMessage(ws1, await encodeRequestMessage('id1', MessageType.REQUEST, { - url: new URL('http://localhost/subscribe'), - body: {}, - headers: { 'last-event-id': '1' }, - method: 'POST', - })) - - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(3) // 1 for response, 2 for events - - expect(ws1['~orpc'].original.send).toHaveBeenNthCalledWith(2, expect.toSatisfy((s: string) => { - return s.includes('"order":1') - })) - expect(ws1['~orpc'].original.send).toHaveBeenNthCalledWith(3, expect.toSatisfy((s: string) => { - return s.includes('"order":2') - })) - - expect(resumeStoreGetSpy).toHaveBeenCalledTimes(1) - expect(resumeStoreGetSpy).toHaveBeenCalledWith(ws1, '1') - }) - - it('ignore resume sending errors', async () => { - resumeStoreGetSpy.mockReturnValue([event1, event2]) - - let time = 0 - vi.mocked(ws1['~orpc'].original.send).mockImplementation(() => { - time++ - if (time >= 2) { - throw new Error('error') - } - }) - - await object.webSocketMessage(ws1, await encodeRequestMessage('id1', MessageType.REQUEST, { - url: new URL('http://localhost/subscribe'), - body: {}, - headers: { 'last-event-id': '1' }, - method: 'POST', - })) - - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(2) // 1 for response, 1 for events (step when error occurs) - - // resumeStoreGetSpy is called -> error from .send is ignored - expect(resumeStoreGetSpy).toHaveBeenCalledTimes(1) - expect(resumeStoreGetSpy).toHaveBeenCalledWith(ws1, '1') - }) - }) - - describe('rpc', () => { - const singleClientHandler = vi.fn(async () => '__singleClientHandlerOutput__') - const singleClient = vi.fn(() => os.handler(singleClientHandler).callable()) - - const nestedClientHandler = vi.fn(async () => '__nestedClientHandlerOutput__') - const nestedClient = vi.fn(() => ({ nested: { nested: os.handler(nestedClientHandler).callable() } })) - - class TestObject extends DurableIteratorObject { - signalClient = singleClient - nestedClient = nestedClient - } - const interceptor = vi.fn(({ next }) => next()) - const object = new TestObject(ctx, env, { signingKey: 'secret', interceptors: [interceptor] }) - - const ws1 = toDurableIteratorWebsocket(createCloudflareWebsocket()) - ws1['~orpc'].serializeTokenPayload({ ...baseTokenPayload, rpc: ['signalClient'] }) - const ws2 = toDurableIteratorWebsocket(createCloudflareWebsocket()) - ws2['~orpc'].serializeTokenPayload({ ...baseTokenPayload, rpc: ['nestedClient'] }) - - it('work with single client', async () => { - await object.webSocketMessage(ws1, await encodeRequestMessage('id1', MessageType.REQUEST, { - url: new URL('http://localhost/call'), - body: { json: { path: ['signalClient'], input: '__input__' } }, - headers: { 'last-event-id': '__lastEventId__' }, - method: 'POST', - })) - - await vi.waitFor(async () => { - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws1['~orpc'].original.send).toHaveBeenCalledWith(await encodeResponseMessage('id1', MessageType.RESPONSE, { - body: { json: '__singleClientHandlerOutput__' }, - headers: {}, - status: 200, - })) - }) - - expect(singleClient).toHaveBeenCalledTimes(1) - expect(singleClient).toHaveBeenCalledWith(ws1) - - expect(singleClientHandler).toHaveBeenCalledTimes(1) - expect(singleClientHandler).toHaveBeenCalledWith(expect.objectContaining({ - input: '__input__', - signal: expect.any(AbortSignal), - lastEventId: '__lastEventId__', - })) - expect(interceptor).toHaveBeenCalledTimes(1) - }) - - it('work with nested client', async () => { - await object.webSocketMessage(ws2, await encodeRequestMessage('id1', MessageType.REQUEST, { - url: new URL('http://localhost/call'), - body: { json: { path: ['nestedClient', 'nested', 'nested'], input: '__input__' } }, - headers: { 'last-event-id': '__lastEventId__' }, - method: 'POST', - })) - - await vi.waitFor(async () => { - expect(ws2['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws2['~orpc'].original.send).toHaveBeenCalledWith(await encodeResponseMessage('id1', MessageType.RESPONSE, { - body: { json: '__nestedClientHandlerOutput__' }, - headers: {}, - status: 200, - })) - }) - - expect(nestedClient).toHaveBeenCalledTimes(1) - expect(nestedClient).toHaveBeenCalledWith(ws2) - - expect(nestedClientHandler).toHaveBeenCalledTimes(1) - expect(nestedClientHandler).toHaveBeenCalledWith(expect.objectContaining({ - input: '__input__', - signal: expect.any(AbortSignal), - lastEventId: '__lastEventId__', - })) - expect(interceptor).toHaveBeenCalledTimes(1) - }) - - it('require have required permissions to call', async () => { - await object.webSocketMessage(ws2, await encodeRequestMessage('id1', MessageType.REQUEST, { - url: new URL('http://localhost/call'), - body: { json: { path: ['signalClient'], input: '__input__' } }, - headers: {}, - method: 'POST', - })) - - await vi.waitFor(async () => { - expect(ws2['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws2['~orpc'].original.send).toHaveBeenCalledWith(expect.toSatisfy((s: string) => { - return s.includes('"code":"FORBIDDEN"') - })) - }) - - expect(singleClient).toHaveBeenCalledTimes(0) - expect(singleClientHandler).toHaveBeenCalledTimes(0) - expect(interceptor).toHaveBeenCalledTimes(1) - }) - - it('throw on not found corresponding client', async () => { - await object.webSocketMessage(ws1, await encodeRequestMessage('id1', MessageType.REQUEST, { - url: new URL('http://localhost/call'), - body: { json: { path: ['signalClient', 'notFound'], input: '__input__' } }, - headers: {}, - method: 'POST', - })) - - await vi.waitFor(async () => { - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(1) - expect(ws1['~orpc'].original.send).toHaveBeenCalledWith(expect.toSatisfy((s: string) => { - return s.includes('"code":"INTERNAL_SERVER_ERROR"') - })) - }) - - expect(singleClient).toHaveBeenCalledTimes(1) - expect(singleClientHandler).toHaveBeenCalledTimes(0) - expect(interceptor).toHaveBeenCalledTimes(1) - }) - - it('websocket message/close use the same reference - abort signal if websocket close', async () => { - singleClientHandler.mockImplementationOnce(async () => { - await sleep(1000) - return '__output__' - }) - - const promise = object.webSocketMessage(ws1, await encodeRequestMessage('id1', MessageType.REQUEST, { - url: new URL('http://localhost/call'), - body: { json: { path: ['signalClient'], input: '__input__' } }, - headers: {}, - method: 'POST', - })) - - await sleep(100) - expect(singleClientHandler).toHaveBeenCalledTimes(1) - const signal = (singleClientHandler as any).mock.calls[0][0].signal - expect(signal).instanceOf(AbortSignal) - expect(signal.aborted).toBe(false) - - // use original here to check if the websocket can reference the same reference or not? - await object.webSocketClose(ws1['~orpc'].original, 1000, 'closed', true) - await sleep(100) - expect(signal.aborted).toBe(true) - - await promise - expect(ws1['~orpc'].original.send).toHaveBeenCalledTimes(0) // closed so not send anymore - }) - }) -}) diff --git a/packages/durable-iterator/src/durable-object/object.ts b/packages/durable-iterator/src/durable-object/object.ts deleted file mode 100644 index 0fb03aa63..000000000 --- a/packages/durable-iterator/src/durable-object/object.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { DurableIteratorObject as IDurableIteratorObject } from '../object' -import type { DurableIteratorObjectHandlerOptions, PublishEventOptions } from './handler' -import type { DurableIteratorObjectState } from './object-state' -import { DurableObject } from 'cloudflare:workers' -import { DurableIteratorError } from '../error' -import { DurableIteratorObjectHandler } from './handler' - -// eslint-disable-next-line ts/no-empty-object-type -- TProps = {} is default behavior of DurableObject -export class DurableIteratorObject extends DurableObject implements IDurableIteratorObject { - '~orpc': DurableIteratorObjectHandler - - /** - * Proxied, ensure you don't accidentally change internal state, and auto close if expired websockets before .send is called - */ - protected override ctx: DurableIteratorObjectState - - constructor( - ctx: DurableObjectState, - env: TEnv, - options: DurableIteratorObjectHandlerOptions, - ) { - /** - * By default, Durable Object constructors only receive `ctx` and `env`. - * To access the 3rd `options` argument, it must be explicitly passed in. - */ - if (!options) { - throw new DurableIteratorError(` - Missing options (3rd argument) for DurableIteratorObject. - When extending DurableIteratorObject, you must define your own constructor - and call super(ctx, env, options). - `) - } - - super(ctx, env) - this['~orpc'] = new DurableIteratorObjectHandler(ctx, this, options) - this.ctx = this['~orpc'].ctx - } - - /** - * Publish an event to clients - */ - publishEvent(payload: T, options: PublishEventOptions = {}): void { - return this['~orpc'].publishEvent(payload, options) - } - - /** - * Upgrades websocket connection - * - * @info You can safety intercept non-upgrade requests - * @warning No verification is done here, you should verify the token payload before calling this method. - */ - override fetch(request: Request): Promise { - return this['~orpc'].fetch(request) - } - - /** - * Handle WebSocket messages - * - * @warning Use `toDurableIteratorWebsocket` to proxy the WebSocket when interacting - * to avoid accidentally modifying internal state, and auto close if expired before .send is called - */ - override webSocketMessage(websocket: WebSocket, message: string | ArrayBuffer): Promise { - return this['~orpc'].webSocketMessage(websocket, message) - } - - /** - * Handle WebSocket close event - * - * @warning Use `toDurableIteratorWebsocket` to proxy the WebSocket when interacting - * to avoid accidentally modifying internal state, and auto close if expired before .send is called - */ - override webSocketClose(websocket: WebSocket, code: number, reason: string, wasClean: boolean): void | Promise { - return this['~orpc'].webSocketClose(websocket, code, reason, wasClean) - } -} diff --git a/packages/durable-iterator/src/durable-object/resume-storage.test.ts b/packages/durable-iterator/src/durable-object/resume-storage.test.ts deleted file mode 100644 index 25946bb3f..000000000 --- a/packages/durable-iterator/src/durable-object/resume-storage.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { getEventMeta, withEventMeta } from '@orpc/server' -import { sleep } from '@orpc/shared' -import { createCloudflareWebsocket, createDurableObjectState } from '../../tests/shared' -import { EventResumeStorage } from './resume-storage' -import { toDurableIteratorWebsocket } from './websocket' - -describe('eventStreamStorage', () => { - it('do nothing by default', () => { - const ctx = createDurableObjectState() - const storage = new EventResumeStorage(ctx, {}) - - storage.store({ v: 1 }, { targets: [], exclude: [] }) - - expect(ctx.storage.sql.exec('SELECT name FROM sqlite_master WHERE type=?', 'table').toArray()).toEqual([]) - expect(storage.get(createCloudflareWebsocket(), '0')).toEqual([]) - }) - - it('auto remove expired events on init', async () => { - const ctx = createDurableObjectState() - const storage = new EventResumeStorage(ctx, { resumeRetentionSeconds: 1 }) - storage.store({ v: 1 }, { targets: [], exclude: [] }) - expect(ctx.storage.sql.exec('SELECT count(*) as count FROM "orpc:durable-iterator:resume:events"').one().count).toEqual(1) - - await sleep(2000) - void new EventResumeStorage(ctx, { resumeRetentionSeconds: 1 }) - expect(ctx.storage.sql.exec('SELECT count(*) as count FROM "orpc:durable-iterator:resume:events"').one().count).toEqual(0) - }) - - it('store -> get -> expire', async () => { - const ctx = createDurableObjectState() - const storage = new EventResumeStorage(ctx, { resumeRetentionSeconds: 1 }) - const ws = toDurableIteratorWebsocket(createCloudflareWebsocket()) - ws['~orpc'].serializeTokenPayload({} as any) - ws['~orpc'].serializeId('ws-id') - - const payload1 = storage.store(withEventMeta({ order: 1 }, { id: 'some-id', retry: 238 }), {}) - expect(payload1).toEqual({ order: 1 }) - expect(getEventMeta(payload1)).toEqual({ id: '1', retry: 238 }) // id is overridden for matching id in sqlite - - const payload2 = storage.store(withEventMeta({ order: 2 }, { comments: ['hi'] }), {}) - expect(payload2).toEqual({ order: 2 }) - expect(getEventMeta(payload2)).toEqual({ id: '2', comments: ['hi'] }) - - const payload3 = storage.store({ order: 3 }, {}) - expect(payload3).toEqual({ order: 3 }) - expect(getEventMeta(payload3)).toEqual({ id: '3' }) - - const relatives = storage.get(ws, '0') - expect(relatives).toEqual([payload1, payload2, payload3]) - expect(getEventMeta(relatives[0])).toEqual({ id: '1', retry: 238 }) - expect(getEventMeta(relatives[1])).toEqual({ id: '2', comments: ['hi'] }) - expect(getEventMeta(relatives[2])).toEqual({ id: '3' }) - - expect(storage.get(ws, '1')).toEqual([payload2, payload3]) - expect(storage.get(ws, '2')).toEqual([payload3]) - - await sleep(2000) - expect(storage.get(ws, '0')).toEqual([]) - expect(storage.get(ws, '1')).toEqual([]) - expect(storage.get(ws, '2')).toEqual([]) - }) - - it('store auto reset data on id overflow', async () => { - const ctx = createDurableObjectState() - const storage = new EventResumeStorage(ctx, { resumeRetentionSeconds: 1 }) - - // fake reading id limit - ctx.storage.sql.exec( - `INSERT INTO "orpc:durable-iterator:resume:events" (id, payload, target_ids, exclusion_ids) VALUES (?, ?, ?, ?)`, - '9223372036854775807', - '{}', - '[]', - '[]', - ) - expect(ctx.storage.sql.exec('SELECT count(*) as count FROM "orpc:durable-iterator:resume:events"').one().count).toEqual(1) - - const payload = storage.store({ order: 1 }, {}) - expect(payload).toEqual({ order: 1 }) - expect(getEventMeta(payload)).toEqual({ id: '1' }) // id is reset - expect(ctx.storage.sql.exec('SELECT count(*) as count FROM "orpc:durable-iterator:resume:events"').one().count).toEqual(1) - }) - - it('get tags, targets, exclude options', async () => { - const ctx = createDurableObjectState() - const storage = new EventResumeStorage(ctx, { resumeRetentionSeconds: 1 }) - - const ws1 = toDurableIteratorWebsocket(createCloudflareWebsocket()) - ws1['~orpc'].serializeTokenPayload({ tags: ['tag-1', 'tag-2'] } as any) - ws1['~orpc'].serializeId('ws-1') - const ws2 = toDurableIteratorWebsocket(createCloudflareWebsocket()) - ws2['~orpc'].serializeTokenPayload({ tags: ['tag-2'] } as any) - ws2['~orpc'].serializeId('ws-2') - const ws3 = toDurableIteratorWebsocket(createCloudflareWebsocket()) - ws3['~orpc'].serializeTokenPayload({ } as any) - ws3['~orpc'].serializeId('ws-3') - - const payload1 = storage.store({ order: 1 }, { targets: [ws1] }) - const payload2 = storage.store({ order: 2 }, { exclude: [ws2] }) - const payload3 = storage.store({ order: 3 }, { targets: [ws1, ws2], exclude: [ws2, ws3] }) - const payload4 = storage.store({ order: 4 }, { exclude: [ws1, ws3] }) - const payload5 = storage.store({ order: 5 }, { tags: ['tag-1', 'tag-2'], exclude: [ws2] }) - const payload6 = storage.store({ order: 6 }, { tags: ['tag-2'] }) - const payload7 = storage.store({ order: 7 }, { tags: ['tag-3'], targets: [ws3] }) - - expect(storage.get(ws1, '0')).toEqual([payload1, payload2, payload3, payload5, payload6]) - expect(storage.get(ws2, '0')).toEqual([payload4, payload6]) - expect(storage.get(ws3, '0')).toEqual([payload2]) - }) - - it('support custom json serializer', async () => { - class Person { - constructor(public name: string) {} - } - - const ctx = createDurableObjectState() - const storage = new EventResumeStorage(ctx, { - resumeRetentionSeconds: 1, - customJsonSerializers: [ - { - type: 1000, - condition: v => v instanceof Person, - serialize: v => ({ name: v.name }), - deserialize: ({ name }) => new Person(name), - }, - ], - }) - - const ws = toDurableIteratorWebsocket(createCloudflareWebsocket()) - ws['~orpc'].serializeTokenPayload({} as any) - ws['~orpc'].serializeId('ws-1') - - storage.store(new Person('__name__'), {}) - const payload = storage.get(ws, '0')[0] - expect(payload).toEqual(new Person('__name__')) - }) -}) diff --git a/packages/durable-iterator/src/durable-object/resume-storage.ts b/packages/durable-iterator/src/durable-object/resume-storage.ts deleted file mode 100644 index a3191ab07..000000000 --- a/packages/durable-iterator/src/durable-object/resume-storage.ts +++ /dev/null @@ -1,240 +0,0 @@ -import type { StandardRPCJsonSerializerOptions } from '@orpc/client/standard' -import type { DurableIteratorWebsocket } from './websocket' -import { StandardRPCJsonSerializer } from '@orpc/client/standard' -import { getEventMeta, withEventMeta } from '@orpc/server' -import { fallback, parseEmptyableJSON, stringifyJSON } from '@orpc/shared' - -export interface EventResumeStorageOptions extends StandardRPCJsonSerializerOptions { - /** - * How long (in seconds) to retain events for reconnection replay. - * - * When a client reconnects, stored events within this window can be replayed - * to ensure no data is lost. Outside this window, missed events are dropped. - * - * @remarks - * - Use infinite values to disable - * - Note that for performance, expired event cleanup is deferred. This means - * expired events may remain in storage for a short period beyond their - * retention time. - * - * @default NaN (disabled) - */ - resumeRetentionSeconds?: number - - /** - * Prefix for the resume storage table schema. - * This is used to avoid naming conflicts with other tables in the same Durable Object. - * - * @default 'orpc:durable-iterator:resume:' - */ - resumeSchemaPrefix?: string -} - -export interface ResumeEventFilter { - /** Only websockets with these tags will receive the event */ - tags?: readonly string[] - /** Only websockets that are in this list will receive the event */ - targets?: readonly DurableIteratorWebsocket[] - /** Websockets that are in this list will not receive the event */ - exclude?: readonly DurableIteratorWebsocket[] -} - -export class EventResumeStorage { - private readonly serializer: StandardRPCJsonSerializer - private readonly retentionSeconds: number - private readonly schemaPrefix: string - - get isEnabled(): boolean { - return Number.isFinite(this.retentionSeconds) && this.retentionSeconds > 0 - } - - constructor( - private readonly durableState: DurableObjectState, - options: EventResumeStorageOptions = {}, - ) { - this.retentionSeconds = fallback(options.resumeRetentionSeconds, Number.NaN) // disabled by default - this.schemaPrefix = fallback(options.resumeSchemaPrefix, 'orpc:durable-iterator:resume:') - this.serializer = new StandardRPCJsonSerializer(options) - - if (this.isEnabled) { - this.initSchema() - this.cleanupExpiredEvents() - } - } - - /** - * Store an payload for resume capability. - * - * @returns The updated meta of the stored payload - */ - store( - payload: T, - resumeFilter: ResumeEventFilter, - ): T { - if (!this.isEnabled) { - return payload - } - - this.cleanupExpiredEvents() - - const serializedEvent = this.serializeEventPayload(payload) - const targetIds = resumeFilter.targets?.map( - ws => ws['~orpc'].deserializeId(), - ) - const excludeIds = resumeFilter.exclude?.map( - ws => ws['~orpc'].deserializeId(), - ) - - const insertEvent = () => { - /** - * SQLite INTEGER can exceed JavaScript's safe integer range, - * so we cast to TEXT for safe ID handling in resume operations. - */ - const insertResult = this.durableState.storage.sql.exec( - `INSERT INTO "${this.schemaPrefix}events" (payload, tags, target_ids, exclusion_ids) VALUES (?, ?, ?, ?) RETURNING CAST(id AS TEXT) as id`, - serializedEvent, - stringifyJSON(resumeFilter.tags), - stringifyJSON(targetIds), - stringifyJSON(excludeIds), - ) - - const id = insertResult.one()?.id as string - return this.withEventId(payload, id) - } - - try { - return insertEvent() - } - catch { - /** - * In the error case, like full disk, exceeding the max number of rows, etc., - * we reset the schema and try to insert again. - * This can lead to data loss, but it's better than failing the entire operation. - */ - this.resetSchema() - return insertEvent() - } - } - - /** - * Get events after lastEventId for a specific websocket - */ - get( - websocket: DurableIteratorWebsocket, - lastEventId: string, - ): T[] { - if (!this.isEnabled) { - return [] - } - - this.cleanupExpiredEvents() - - const websocketTags = websocket['~orpc'].deserializeTokenPayload().tags - const websocketId = websocket['~orpc'].deserializeId() - - /** - * SQLite INTEGER can exceed JavaScript's safe integer range, - * so we cast to TEXT for safe resume ID comparison. - */ - const resumeQuery = this.durableState.storage.sql.exec(` - SELECT CAST(id AS TEXT) as id, payload, tags, target_ids, exclusion_ids - FROM "${this.schemaPrefix}events" - WHERE id > ? - ORDER BY id ASC - `, lastEventId) - - return resumeQuery - .toArray() - .filter((resumeRecord: Record) => { - const tags = parseEmptyableJSON(resumeRecord.tags) as string[] | undefined - - if (tags && !tags.some(tag => websocketTags?.includes(tag))) { - return false - } - - const resumeTargetIds = parseEmptyableJSON(resumeRecord.target_ids) as string[] | undefined - const resumeExclusionIds = parseEmptyableJSON(resumeRecord.exclusion_ids) as string[] | undefined - - if (resumeTargetIds && !resumeTargetIds.includes(websocketId)) { - return false - } - - if (resumeExclusionIds && resumeExclusionIds.includes(websocketId)) { - return false - } - - return true - }) - .map((resumeRecord: Record) => this.withEventId( - this.deserializeEventPayload(resumeRecord.payload), - resumeRecord.id, - )) - } - - private initSchema(): void { - this.durableState.storage.sql.exec(` - CREATE TABLE IF NOT EXISTS "${this.schemaPrefix}events" ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - payload TEXT NOT NULL, - tags TEXT, - target_ids TEXT, - exclusion_ids TEXT, - stored_at INTEGER NOT NULL DEFAULT (unixepoch()) - ) - `) - - this.durableState.storage.sql.exec(` - CREATE INDEX IF NOT EXISTS "${this.schemaPrefix}idx_events_id" ON "${this.schemaPrefix}events" (id) - `) - - this.durableState.storage.sql.exec(` - CREATE INDEX IF NOT EXISTS "${this.schemaPrefix}idx_events_stored_at" ON "${this.schemaPrefix}events" (stored_at) - `) - } - - private resetSchema(): void { - this.durableState.storage.sql.exec(` - DROP TABLE IF EXISTS "${this.schemaPrefix}events" - `) - - this.initSchema() - } - - private lastCleanupTime: number | undefined - private cleanupExpiredEvents(): void { - const now = Date.now() - // defer cleanup to improve performance - if (this.lastCleanupTime && this.lastCleanupTime + this.retentionSeconds * 1000 > now) { - return - } - - this.lastCleanupTime = now - - this.durableState.storage.sql.exec(` - DELETE FROM "${this.schemaPrefix}events" WHERE stored_at < unixepoch() - ? - `, this.retentionSeconds) - } - - private serializeEventPayload(payload: T): string { - const eventMeta = getEventMeta(payload) - const [json, meta] = this.serializer.serialize({ payload, meta: eventMeta }) - return stringifyJSON({ json, meta }) - } - - private deserializeEventPayload(payload: string): T { - const { json, meta } = JSON.parse(payload) - const { payload: deserializedPayload, meta: eventMeta } = this.serializer.deserialize(json, meta) as { - payload: T - meta: ReturnType - } - - return eventMeta ? withEventMeta(deserializedPayload, eventMeta) : deserializedPayload - } - - private withEventId(payload: T, id: string): T { - return withEventMeta(payload, { - ...getEventMeta(payload), - id, - }) - } -} diff --git a/packages/durable-iterator/src/durable-object/upgrade.test.ts b/packages/durable-iterator/src/durable-object/upgrade.test.ts deleted file mode 100644 index 1ff34608a..000000000 --- a/packages/durable-iterator/src/durable-object/upgrade.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { DURABLE_ITERATOR_ID_PARAM, DURABLE_ITERATOR_TOKEN_PARAM } from '../consts' -import { signDurableIteratorToken } from '../schemas' -import { upgradeDurableIteratorRequest } from './upgrade' - -describe('upgradeDurableIteratorRequest', () => { - it('reject non-websocket upgrade', async () => { - const response = await upgradeDurableIteratorRequest( - new Request('https://example.com'), - { - namespace: {} as any, - signingKey: 'test-sign', - }, - ) - - expect(response.status).toBe(426) - }) - - it('rejects missing client id', async () => { - const token = await signDurableIteratorToken('signing-key', { - chn: 'test-channel', - rpc: ['someMethod'], - att: { some: 'attachment' }, - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 1000, - }) - - const url = new URL('https://example.com') - url.searchParams.set(DURABLE_ITERATOR_TOKEN_PARAM, token) - - const response = await upgradeDurableIteratorRequest( - new Request(url, { - headers: { upgrade: 'websocket' }, - }), - { - namespace: {} as any, - signingKey: 'test-sign', - }, - ) - - expect(response.status).toBe(401) - }) - - it('rejects missing token', async () => { - const url = new URL('https://example.com') - url.searchParams.set(DURABLE_ITERATOR_ID_PARAM, 'client-id') - - const response = await upgradeDurableIteratorRequest( - new Request(url, { - headers: { upgrade: 'websocket' }, - }), - { - namespace: {} as any, - signingKey: 'test-sign', - }, - ) - - expect(response.status).toBe(401) - }) - - it('rejects invalid token', async () => { - const url = new URL('https://example.com') - url.searchParams.set(DURABLE_ITERATOR_TOKEN_PARAM, 'invalid') - url.searchParams.set(DURABLE_ITERATOR_ID_PARAM, 'client-id') - - const response = await upgradeDurableIteratorRequest( - new Request(url, { - headers: { upgrade: 'websocket' }, - }), - { - namespace: {} as any, - signingKey: 'test-sign', - }, - ) - - expect(response.status).toBe(401) - }) - - it('rejects invalid payload', async () => { - const invalidToken = await signDurableIteratorToken('test-sign', {} as any) - const url = new URL('https://example.com') - url.searchParams.set(DURABLE_ITERATOR_TOKEN_PARAM, invalidToken) - url.searchParams.set(DURABLE_ITERATOR_ID_PARAM, 'client-id') - - const response = await upgradeDurableIteratorRequest( - new Request(url, { - headers: { upgrade: 'websocket' }, - }), - { - namespace: {} as any, - signingKey: 'test-sign', - }, - ) - - expect(response.status).toBe(401) - }) - - it('upgrades valid request', async () => { - const namespace = { - idFromName: vi.fn((name: string) => new TextEncoder().encode(name)), - get: vi.fn(() => ({ - fetch: vi.fn(() => new Response('WebSocket Upgrade Successful')), - })), - } - - const token = await signDurableIteratorToken('signing-key', { - chn: 'test-channel', - rpc: ['someMethod'], - att: { some: 'attachment' }, - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 1000, - }) - - const url = new URL('https://example.com') - url.searchParams.set(DURABLE_ITERATOR_TOKEN_PARAM, token) - url.searchParams.set(DURABLE_ITERATOR_ID_PARAM, 'test-id') - - const request = new Request(url, { - headers: { upgrade: 'websocket' }, - }) - const response = await upgradeDurableIteratorRequest( - request, - { - namespace: namespace as any, - signingKey: 'signing-key', - namespaceGetOptions: { locationHint: 'something' } as any, - }, - ) - - expect(await response.text()).toEqual('WebSocket Upgrade Successful') - - expect(namespace.idFromName).toHaveBeenCalledWith('test-channel') - expect(namespace.get).toHaveBeenCalledWith(namespace.idFromName.mock.results[0]!.value, { locationHint: 'something' }) - - const stub = namespace.get.mock.results[0]!.value - expect(stub.fetch).toHaveBeenCalledOnce() - expect(stub.fetch).toHaveBeenCalledWith(request) - }) -}) diff --git a/packages/durable-iterator/src/durable-object/upgrade.ts b/packages/durable-iterator/src/durable-object/upgrade.ts deleted file mode 100644 index 0758f4106..000000000 --- a/packages/durable-iterator/src/durable-object/upgrade.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Interceptor } from '@orpc/shared' -import type { DurableIteratorObject } from '.' -import type { DurableIteratorTokenPayload } from '../schemas' -import { intercept, toArray } from '@orpc/shared' -import { DURABLE_ITERATOR_ID_PARAM, DURABLE_ITERATOR_TOKEN_PARAM } from '../consts' -import { verifyDurableIteratorToken } from '../schemas' - -export interface UpgradeDurableIteratorRequestOptions { - /** - * The signing key used to verify the token - */ - signingKey: string - - /** - * The durable object namespace - */ - namespace: DurableObjectNamespace - - /** - * The options to use when getting the durable object stub - */ - namespaceGetOptions?: DurableObjectNamespaceGetDurableObjectOptions - - /** - * intercept upgrade process - */ - interceptors?: Interceptor<{ payload: DurableIteratorTokenPayload }, Promise>[] -} - -/** - * Verifies and upgrades a durable iterator request. - * - * @info Verify token before forwarding to durable object to prevent DDoS attacks - */ -export async function upgradeDurableIteratorRequest( - request: Request, - options: UpgradeDurableIteratorRequestOptions, -): Promise { - if (request.headers.get('upgrade') !== 'websocket') { - return new Response('Expected WebSocket upgrade', { - status: 426, - }) - } - - const url = new URL(request.url) - const token = url.searchParams.getAll(DURABLE_ITERATOR_TOKEN_PARAM).at(-1) - const id = url.searchParams.getAll(DURABLE_ITERATOR_ID_PARAM).at(-1) - - if (typeof id !== 'string') { - return new Response('ID is required', { status: 401 }) - } - - if (!token) { - return new Response('Token is required', { status: 401 }) - } - - const payload = await verifyDurableIteratorToken(options.signingKey, token) - - if (!payload) { - return new Response('Invalid Token', { status: 401 }) - } - - return intercept( - toArray(options.interceptors), - { payload }, - async ({ payload }) => { - const namespace = options.namespace as DurableObjectNamespace> - const id = namespace.idFromName(payload.chn) - const stub = namespace.get(id, options.namespaceGetOptions) - return stub.fetch(request) - }, - ) -} diff --git a/packages/durable-iterator/src/durable-object/websocket.test.ts b/packages/durable-iterator/src/durable-object/websocket.test.ts deleted file mode 100644 index c300122ba..000000000 --- a/packages/durable-iterator/src/durable-object/websocket.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { sleep } from '@orpc/shared' -import { createCloudflareWebsocket } from '../../tests/shared' -import { DurableIteratorError } from '../error' -import { toDurableIteratorWebsocket } from './websocket' - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('toDurableIteratorWebsocket', () => { - const ws = createCloudflareWebsocket() - const proxied = toDurableIteratorWebsocket(ws) as any - - it('proxy with additional ~orpc', () => { - expect('readyState' in proxied).toBe(true) - expect(proxied.readyState).toBeTypeOf('number') - - expect('close' in proxied).toBe(true) - expect(proxied.close).toBeInstanceOf(Function) - - expect('~orpc' in proxied).toBe(true) - expect(proxied['~orpc']).toBeInstanceOf(Object) - expect(proxied['~orpc'].original).toBe(ws) - }) - - it('proxy and provide attachment helpers', () => { - proxied.serializeAttachment('attachment') - expect(proxied.deserializeAttachment()).toEqual('attachment') - expect(ws.deserializeAttachment()).toEqual({ wa: 'attachment' }) - - proxied['~orpc'].serializeHibernationId('some-id') - expect(proxied['~orpc'].deserializeHibernationId()).toEqual('some-id') - expect(ws.deserializeAttachment()).toEqual({ hi: 'some-id', wa: 'attachment' }) - - proxied.serializeAttachment({ v: 1 }) // change attachment not accidentally override others - expect(proxied.deserializeAttachment()).toEqual({ v: 1 }) - expect(ws.deserializeAttachment()).toEqual({ hi: 'some-id', wa: { v: 1 } }) - - expect(() => proxied['~orpc'].deserializeId()).toThrow( - new DurableIteratorError('ID not found, please call serializeId first'), - ) - proxied['~orpc'].serializeId('some-id') - expect(proxied['~orpc'].deserializeId()).toEqual('some-id') - expect(ws.deserializeAttachment()).toEqual({ hi: 'some-id', wa: { v: 1 }, id: 'some-id' }) - - expect(() => proxied['~orpc'].deserializeTokenPayload()).toThrow( - new DurableIteratorError('Token payload not found, please call serializeTokenPayload first'), - ) - proxied['~orpc'].serializeTokenPayload({ exp: 398398 }) - expect(proxied['~orpc'].deserializeTokenPayload()).toEqual({ exp: 398398 }) - expect(ws.deserializeAttachment()).toEqual({ tp: { exp: 398398 }, hi: 'some-id', wa: { v: 1 }, id: 'some-id' }) - }) - - it('proxied and auto close if expired on send', async () => { - const nowInSeconds = Math.floor(Date.now() / 1000) - proxied['~orpc'].serializeTokenPayload({ id: 'some-id', exp: nowInSeconds + 1 }) - - proxied.send('data') - expect(ws.send).toHaveBeenCalledTimes(1) - expect(ws.close).toHaveBeenCalledTimes(0) - - vi.mocked(ws.send as () => any).mockClear() - - await sleep(1001) - proxied.send('data') - expect(ws.send).toHaveBeenCalledTimes(1) - expect(ws.close).toHaveBeenCalledTimes(1) - expect(ws.close).toHaveBeenCalledBefore(ws.send) - }) - - it('not proxy again if already proxied', () => { - expect(toDurableIteratorWebsocket(ws)).toBe(proxied) - expect(toDurableIteratorWebsocket(proxied)).toBe(proxied) - }) -}) diff --git a/packages/durable-iterator/src/durable-object/websocket.ts b/packages/durable-iterator/src/durable-object/websocket.ts deleted file mode 100644 index 49b3385ff..000000000 --- a/packages/durable-iterator/src/durable-object/websocket.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { DurableIteratorTokenPayload } from '../schemas' -import { DurableIteratorError } from '../error' - -export interface DurableIteratorWebsocketInternal { - /** - * Access the original websocket instance - * - * @warning Be careful when using original because you can accidentally modifying internal state. - */ - original: WebSocket - - /** - * Serialize the websocket id - * - * @warning this method should be called when client established connection - */ - serializeId(id: string): void - - /** - * Deserialize the websocket id - * - * @warning this method assumes that the id is already set when client established connection - */ - deserializeId(): string - - /** - * Serialize the token payload usually when client connected - * - * @warning this method should be called when client established connection or when token payload is updated - */ - serializeTokenPayload(payload: DurableIteratorTokenPayload): void - - /** - * Deserialize the payload attached when client connected - * - * @warning this method assumes that the token payload is already set when client established connection - */ - deserializeTokenPayload(): DurableIteratorTokenPayload - - /** - * Serialize the hibernation id used for publishing events to the client - */ - serializeHibernationId(id: string): void - - /** - * Deserialize the hibernation id used for publishing events to the client - */ - deserializeHibernationId(): string | undefined - - /** - * Close the websocket connection if expired - * - * @warning this method assumes that the token payload is already set when client established connection - */ - closeIfExpired(): void -} - -export interface DurableIteratorWebsocket extends WebSocket { - /** - * Durable Event internal apis - */ - ['~orpc']: DurableIteratorWebsocketInternal -} - -const websocketReferencesCache = new WeakMap() - -/** - * Create a Durable Iterator WebSocket from a regular WebSocket - * - * @info The websocket automatically closes if expired before sending data - */ -export function toDurableIteratorWebsocket(original: WebSocket): DurableIteratorWebsocket { - if ('~orpc' in original) { - return original as DurableIteratorWebsocket - } - - /** - * The WebSocket adapter relies on reference equality, so we must ensure that - * the same WebSocket always maps to the same DurableIteratorWebSocket. - */ - const cached = websocketReferencesCache.get(original) - if (cached) { - return cached - } - - const internal: DurableIteratorWebsocketInternal = { - original, - serializeId(id) { - original.serializeAttachment({ - ...original.deserializeAttachment(), - id, - }) - }, - deserializeId() { - const id = original.deserializeAttachment()?.id - - if (!id) { - throw new DurableIteratorError('ID not found, please call serializeId first') - } - - return id - }, - serializeTokenPayload(payload) { - original.serializeAttachment({ - ...original.deserializeAttachment(), - tp: payload, - }) - }, - deserializeTokenPayload() { - const payload = original.deserializeAttachment()?.tp - - if (!payload) { - throw new DurableIteratorError('Token payload not found, please call serializeTokenPayload first') - } - - return payload - }, - serializeHibernationId(id) { - original.serializeAttachment({ - ...original.deserializeAttachment(), - hi: id, - }) - }, - deserializeHibernationId() { - return original.deserializeAttachment()?.hi - }, - closeIfExpired() { - const payload = internal.deserializeTokenPayload() - - if (payload.exp < Date.now() / 1000) { - original.close(1008, 'Token expired') - } - }, - } - - const serializeAttachment: WebSocket['serializeAttachment'] = (wa) => { - original.serializeAttachment({ - ...original.deserializeAttachment(), - wa, - }) - } - - const deserializeAttachment: WebSocket['deserializeAttachment'] = () => { - return original.deserializeAttachment()?.wa - } - - const send: WebSocket['send'] = (data) => { - internal.closeIfExpired() // should check before to ensure nothing send after expired - return original.send(data) - } - - const proxy = new Proxy(original, { - get(_, prop) { - if (prop === '~orpc') { - return internal - } - - if (prop === 'serializeAttachment') { - return serializeAttachment - } - - if (prop === 'deserializeAttachment') { - return deserializeAttachment - } - - if (prop === 'send') { - return send - } - - const v = Reflect.get(original, prop) - return typeof v === 'function' - ? v.bind(original) // Require .bind itself for calling - : v - }, - has(_, p) { - return p === '~orpc' || Reflect.has(original, p) - }, - }) - - websocketReferencesCache.set(original, proxy as DurableIteratorWebsocket) - return proxy as DurableIteratorWebsocket -} diff --git a/packages/durable-iterator/src/error.ts b/packages/durable-iterator/src/error.ts deleted file mode 100644 index da00bd4c6..000000000 --- a/packages/durable-iterator/src/error.ts +++ /dev/null @@ -1,2 +0,0 @@ -export class DurableIteratorError extends Error { -} diff --git a/packages/durable-iterator/src/index.test.ts b/packages/durable-iterator/src/index.test.ts deleted file mode 100644 index 5a2d62782..000000000 --- a/packages/durable-iterator/src/index.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -it('export something', async () => { - expect(Object.keys(await import('./index'))).toContain('DurableIteratorHandlerPlugin') -}) diff --git a/packages/durable-iterator/src/index.ts b/packages/durable-iterator/src/index.ts deleted file mode 100644 index 09586c12d..000000000 --- a/packages/durable-iterator/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './consts' -export * from './contract' -export * from './error' -export * from './iterator' -export * from './object' -export * from './plugin' -export * from './schemas' diff --git a/packages/durable-iterator/src/iterator.test-d.ts b/packages/durable-iterator/src/iterator.test-d.ts deleted file mode 100644 index 57d4cb034..000000000 --- a/packages/durable-iterator/src/iterator.test-d.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Client } from '@orpc/client' -import type { ClientDurableIterator } from './client' -import type { DurableIteratorObject } from './object' -import { DurableIterator } from './iterator' - -describe('DurableIteratorOptions', () => { - it('require provide valid rpc methods', () => { - interface TestObject extends DurableIteratorObject<{ v: string }> { - rpc: () => Client - invalid: 'invalid' - } - - const iterator1 = new DurableIterator('some-room', { - signingKey: 'signing-key', - }).rpc('rpc') - - const iterator2 = new DurableIterator('some-room', { - signingKey: 'signing-key', - }) - // @ts-expect-error - Should error on invalid rpc - .rpc('invalid') - }) - - it('resolve correct client durable iterator type', async () => { - interface TestObject extends DurableIteratorObject<{ v: string }> { - rpc: () => Client - } - - const iterator1 = await new DurableIterator('some-room', { - signingKey: 'singing-key', - }) - - expectTypeOf(iterator1).toEqualTypeOf>() - - const iterator2 = await new DurableIterator('some-room', { - signingKey: 'singing-key', - }).rpc('rpc') - - expectTypeOf(iterator2).toEqualTypeOf>() - }) -}) diff --git a/packages/durable-iterator/src/iterator.test.ts b/packages/durable-iterator/src/iterator.test.ts deleted file mode 100644 index 9395c2df4..000000000 --- a/packages/durable-iterator/src/iterator.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getClientDurableIteratorToken } from './client' -import { DurableIterator } from './iterator' -import { verifyDurableIteratorToken } from './schemas' - -describe('durableIterator', () => { - const testChannel = 'test-channel' - const testSigningKey = 'test-signing-key-32-chars-long-123' - - it('rpc: should return new instance with rpc methods specified', () => { - const options = { - att: { userId: 'user123' }, - signingKey: testSigningKey, - } - - const iterator = new DurableIterator(testChannel, options) as any - const rpcIterator = iterator.rpc('getUser', 'sendMessage') - - expect(rpcIterator).toBeInstanceOf(DurableIterator) - expect(rpcIterator).not.toBe(iterator) // Should be a new instance - }) - - describe('.then: ClientDurableIterator', () => { - it('token & throw when interacting with client iterator', async () => { - const date = new Date() - const options = { - tags: ['tag1', 'tag2'], - att: { userId: 'user123' }, - rpc: ['getUser', 'sendMessage'] as any, - signingKey: testSigningKey, - } - const iterator = new DurableIterator(testChannel, options) as any - const clientIterator = await iterator - - const token = getClientDurableIteratorToken(clientIterator) - expect(token).toBeDefined() - const payload = await verifyDurableIteratorToken(testSigningKey, token!) - - expect(payload?.chn).toBe(testChannel) - expect(payload?.tags).toEqual(['tag1', 'tag2']) - expect(payload?.att).toEqual({ userId: 'user123' }) - expect(payload?.rpc).toEqual(['getUser', 'sendMessage']) - expect(payload?.iat).toEqual(Math.floor(date.getTime() / 1000)) - expect(payload?.exp).toEqual(Math.floor(date.getTime() / 1000) + 60 * 60 * 24) - - await expect(clientIterator.next()).rejects.toThrow() - await expect(clientIterator.getUser()).rejects.toThrow() - }) - - it('can change token TTL', async () => { - const date = new Date() - const options = { - tokenTTLSeconds: 3600, // 1 hour - signingKey: testSigningKey, - } - - const iterator = new DurableIterator(testChannel, options) as any - const clientIterator = await iterator - const token = getClientDurableIteratorToken(clientIterator) - const payload = await verifyDurableIteratorToken(testSigningKey, token!) - - expect(payload?.exp).toEqual(Math.floor(date.getTime() / 1000) + 3600) - }) - }) -}) diff --git a/packages/durable-iterator/src/iterator.ts b/packages/durable-iterator/src/iterator.ts deleted file mode 100644 index a00ba07dd..000000000 --- a/packages/durable-iterator/src/iterator.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { ClientLink } from '@orpc/client' -import type { ClientDurableIterator } from './client' -import type { DurableIteratorObject, InferDurableIteratorObjectRPC } from './object' -import { AsyncIteratorClass } from '@orpc/shared' -import { createClientDurableIterator } from './client' -import { DurableIteratorError } from './error' -import { signDurableIteratorToken } from './schemas' - -export interface DurableIteratorOptions< - T extends DurableIteratorObject, - RPC extends InferDurableIteratorObjectRPC, -> { - /** - * The signing key used to sign the token - */ - signingKey: string - - /** - * Time to live for the token in seconds. - * After expiration, the token will no longer be valid. - * - * @default 24 hours (60 * 60 * 24) - */ - tokenTTLSeconds?: number - - /** - * Tags to attach to the token. - */ - tags?: readonly string[] - - /** - * Token's attachment - */ - att?: unknown - - /** - * The methods that are allowed to be called remotely. - * - * @warning Please use .rpc method to set this field in case ts complains about value you pass - */ - rpc?: readonly RPC[] -} - -export class DurableIterator< - T extends DurableIteratorObject, - RPC extends InferDurableIteratorObjectRPC = never, -> implements PromiseLike> { - constructor( - private readonly chn: string, - private readonly options: DurableIteratorOptions, - ) { - } - - /** - * List of methods that are allowed to be called remotely. - */ - rpc>(...rpc: U[]): Omit, 'rpc'> { - return new DurableIterator(this.chn, { - ...this.options, - rpc, - }) - } - - then, TResult2 = never>( - onfulfilled?: ((value: ClientDurableIterator) => TResult1 | PromiseLike) | null | undefined, - onrejected?: ((reason: any) => TResult2 | PromiseLike) | null | undefined, - ): PromiseLike { - return (async () => { - const tokenTTLSeconds = this.options.tokenTTLSeconds ?? 60 * 60 * 24 // 24 hours - - const nowInSeconds = Math.floor(Date.now() / 1000) - - const token = await signDurableIteratorToken(this.options.signingKey, { - chn: this.chn, - tags: this.options.tags, - att: this.options.att, - rpc: this.options.rpc, - iat: nowInSeconds, - exp: nowInSeconds + tokenTTLSeconds, - }) - - const iterator = new AsyncIteratorClass( - () => Promise.reject(new DurableIteratorError('Cannot be iterated directly.')), - () => Promise.reject(new DurableIteratorError('Cannot be cleaned up directly.')), - ) - - const link: ClientLink = { - call() { - throw new DurableIteratorError('Cannot call methods directly.') - }, - } - - const durableIterator = createClientDurableIterator(iterator, link, { - getToken: () => token, - }) - - return durableIterator as ClientDurableIterator - })().then(onfulfilled, onrejected) - } -} diff --git a/packages/durable-iterator/src/object.test-d.ts b/packages/durable-iterator/src/object.test-d.ts deleted file mode 100644 index 26e60ced2..000000000 --- a/packages/durable-iterator/src/object.test-d.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Client } from '@orpc/client' -import type { DurableIteratorObject, InferDurableIteratorObjectRPC } from './object' - -it('InferDurableIteratorObjectRPC', () => { - interface TestObject extends DurableIteratorObject { - singleClient: (ws: WebSocket) => Client - nestedClient: (ws: WebSocket) => { a: Client, b: Client } - - // invalid cases - requiredContext: (ws: WebSocket) => Client<{ a: string }, { message: string }, void, Error> - next: (ws: WebSocket) => Client - notAFunction: Client<{ a: string }, { message: string }, void, Error> - } - - expectTypeOf>().toEqualTypeOf< - 'singleClient' | 'nestedClient' - >() -}) diff --git a/packages/durable-iterator/src/object.ts b/packages/durable-iterator/src/object.ts deleted file mode 100644 index cdb4f6c20..000000000 --- a/packages/durable-iterator/src/object.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { NestedClient } from '@orpc/client' -import type { AsyncIteratorClass } from '@orpc/shared' - -export interface DurableIteratorObjectDef { - '~eventPayloadType'?: { type: T } -} - -export interface DurableIteratorObject { - '~orpc'?: DurableIteratorObjectDef -} - -export type InferDurableIteratorObjectRPC< - T extends DurableIteratorObject, -> = Exclude<{ - [K in keyof T]: T[K] extends ((...args: any[]) => NestedClient) - ? K - : never -}[keyof T], keyof AsyncIteratorClass> & string diff --git a/packages/durable-iterator/src/plugin.test.ts b/packages/durable-iterator/src/plugin.test.ts deleted file mode 100644 index 87b29b158..000000000 --- a/packages/durable-iterator/src/plugin.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { os } from '@orpc/server' -import { StandardRPCHandler } from '@orpc/server/standard' -import { getClientDurableIteratorToken } from './client' -import { DURABLE_ITERATOR_PLUGIN_HEADER_KEY, DURABLE_ITERATOR_PLUGIN_HEADER_VALUE } from './consts' -import { DurableIterator } from './iterator' -import { DurableIteratorHandlerPlugin } from './plugin' - -beforeEach(() => { - vi.resetAllMocks() -}) - -describe('durableIteratorHandlerPlugin', async () => { - const interceptor = vi.fn(({ next }) => next()) - - const durableIterator = await new DurableIterator('some-room', { signingKey: 'signing-key' }) - - const handler = new StandardRPCHandler({ - durableIterator: os.handler(() => durableIterator), - regularResponse: os.handler(() => 'regular response'), - }, { - plugins: [ - new DurableIteratorHandlerPlugin(), - ], - interceptors: [ - interceptor, - ], - }) - - it('should add plugin header when output is a client durable iterator', async () => { - const { response } = await handler.handle({ - url: new URL('http://localhost/durableIterator'), - method: 'POST', - body: () => Promise.resolve(JSON.stringify({})), - headers: {}, - signal: undefined, - }, { - context: {}, - }) - - expect(response!.status).toBe(200) - expect(response!.headers[DURABLE_ITERATOR_PLUGIN_HEADER_KEY]).toBe(DURABLE_ITERATOR_PLUGIN_HEADER_VALUE) - const token = getClientDurableIteratorToken(durableIterator) - expect(token).toBeTypeOf('string') - expect(response!.body).toEqual({ json: token }) - }) - - it('should not add plugin header when output is not a durable iterator', async () => { - const { response } = await handler.handle({ - url: new URL('http://localhost/regularResponse'), - method: 'POST', - body: () => Promise.resolve(JSON.stringify({})), - headers: {}, - signal: undefined, - }, { - context: {}, - }) - - expect(response!.status).toBe(200) - expect(response!.headers[DURABLE_ITERATOR_PLUGIN_HEADER_KEY]).toBeUndefined() - expect(response!.body).toEqual({ json: 'regular response' }) - }) - - it('should do nothing if handler does not match', async () => { - const { matched } = await handler.handle({ - url: new URL('http://localhost/not-found'), - method: 'POST', - body: () => Promise.resolve(JSON.stringify({})), - headers: {}, - signal: undefined, - }, { - context: {}, - }) - - expect(matched).toBe(false) - }) - - it('should throw error if plugin context is corrupted', async () => { - interceptor.mockImplementationOnce(({ next, ...options }) => next({ ...options, context: {} })) - - const { response } = await handler.handle({ - url: new URL('http://localhost/durableIterator'), - method: 'POST', - body: () => Promise.resolve(JSON.stringify({})), - headers: {}, - signal: undefined, - }, { - context: {}, - }) - - expect(response?.status).toBe(500) - }) -}) diff --git a/packages/durable-iterator/src/plugin.ts b/packages/durable-iterator/src/plugin.ts deleted file mode 100644 index 47b6abafe..000000000 --- a/packages/durable-iterator/src/plugin.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { Context, Router } from '@orpc/server' -import type { StandardHandlerOptions, StandardHandlerPlugin } from '@orpc/server/standard' -import { getClientDurableIteratorToken } from './client' -import { DURABLE_ITERATOR_PLUGIN_HEADER_KEY, DURABLE_ITERATOR_PLUGIN_HEADER_VALUE } from './consts' -import { DurableIteratorError } from './error' - -export interface DurableIteratorHandlerPluginContext { - isClientDurableIteratorOutput?: boolean -} - -/** - * @see {@link https://orpc.dev/docs/integrations/durable-iterator Durable Iterator Integration} - */ -export class DurableIteratorHandlerPlugin implements StandardHandlerPlugin { - readonly CONTEXT_SYMBOL = Symbol('ORPC_DURABLE_ITERATOR_HANDLER_PLUGIN_CONTEXT') - - /** - * make sure run after batch plugin - */ - order = 1_500_000 - - init(options: StandardHandlerOptions, _router: Router): void { - options.interceptors ??= [] - options.clientInterceptors ??= [] - - options.interceptors.unshift(async (options) => { - const pluginContext: DurableIteratorHandlerPluginContext = {} - - const result = await options.next({ - ...options, - context: { - [this.CONTEXT_SYMBOL]: pluginContext, - ...options.context, - }, - }) - - if (!result.matched) { - return result - } - - return { - ...result, - response: { - ...result.response, - headers: { - ...result.response.headers, - [DURABLE_ITERATOR_PLUGIN_HEADER_KEY]: pluginContext.isClientDurableIteratorOutput - ? DURABLE_ITERATOR_PLUGIN_HEADER_VALUE - : undefined, - }, - }, - } - }) - - options.clientInterceptors.unshift(async (options) => { - const pluginContext = options.context[this.CONTEXT_SYMBOL] as DurableIteratorHandlerPluginContext | undefined - - if (!pluginContext) { - throw new DurableIteratorError('Plugin context has been corrupted or modified by another plugin or interceptor') - } - - const output = await options.next() - - const token = getClientDurableIteratorToken(output) - - if (typeof token === 'string') { - pluginContext.isClientDurableIteratorOutput = true - return token - } - - return output - }) - } -} diff --git a/packages/durable-iterator/src/schemas.test.ts b/packages/durable-iterator/src/schemas.test.ts deleted file mode 100644 index cb1816a1d..000000000 --- a/packages/durable-iterator/src/schemas.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type { DurableIteratorTokenPayload } from './schemas' -import { sign } from '@orpc/server/helpers' -import { describe, expect, it } from 'vitest' -import { parseDurableIteratorToken, signDurableIteratorToken, verifyDurableIteratorToken } from './schemas' - -describe('signDurableIteratorToken', () => { - it('should sign a token payload and return a string', async () => { - const payload: DurableIteratorTokenPayload = { - chn: 'test-channel', - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600, - } - - const token = await signDurableIteratorToken('secret-key', payload) - - expect(typeof token).toBe('string') - expect(token.length).toBeGreaterThan(0) - }) - - it('should produce different tokens for different payloads', async () => { - const payload1: DurableIteratorTokenPayload = { - chn: 'channel-1', - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600, - } - - const payload2: DurableIteratorTokenPayload = { - chn: 'channel-2', - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600, - } - - const token1 = await signDurableIteratorToken('secret-key', payload1) - const token2 = await signDurableIteratorToken('secret-key', payload2) - - expect(token1).not.toBe(token2) - }) - - it('should produce different tokens for different secrets', async () => { - const payload: DurableIteratorTokenPayload = { - chn: 'test-channel', - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600, - } - - const token1 = await signDurableIteratorToken('secret-1', payload) - const token2 = await signDurableIteratorToken('secret-2', payload) - - expect(token1).not.toBe(token2) - }) -}) - -describe('verifyDurableIteratorToken', () => { - it('should verify a valid token and return the payload', async () => { - const payload: DurableIteratorTokenPayload = { - chn: 'test-channel', - att: { userId: 'user-456' }, - rpc: ['getUser', 'sendMessage'], - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600, - } - - const token = await signDurableIteratorToken('secret-key', payload) - const verifiedPayload = await verifyDurableIteratorToken('secret-key', token) - - expect(verifiedPayload).toEqual(payload) - }) - - it('should return undefined for an expired token', async () => { - const payload: DurableIteratorTokenPayload = { - chn: 'test-channel', - iat: Math.floor(Date.now() / 1000) - 7200, // 2 hours ago - exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago (expired) - } - - const token = await signDurableIteratorToken('secret-key', payload) - const verifiedPayload = await verifyDurableIteratorToken('secret-key', token) - - expect(verifiedPayload).toBeUndefined() - }) - - it('should return undefined for a token with wrong secret', async () => { - const payload: DurableIteratorTokenPayload = { - chn: 'test-channel', - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600, - } - - const token = await signDurableIteratorToken('secret-key', payload) - const verifiedPayload = await verifyDurableIteratorToken('wrong-secret', token) - - expect(verifiedPayload).toBeUndefined() - }) - - it('should return undefined for an invalid token format', async () => { - const invalidToken = 'invalid-token-format' - const verifiedPayload = await verifyDurableIteratorToken('secret-key', invalidToken) - - expect(verifiedPayload).toBeUndefined() - }) - - it('should return undefined for a token with invalid payload structure', async () => { - // Create a token with invalid payload structure - const invalidPayload = { - id: 123, // should be string - chn: 'test-channel', - iat: 'invalid', // should be number - exp: Math.floor(Date.now() / 1000) + 3600, - } - - // We need to bypass type checking to create an invalid token - const token = await signDurableIteratorToken('secret-key', invalidPayload as any) - const verifiedPayload = await verifyDurableIteratorToken('secret-key', token) - - expect(verifiedPayload).toBeUndefined() - }) - - it('should handle edge case where exp equals current time', async () => { - const currentTime = Math.floor(Date.now() / 1000) - const payload: DurableIteratorTokenPayload = { - chn: 'test-channel', - iat: currentTime, - exp: currentTime, // expires exactly now - } - - const token = await signDurableIteratorToken('secret-key', payload) - - // Since exp < Date.now() / 1000, it should be considered expired - const verifiedPayload = await verifyDurableIteratorToken('secret-key', token) - expect(verifiedPayload).toBeUndefined() - }) - - it('should handle token with malformed JSON', async () => { - // Create a token that will have malformed JSON when parsed - const malformedToken = await sign('invalid-json', 'secret-key') - const verifiedPayload = await verifyDurableIteratorToken('secret-key', malformedToken) - - expect(verifiedPayload).toBeUndefined() - }) -}) - -describe('parseDurableIteratorToken', () => { - it('should parse a valid token and return the payload', async () => { - const payload: DurableIteratorTokenPayload = { - chn: 'test-channel', - att: { userId: 'user-456' }, - rpc: ['getUser', 'sendMessage'], - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600, - } - - const token = await signDurableIteratorToken('secret-key', payload) - const parsedPayload = parseDurableIteratorToken(token) - - expect(parsedPayload).toEqual(payload) - }) - - it('should parse an expired token (does not check expiration)', async () => { - const payload: DurableIteratorTokenPayload = { - chn: 'test-channel', - iat: Math.floor(Date.now() / 1000) - 7200, - exp: Math.floor(Date.now() / 1000) - 3600, // expired - } - - const token = await signDurableIteratorToken('secret-key', payload) - const parsedPayload = parseDurableIteratorToken(token) - - // parseToken should return the payload even if expired - expect(parsedPayload).toEqual(payload) - }) - - it('should throw error for invalid token format', () => { - const invalidToken = 'invalid-token-format' - - expect(() => parseDurableIteratorToken(invalidToken)).toThrow() - }) - - it('should throw error for token with invalid payload structure', async () => { - // Create a token with invalid payload structure - const invalidPayload = { - id: 123, // should be string - chn: 'test-channel', - iat: 'invalid', // should be number - exp: Math.floor(Date.now() / 1000) + 3600, - } - - const token = await signDurableIteratorToken('secret-key', invalidPayload as any) - - expect(() => parseDurableIteratorToken(token)).toThrow() - }) - - it('should throw error for token with malformed JSON', () => { - const malformedToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid-json.signature' - - expect(() => parseDurableIteratorToken(malformedToken)).toThrow() - }) - - it('should handle token with missing required fields', async () => { - const incompletePayload = { - id: 'client-123', - // missing required fields - } - - const token = await signDurableIteratorToken('secret-key', incompletePayload as any) - - expect(() => parseDurableIteratorToken(token)).toThrow() - }) -}) - -describe('integration tests', () => { - it('should handle complete sign -> verify -> parse flow', async () => { - const originalPayload: DurableIteratorTokenPayload = { - chn: 'test-channel', - tags: ['tag1', 'tag2'], - att: { userId: 'user-456', role: 'admin' }, - rpc: ['getUser', 'sendMessage', 'deleteMessage'], - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600, - } - - // Sign the token - const token = await signDurableIteratorToken('secret-key', originalPayload) - - // Verify the token - const verifiedPayload = await verifyDurableIteratorToken('secret-key', token) - expect(verifiedPayload).toEqual(originalPayload) - - // Parse the token (without verification) - const parsedPayload = parseDurableIteratorToken(token) - expect(parsedPayload).toEqual(originalPayload) - }) - - it('should handle tokens with complex attachment data', async () => { - const complexAttachment = { - user: { - id: 'user-123', - profile: { - name: 'John Doe', - email: 'john@example.com', - preferences: { - theme: 'dark', - notifications: true, - }, - }, - }, - permissions: ['read', 'write', 'admin'], - metadata: { - createdAt: new Date().toISOString(), - version: '1.0.0', - }, - } - - const payload: DurableIteratorTokenPayload = { - chn: 'test-channel', - att: complexAttachment, - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600, - } - - const token = await signDurableIteratorToken('secret-key', payload) - const verifiedPayload = await verifyDurableIteratorToken('secret-key', token) - - expect(verifiedPayload).toEqual(payload) - expect(verifiedPayload?.att).toEqual(complexAttachment) - }) -}) diff --git a/packages/durable-iterator/src/schemas.ts b/packages/durable-iterator/src/schemas.ts deleted file mode 100644 index 162febc9e..000000000 --- a/packages/durable-iterator/src/schemas.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { getSignedValue, sign, unsign } from '@orpc/server/helpers' -import { parseEmptyableJSON, stringifyJSON } from '@orpc/shared' -import * as v from 'valibot' -import { DurableIteratorError } from './error' - -export type DurableIteratorTokenPayload = v.InferOutput - -const DurableIteratorTokenPayloadSchema = v.object({ - chn: v.pipe(v.string(), v.description('Channel name')), - tags: v.pipe(v.optional(v.array(v.string())), v.readonly(), v.description('Tags')), - att: v.pipe(v.optional(v.unknown()), v.description('Attachment')), - rpc: v.pipe(v.optional(v.array(v.string())), v.readonly(), v.description('Allowed remote methods')), - iat: v.pipe(v.number(), v.description('Issued at time in seconds')), - exp: v.pipe(v.number(), v.description('Expiration time in seconds')), -}) - -/** - * Signs and encodes a token payload. - */ -export function signDurableIteratorToken(secret: string, payload: DurableIteratorTokenPayload): Promise { - return sign(stringifyJSON(payload), secret) -} - -/** - * Verifies a token and returns the payload if valid. - */ -export async function verifyDurableIteratorToken(secret: string, token: string): Promise { - try { - const payload = parseEmptyableJSON(await unsign(token, secret)) - - if (!v.is(DurableIteratorTokenPayloadSchema, payload)) { - return undefined - } - - if (payload.exp < (Date.now() / 1000)) { - return undefined - } - - return payload - } - catch { - // parseEmptyableJSON can throw error if the token contains invalid json - return undefined - } -} - -/** - * Extracts the payload from a token without verifying its signature. - * - * @throws if invalid format - */ -export function parseDurableIteratorToken(token: string | null | undefined): DurableIteratorTokenPayload { - try { - const payload = parseEmptyableJSON(getSignedValue(token)) - return v.parse(DurableIteratorTokenPayloadSchema, payload) - } - catch (error) { - throw new DurableIteratorError('Invalid token payload', { cause: error }) - } -} diff --git a/packages/durable-iterator/tests/shared.ts b/packages/durable-iterator/tests/shared.ts deleted file mode 100644 index bc6539a50..000000000 --- a/packages/durable-iterator/tests/shared.ts +++ /dev/null @@ -1,38 +0,0 @@ -import Database from 'better-sqlite3' - -export function createDurableObjectState(): any { - const db = new Database(':memory:') - - return { - storage: { - sql: { - exec: (query: string, ...bindings: any[]) => { - const method = query.includes('SELECT') || query.includes('RETURNING') ? 'all' : 'run' - const result = db.prepare(query)[method](...bindings) - - if (method === 'all') { - return { - one: () => (result as any)[0], - toArray: () => result, - } - } - }, - }, - }, - waitUntil: vi.fn(), - acceptWebSocket: vi.fn(), - getWebSockets: vi.fn(() => []), - } -} - -export function createCloudflareWebsocket(): any { - let attachment: any = null - - return { - readyState: 1, - send: vi.fn(), - close: vi.fn(), - serializeAttachment: vi.fn((newAttachment) => { attachment = newAttachment }), - deserializeAttachment: vi.fn(() => attachment), - } -} diff --git a/packages/durable-iterator/tsconfig.json b/packages/durable-iterator/tsconfig.json deleted file mode 100644 index e6e4bd697..000000000 --- a/packages/durable-iterator/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "../../tsconfig.lib.json", - "compilerOptions": { - "types": ["node", "@cloudflare/workers-types"] - }, - "references": [ - { "path": "../client" }, - { "path": "../server" }, - { "path": "../shared" } - ], - "include": ["src"], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] -} diff --git a/packages/durable-iterator/tsconfig.test.json b/packages/durable-iterator/tsconfig.test.json deleted file mode 100644 index a11a54216..000000000 --- a/packages/durable-iterator/tsconfig.test.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "experimentalDecorators": true, - "types": ["node", "@cloudflare/workers-types", "vitest/globals"] - }, - "references": [ - { "path": "./tsconfig.json" } - ], - "include": [ - "tests", - "src/**/*.test.*", - "src/**/*.test-d.ts" - ] -} diff --git a/packages/hey-api/.gitignore b/packages/effect/.gitignore similarity index 100% rename from packages/hey-api/.gitignore rename to packages/effect/.gitignore diff --git a/packages/effect/README.md b/packages/effect/README.md new file mode 100644 index 000000000..83b57e253 --- /dev/null +++ b/packages/effect/README.md @@ -0,0 +1,188 @@ +

oRPC - Typesafe APIs Made Simple 🪄

+ + + +## Documentation + +You can read the documentation [here](https://orpc.dev). + +## Packages + +**Core** + +- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Define type-safe contracts for your API. +- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Implement your API or contract on the server. +- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API from the client with full type safety. +- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Bring OpenAPI compatibility to your APIs. + +**Schema validation** + +- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): Integrate with [Zod](https://zod.dev/). +- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): Integrate with [Valibot](https://valibot.dev/). +- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): Integrate with [ArkType](https://arktype.io/). + +**Framework & ecosystem integrations** + +- [@orpc/next](https://www.npmjs.com/package/@orpc/react): Use oRPC with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). +- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). +- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). + +**Built-in features** + +- [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub. +- [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting. +- [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. + +**Observability** + +- [@orpc/opentelemetry](https://www.npmjs.com/package/@orpc/opentelemetry): Instrument your API with [OpenTelemetry](https://opentelemetry.io/). +- [@orpc/pino](https://www.npmjs.com/package/@orpc/pino): Log with [Pino](https://getpino.io/). +- [@orpc/evlog](https://www.npmjs.com/package/@orpc/evlog): Log with [Evlog](https://evlog.dev/). + +## Sponsors + +Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). Every bit helps! 🚀 + +### 🏆 Platinum Sponsor + + + + + +
ScreenshotOne.com
ScreenshotOne.com
+ +### 🥈 Silver Sponsor + + + + + +
村上さん
村上さん
+ +### Generous Sponsors + + + + + +
LN Markets
LN Markets
+ +### Sponsors + + + + + + + + + + + + + + + + + + + + + + + + +
Reece McDonald
Reece McDonald
nk
nk
supastarter
supastarter
Dexter Miguel
Dexter Miguel
herrfugbaum
herrfugbaum
Ryota Murakami
Ryota Murakami
David Cramer
David Cramer
Valerii Petryniak
Valerii Petryniak
Valerii Strilets
Valerii Strilets
Kyle Mistele
Kyle Mistele
Andrew Peters
Andrew Peters
Ryan Vogel
Ryan Vogel
christ12938
christ12938
Peter Adam
Peter Adam
Ryan Soderberg
Ryan Soderberg
shota
shota
+ +### Backers + + + + + + + + + + + + + + + + + + + + + + + + + +
David Walsh
David Walsh
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
Alex
Alex
nattstack
nattstack
Andrey Gubanov
Andrey Gubanov
+ +### Past Sponsors + +

+ Maxie + Stijn Timmer + あわわわとーにゅ + Zuplo + motopods + Francisco Hermida + Théo LUDWIG + Abhay Ramesh + shr.ink oü + 0x4e32 + Ryuz + happyboy + yicchi + Saksham + Roman Hrynevych + rokitg + Omar Khatib + Yu-Sabo + Bapusaheb Patil + grim + Nelson Lai + Lê Cao Nguyên + Robert Soriano + SKostyukovich + Fabworks + Novak Antonijevic + Laduni Estu Syalwa + Chen, Zhi-Yuan + Illarion Koperski + Anees Iqbal + Sefa Eyeoglu + Adam Tkaczyk + plancraft +

+ +## 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/effect/package.json b/packages/effect/package.json new file mode 100644 index 000000000..bddfca2c1 --- /dev/null +++ b/packages/effect/package.json @@ -0,0 +1,65 @@ +{ + "name": "@orpc/experimental-effect", + "type": "module", + "version": "1.13.4", + "license": "MIT", + "homepage": "https://orpc.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/middleapi/orpc.git", + "directory": "packages/effect" + }, + "keywords": [ + "orpc", + "effect" + ], + "sideEffects": [ + "./dist/extensions/effect.mjs", + "./dist/extensions/input-output.mjs" + ], + "publishConfig": { + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + }, + "./extensions/effect": { + "types": "./dist/extensions/effect.d.mts", + "import": "./dist/extensions/effect.mjs", + "default": "./dist/extensions/effect.mjs" + }, + "./extensions/input-output": { + "types": "./dist/extensions/input-output.d.mts", + "import": "./dist/extensions/input-output.mjs", + "default": "./dist/extensions/input-output.mjs" + } + } + }, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./extensions/effect": "./src/extensions/effect.ts", + "./extensions/input-output": "./src/extensions/input-output.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "unbuild", + "type:check": "tsc -b" + }, + "peerDependencies": { + "effect": ">=3.21.2" + }, + "dependencies": { + "@orpc/contract": "workspace:*", + "@orpc/json-schema": "workspace:*", + "@orpc/server": "workspace:*", + "@orpc/shared": "workspace:*" + }, + "devDependencies": { + "effect": "^3.21.3" + } +} diff --git a/packages/effect/src/context.ts b/packages/effect/src/context.ts new file mode 100644 index 000000000..6cfbb9559 --- /dev/null +++ b/packages/effect/src/context.ts @@ -0,0 +1,49 @@ +import type { AnyProcedure } from '@orpc/server' +import type { Effect, Context as EffectContext } from 'effect' + +export interface WithEffectContext { + /** + * A pre-built Effect context providing the services available within this oRPC context. + * Automatically provided to any effect that runs under this context. + * + * @example + * ```ts + * import { Context } from 'effect' + * + * interface ServerContext extends WithEffectContext {} + * + * const context: ServerContext = { + * '~effect/context': Context.empty().pipe( + * Context.add(Random, { next: Effect.sync(() => Math.random()) }), + * ), + * } + * ``` + */ + ['~effect/context']: EffectContext.Context + + /** + * An optional hook to wrap any effect before it is executed within this oRPC context. + * Useful for adding observability, tracing, or error handling. + * + * @example + * ```ts + * import { Resource, Tracer } from '@effect/opentelemetry' + * import { Context, Effect, Layer } from 'effect' + * + * interface ServerContext extends WithEffectContext {} + * + * const TracingLive = Tracer.layerGlobal.pipe( + * Layer.provide(Resource.layerFromEnv()), + * ) + * + * const context: ServerContext = { + * '~effect/context': Context.empty(), + * '~effect/wrap': (effect) => effect.pipe(Effect.provide(TracingLive)), + * } + * ``` + */ + ['~effect/wrap']?: ( + effect: Effect.Effect, + opts: { path: string[], procedure: AnyProcedure, signal?: undefined | AbortSignal }, + ) => Effect.Effect +} diff --git a/packages/effect/src/converter.test.ts b/packages/effect/src/converter.test.ts new file mode 100644 index 000000000..5f358741b --- /dev/null +++ b/packages/effect/src/converter.test.ts @@ -0,0 +1,55 @@ +import * as Effect from 'effect' +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { EffectSchemaToJsonSchemaConverter } from './converter' +import { toStandardSchema } from './schema' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('effectSchemaToJsonSchemaConverter', () => { + const converter = new EffectSchemaToJsonSchemaConverter() + + describe('.condition', () => { + it('returns true for effect schema', () => { + expect(converter.condition(toStandardSchema(Effect.Schema.String), 'input')).toBe(true) + }) + + it('returns false for non-effect schema', () => { + expect(converter.condition(z.string(), 'input')).toBe(false) + }) + + it('returns false for undefined schema', () => { + expect(converter.condition(undefined, 'input')).toBe(false) + }) + }) + + describe('.convert', () => { + it('converts effect schema to JSON schema', () => { + const schema = toStandardSchema(Effect.Schema.String) + const [jsonSchema, optional] = converter.convert(schema, 'input') + expect(jsonSchema).toMatchObject({ type: 'string' }) + expect(optional).toBe(false) + }) + + it('marks as optional if direction is input and schema accept undefined', () => { + const [, optional1] = converter.convert(toStandardSchema(Effect.Schema.Unknown), 'input') + expect(optional1).toBe(true) + }) + + it('marks as optional if direction is output and validated data is undefined', () => { + const [, optional1] = converter.convert(toStandardSchema(Effect.Schema.Unknown), 'output') + expect(optional1).toBe(true) + }) + + it('marks as required if validation throw', () => { + const schema = toStandardSchema(Effect.Schema.Unknown) + ;(schema as any)['~standard'].validate = () => { + throw new Error('test') + } + const [, optional] = converter.convert(schema, 'input') + expect(optional).toBe(false) + }) + }) +}) diff --git a/packages/effect/src/converter.ts b/packages/effect/src/converter.ts new file mode 100644 index 000000000..a58ac82c1 --- /dev/null +++ b/packages/effect/src/converter.ts @@ -0,0 +1,26 @@ +import type { AnySchema } from '@orpc/contract' +import type { JsonSchema, JsonSchemaConverter, JsonSchemaConverterDirection } from '@orpc/json-schema' +import type { Schema as EffectSchema } from 'effect' +import { JSONSchema } from 'effect' + +export class EffectSchemaToJsonSchemaConverter implements JsonSchemaConverter { + condition(schema: AnySchema | undefined, _direction: JsonSchemaConverterDirection): boolean { + return schema?.['~standard'].vendor === 'effect' + } + + convert(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): [jsonSchema: JsonSchema, optional: boolean] { + const effectSchema = schema as unknown as EffectSchema.Schema & AnySchema + const jsonSchema = JSONSchema.make(effectSchema, { target: 'jsonSchema2020-12' }) + + let optional = false + try { + const result = effectSchema['~standard'].validate(undefined) + if (!(result instanceof Promise) && !result.issues) { + optional = direction === 'input' ? true : result.value === undefined + } + } + catch {} + + return [jsonSchema as JsonSchema, optional] + } +} diff --git a/packages/effect/src/extensions/effect.test-d.ts b/packages/effect/src/extensions/effect.test-d.ts new file mode 100644 index 000000000..3a6625bd8 --- /dev/null +++ b/packages/effect/src/extensions/effect.test-d.ts @@ -0,0 +1,257 @@ +import type { Schema } from '@orpc/contract' +import type { Builder, BuilderWithInput, BuilderWithInputOutput, BuilderWithMiddlewares, BuilderWithOutput, DecoratedProcedure, ORPCErrorConstructorMap } from '@orpc/server' +import { ORPCError } from '@orpc/server' +import { Effect } from 'effect' +import { z } from 'zod' +import './effect' +import '@orpc/server/extensions/callable' // not sure why, but we need import this to make type work + +const errorMap = { + BASE: { + data: z.object({ id: z.string() }), + message: 'base', + }, +} + +const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) +const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) + +describe('adds .effect into Builder', async () => { + const builder = {} as Builder<{ auth: boolean }, typeof errorMap> + + it('simple', () => { + expectTypeOf(builder.effect(function* ({ errors, context }, input) { + expectTypeOf(errors).toEqualTypeOf>() + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ auth: boolean }>() + + return 'out' + })).toEqualTypeOf< + DecoratedProcedure< + { auth: boolean }, + object, + Schema, + Schema, + typeof errorMap, + never + > + >() + }) + + it('return ORPCError', () => { + expectTypeOf(builder.effect(function* () { + // use error that has properties that ORPCError doesn't have. + yield* Effect.fail({ _tags: 'e', non_exists_in_ORPCError: 'abc' }) + yield* Effect.fail(new ORPCError('CONFLICT', { data: 123 })) + + if (Math.random() > 0.5) { + return new ORPCError('BAD_REQUEST', { data: 'data' }) + } + + return 'out' + })).toEqualTypeOf< + DecoratedProcedure< + { auth: boolean }, + object, + Schema, + Schema<'out'>, + typeof errorMap, + ORPCError<'CONFLICT', number> | ORPCError<'BAD_REQUEST', string> + > + >() + }) +}) + +describe('adds .effect into BuilderWithMiddlewares', async () => { + const builder = {} as BuilderWithMiddlewares<{ auth: boolean }, { extra: boolean }, typeof errorMap> + + it('simple', () => { + expectTypeOf(builder.effect(function* ({ errors, context }, input) { + expectTypeOf(errors).toEqualTypeOf>() + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ auth: boolean } & { extra: boolean }>() + + return 'out' + })).toEqualTypeOf< + DecoratedProcedure< + { auth: boolean }, + { extra: boolean }, + Schema, + Schema, + typeof errorMap, + never + > + >() + }) + + it('return ORPCError', () => { + expectTypeOf(builder.effect(function* () { + // use error that has properties that ORPCError doesn't have. + yield* Effect.fail({ _tags: 'e', non_exists_in_ORPCError: 'abc' }) + yield* Effect.fail(new ORPCError('CONFLICT', { data: 123 })) + + if (Math.random() > 0.5) { + return new ORPCError('BAD_REQUEST', { data: 'data' }) + } + + return 'out' + })).toEqualTypeOf< + DecoratedProcedure< + { auth: boolean }, + { extra: boolean }, + Schema, + Schema<'out'>, + typeof errorMap, + ORPCError<'CONFLICT', number> | ORPCError<'BAD_REQUEST', string> + > + >() + }) +}) + +describe('adds .effect into BuilderWithInput', async () => { + const builder = {} as BuilderWithInput<{ auth: boolean }, { extra: boolean }, typeof schema1, typeof errorMap> + + it('simple', () => { + expectTypeOf(builder.effect(function* ({ errors, context }, input) { + expectTypeOf(errors).toEqualTypeOf>() + expectTypeOf(input).toEqualTypeOf<{ schema1: string }>() + expectTypeOf(context).toEqualTypeOf<{ auth: boolean } & { extra: boolean }>() + + return 'out' + })).toEqualTypeOf< + DecoratedProcedure< + { auth: boolean }, + { extra: boolean }, + typeof schema1, + Schema, + typeof errorMap, + never + > + >() + }) + + it('return ORPCError', () => { + expectTypeOf(builder.effect(function* () { + // use error that has properties that ORPCError doesn't have. + yield* Effect.fail({ _tags: 'e', non_exists_in_ORPCError: 'abc' }) + yield* Effect.fail(new ORPCError('CONFLICT', { data: 123 })) + + if (Math.random() > 0.5) { + return new ORPCError('BAD_REQUEST', { data: 'data' }) + } + + return 'out' + })).toEqualTypeOf< + DecoratedProcedure< + { auth: boolean }, + { extra: boolean }, + typeof schema1, + Schema<'out'>, + typeof errorMap, + ORPCError<'CONFLICT', number> | ORPCError<'BAD_REQUEST', string> + > + >() + }) +}) + +describe('adds .effect into BuilderWithOutput', async () => { + const builder = {} as BuilderWithOutput<{ auth: boolean }, { extra: boolean }, typeof schema2, typeof errorMap> + + it('simple', () => { + expectTypeOf(builder.effect(function* ({ errors, context }, input) { + expectTypeOf(errors).toEqualTypeOf>() + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ auth: boolean } & { extra: boolean }>() + + return { schema2: 123 } + })).toEqualTypeOf< + DecoratedProcedure< + { auth: boolean }, + { extra: boolean }, + Schema, + typeof schema2, + typeof errorMap, + never + > + >() + + // @ts-expect-error - output is invalid + void builder.effect(function* ({ errors, context }, input) { + return 'invalid' + }) + }) + + it('return ORPCError', () => { + expectTypeOf(builder.effect(function* () { + // use error that has properties that ORPCError doesn't have. + yield* Effect.fail({ _tags: 'e', non_exists_in_ORPCError: 'abc' }) + yield* Effect.fail(new ORPCError('CONFLICT', { data: 123 })) + + if (Math.random() > 0.5) { + return new ORPCError('BAD_REQUEST', { data: 'data' }) + } + + return { schema2: 123 } + })).toEqualTypeOf< + DecoratedProcedure< + { auth: boolean }, + { extra: boolean }, + Schema, + typeof schema2, + typeof errorMap, + ORPCError<'CONFLICT', number> | ORPCError<'BAD_REQUEST', string> + > + >() + }) +}) + +describe('adds .effect into BuilderWithInputOutput', async () => { + const builder = {} as BuilderWithInputOutput<{ auth: boolean }, { extra: boolean }, typeof schema1, typeof schema2, typeof errorMap> + + it('simple', () => { + expectTypeOf(builder.effect(function* ({ errors, context }, input) { + expectTypeOf(errors).toEqualTypeOf>() + expectTypeOf(input).toEqualTypeOf<{ schema1: string }>() + expectTypeOf(context).toEqualTypeOf<{ auth: boolean } & { extra: boolean }>() + + return { schema2: 123 } + })).toEqualTypeOf< + DecoratedProcedure< + { auth: boolean }, + { extra: boolean }, + typeof schema1, + typeof schema2, + typeof errorMap, + never + > + >() + + // @ts-expect-error - output is invalid + void builder.effect(function* ({ errors, context }, input) { + return 'invalid' + }) + }) + + it('return ORPCError', () => { + expectTypeOf(builder.effect(function* () { + // use error that has properties that ORPCError doesn't have. + yield* Effect.fail({ _tags: 'e', non_exists_in_ORPCError: 'abc' }) + yield* Effect.fail(new ORPCError('CONFLICT', { data: 123 })) + + if (Math.random() > 0.5) { + return new ORPCError('BAD_REQUEST', { data: 'data' }) + } + + return { schema2: 123 } + })).toEqualTypeOf< + DecoratedProcedure< + { auth: boolean }, + { extra: boolean }, + typeof schema1, + typeof schema2, + typeof errorMap, + ORPCError<'CONFLICT', number> | ORPCError<'BAD_REQUEST', string> + > + >() + }) +}) diff --git a/packages/effect/src/extensions/effect.test.ts b/packages/effect/src/extensions/effect.test.ts new file mode 100644 index 000000000..503268ea5 --- /dev/null +++ b/packages/effect/src/extensions/effect.test.ts @@ -0,0 +1,57 @@ +import { oc } from '@orpc/contract' +import { call, DecoratedProcedure, implement, ImplementedProcedure, os } from '@orpc/server' +import { z } from 'zod' +import * as Handler from '../handler' +import '@orpc/server/extensions/callable' // not sure why, but we need import this to make type work +import './effect' + +const handlerGen = vi.spyOn(Handler, 'handlerGen') + +beforeEach(() => { + vi.clearAllMocks() +}) + +it('adds .effect into Builder', async () => { + const InputSchema = z.string() + const handler = vi.fn(function* ({ input, context }) { + return { output: true, auth: context.auth, input } + }) + const procedure = os + .$context<{ auth: boolean }>() + .input(InputSchema) + .effect(handler) + + expect(handlerGen).toHaveBeenCalledTimes(1) + expect(handlerGen).toHaveBeenNthCalledWith(1, handler) + + expect(procedure).toBeInstanceOf(DecoratedProcedure) + expect(procedure['~orpc'].handler).toBe(handlerGen.mock.results[0]?.value) + expect(procedure['~orpc'].inputSchemas).toEqual([InputSchema]) + + await expect(call(procedure, 'input', { context: { auth: false } })).resolves.toEqual({ output: true, auth: false, input: 'input' }) +}) + +it('adds .effect into ProcedureImplementer', async () => { + const InputSchema = z.string() + const handler = vi.fn(function* ({ input, context }) { + return { output: true, auth: context.auth, input } + }) + + const os = implement({ + ping: oc.input(InputSchema), + }) + + const procedure = os + .$context<{ auth: boolean }>() + .ping + .effect(handler) + + expect(handlerGen).toHaveBeenCalledTimes(1) + expect(handlerGen).toHaveBeenNthCalledWith(1, handler) + + expect(procedure).toBeInstanceOf(ImplementedProcedure) + expect(procedure['~orpc'].handler).toBe(handlerGen.mock.results[0]?.value) + expect(procedure['~orpc'].inputSchemas).toEqual([InputSchema]) + + await expect(call(procedure, 'input', { context: { auth: false } })).resolves.toEqual({ output: true, auth: false, input: 'input' }) +}) diff --git a/packages/effect/src/extensions/effect.ts b/packages/effect/src/extensions/effect.ts new file mode 100644 index 000000000..e9135d16a --- /dev/null +++ b/packages/effect/src/extensions/effect.ts @@ -0,0 +1,199 @@ +import type { AnySchema, ErrorMap, InferSchemaInput, InferSchemaOutput, InitialInputSchema, Schema } from '@orpc/contract' +import type { AnyORPCError, Context, DecoratedProcedure, ImplementedProcedure, MergedContext, ORPCErrorConstructorMap } from '@orpc/server' +import type { Effect } from 'effect' +import type { YieldWrap } from 'effect/Utils' +import type { WithEffectContext } from '../context' +import type { HandlerGen, InferYieldError } from '../handler' +import { Builder, ProcedureImplementer } from '@orpc/server' +import { handlerGen } from '../handler' + +declare module '@orpc/server' { + interface Builder< + TInitialContext extends Context, + TErrorMap extends ErrorMap, + > { + effect< + TYield extends YieldWrap ? S : never + >>, + TReturn, + >( + handler: HandlerGen< + TInitialContext, + InferSchemaOutput, + TYield, + TReturn, + ORPCErrorConstructorMap + >, + ): DecoratedProcedure< + TInitialContext, + object, + InitialInputSchema, + Schema>, + TErrorMap, + Extract, AnyORPCError> + > + } + + interface BuilderWithMiddlewares< + TInitialContext extends Context, + TInjectedContext extends Context, + TErrorMap extends ErrorMap, + > { + effect< + TYield extends YieldWrap extends WithEffectContext ? S : never + >>, + TReturn, + >( + handler: HandlerGen< + MergedContext, + InferSchemaOutput, + TYield, + TReturn, + ORPCErrorConstructorMap + >, + ): DecoratedProcedure< + TInitialContext, + TInjectedContext, + InitialInputSchema, + Schema>, + TErrorMap, + Extract, AnyORPCError> + > + } + + interface BuilderWithInput< + TInitialContext extends Context, + TInjectedContext extends Context, + TInputSchema extends AnySchema, + TErrorMap extends ErrorMap, + > { + effect< + TYield extends YieldWrap extends WithEffectContext ? S : never + >>, + TReturn, + >( + handler: HandlerGen< + MergedContext, + InferSchemaOutput, + TYield, + TReturn, + ORPCErrorConstructorMap + >, + ): DecoratedProcedure< + TInitialContext, + TInjectedContext, + TInputSchema, + Schema>, + TErrorMap, + Extract, AnyORPCError> + > + } + + interface BuilderWithOutput< + TInitialContext extends Context, + TInjectedContext extends Context, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + > { + effect< + TYield extends YieldWrap extends WithEffectContext ? S : never + >>, + TReturn extends InferSchemaInput | AnyORPCError, + >( + handler: HandlerGen< + MergedContext, + InferSchemaOutput, + TYield, + TReturn, + ORPCErrorConstructorMap + >, + ): DecoratedProcedure< + TInitialContext, + TInjectedContext, + InitialInputSchema, + TOutputSchema, + TErrorMap, + Extract, AnyORPCError> + > + } + + interface BuilderWithInputOutput< + TInitialContext extends Context, + TInjectedContext extends Context, + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + > { + effect< + TYield extends YieldWrap extends WithEffectContext ? S : never + >>, + TReturn extends InferSchemaInput | AnyORPCError, + >( + handler: HandlerGen< + MergedContext, + InferSchemaOutput, + TYield, + TReturn, + ORPCErrorConstructorMap + >, + ): DecoratedProcedure< + TInitialContext, + TInjectedContext, + TInputSchema, + TOutputSchema, + TErrorMap, + Extract, AnyORPCError> + > + } + + interface ProcedureImplementer< + TInitialContext extends Context, + TInjectedContext extends Context, + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + > { + effect( + handler: HandlerGen< + MergedContext, + InferSchemaOutput, + YieldWrap extends WithEffectContext ? S : never + >>, + AnyORPCError | InferSchemaInput, + ORPCErrorConstructorMap + >, + ): ImplementedProcedure< + TInitialContext, + TInjectedContext, + TInputSchema, + TOutputSchema, + TErrorMap + > + } +} + +Builder.prototype.effect = function effect(handler) { + return this.handler(handlerGen(handler)) +} + +ProcedureImplementer.prototype.effect = function effect(handler) { + return this.handler(handlerGen(handler)) +} diff --git a/packages/effect/src/extensions/input-output.test-d.ts b/packages/effect/src/extensions/input-output.test-d.ts new file mode 100644 index 000000000..f7f86d25a --- /dev/null +++ b/packages/effect/src/extensions/input-output.test-d.ts @@ -0,0 +1,215 @@ +import type { ContractBuilder, MergedSchema, ProcedureContractBuilderWithInput, ProcedureContractBuilderWithInputOutput, ProcedureContractBuilderWithOutput } from '@orpc/contract' +import type { Builder, BuilderWithInput, BuilderWithInputOutput, BuilderWithMiddlewares, BuilderWithOutput, Schema } from '@orpc/server' +import { Schema as EffectSchema } from 'effect' +import { z } from 'zod' +import './input-output' + +const errorMap = { + BASE: { + data: z.object({ id: z.string() }), + message: 'base', + }, +} + +const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) +const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) + +const NumberFromString = EffectSchema.transform( + EffectSchema.String, + EffectSchema.JsonNumber, + { + strict: true, + decode: literal => Number(literal), + encode: number => number.toString(), + }, +) + +it('adds .input .output into ContractBuilder', async () => { + const builder = {} as ContractBuilder + + expectTypeOf(builder.input(NumberFromString)).toEqualTypeOf< + ProcedureContractBuilderWithInput< + Schema, + typeof errorMap + > + >() + + expectTypeOf(builder.output(NumberFromString)).toEqualTypeOf< + ProcedureContractBuilderWithOutput< + Schema, + typeof errorMap + > + >() +}) + +describe('adds .input .output into ProcedureContractBuilderWithInput', async () => { + const builder = {} as ProcedureContractBuilderWithInput + + expectTypeOf(builder.input(NumberFromString)).toEqualTypeOf< + ProcedureContractBuilderWithInput< + MergedSchema, typeof schema1>, + typeof errorMap + > + >() + + expectTypeOf(builder.output(NumberFromString)).toEqualTypeOf< + ProcedureContractBuilderWithInputOutput< + typeof schema1, + Schema, + typeof errorMap + > + >() +}) + +describe('adds .input .output into ProcedureContractBuilderWithOutput', async () => { + const builder = {} as ProcedureContractBuilderWithOutput + + expectTypeOf(builder.input(NumberFromString)).toEqualTypeOf< + ProcedureContractBuilderWithInputOutput< + Schema, + typeof schema2, + typeof errorMap + > + >() + + expectTypeOf(builder.output(NumberFromString)).toEqualTypeOf< + ProcedureContractBuilderWithOutput< + MergedSchema, typeof schema2>, + typeof errorMap + > + >() +}) + +describe('adds .input .output into ProcedureContractBuilderWithInputOutput', async () => { + const builder = {} as ProcedureContractBuilderWithInputOutput + + expectTypeOf(builder.input(NumberFromString)).toEqualTypeOf< + ProcedureContractBuilderWithInputOutput< + MergedSchema, typeof schema1>, + typeof schema2, + typeof errorMap + > + >() + + expectTypeOf(builder.output(NumberFromString)).toEqualTypeOf< + ProcedureContractBuilderWithInputOutput< + typeof schema1, + MergedSchema, typeof schema2>, + typeof errorMap + > + >() +}) + +it('adds .input .output into Builder', async () => { + const builder = {} as Builder<{ auth: boolean }, typeof errorMap> + + expectTypeOf(builder.input(NumberFromString)).toEqualTypeOf< + BuilderWithInput< + { auth: boolean }, + object, + Schema, + typeof errorMap + > + >() + + expectTypeOf(builder.output(NumberFromString)).toEqualTypeOf< + BuilderWithOutput< + { auth: boolean }, + object, + Schema, + typeof errorMap + > + >() +}) + +describe('adds .input .output into BuilderWithMiddlewares', async () => { + const builder = {} as BuilderWithMiddlewares<{ auth: boolean }, { extra: boolean }, typeof errorMap> + + expectTypeOf(builder.input(NumberFromString)).toEqualTypeOf< + BuilderWithInput< + { auth: boolean }, + { extra: boolean }, + Schema, + typeof errorMap + > + >() + + expectTypeOf(builder.output(NumberFromString)).toEqualTypeOf< + BuilderWithOutput< + { auth: boolean }, + { extra: boolean }, + Schema, + typeof errorMap + > + >() +}) + +describe('adds .input .output into BuilderWithInput', async () => { + const builder = {} as BuilderWithInput<{ auth: boolean }, { extra: boolean }, typeof schema1, typeof errorMap> + + expectTypeOf(builder.input(NumberFromString)).toEqualTypeOf< + BuilderWithInput< + { auth: boolean }, + { extra: boolean }, + MergedSchema, typeof schema1>, + typeof errorMap + > + >() + + expectTypeOf(builder.output(NumberFromString)).toEqualTypeOf< + BuilderWithInputOutput< + { auth: boolean }, + { extra: boolean }, + typeof schema1, + Schema, + typeof errorMap + > + >() +}) + +describe('adds .input .output into BuilderWithOutput', async () => { + const builder = {} as BuilderWithOutput<{ auth: boolean }, { extra: boolean }, typeof schema2, typeof errorMap> + + expectTypeOf(builder.input(NumberFromString)).toEqualTypeOf< + BuilderWithInputOutput< + { auth: boolean }, + { extra: boolean }, + Schema, + typeof schema2, + typeof errorMap + > + >() + + expectTypeOf(builder.output(NumberFromString)).toEqualTypeOf< + BuilderWithOutput< + { auth: boolean }, + { extra: boolean }, + MergedSchema, typeof schema2>, + typeof errorMap + > + >() +}) + +describe('adds .input .output into BuilderWithInputOutput', async () => { + const builder = {} as BuilderWithInputOutput<{ auth: boolean }, { extra: boolean }, typeof schema1, typeof schema2, typeof errorMap> + + expectTypeOf(builder.input(NumberFromString)).toEqualTypeOf< + BuilderWithInputOutput< + { auth: boolean }, + { extra: boolean }, + MergedSchema, typeof schema1>, + typeof schema2, + typeof errorMap + > + >() + + expectTypeOf(builder.output(NumberFromString)).toEqualTypeOf< + BuilderWithInputOutput< + { auth: boolean }, + { extra: boolean }, + typeof schema1, + MergedSchema, typeof schema2>, + typeof errorMap + > + >() +}) diff --git a/packages/effect/src/extensions/input-output.test.ts b/packages/effect/src/extensions/input-output.test.ts new file mode 100644 index 000000000..a9366475b --- /dev/null +++ b/packages/effect/src/extensions/input-output.test.ts @@ -0,0 +1,47 @@ +import { oc } from '@orpc/contract' +import { os } from '@orpc/server' +import { Schema as EffectSchema } from 'effect' +import { z } from 'zod' +import * as SchemaModule from '../schema' +import './input-output' + +const toStandardSchemaSpy = vi.spyOn(SchemaModule, 'toStandardSchema') + +beforeEach(() => { + vi.clearAllMocks() +}) + +const standardSchema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) +const standardSchema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) +const effectSchema1 = EffectSchema.Struct({ schema1: EffectSchema.Number }) +const effectSchema2 = EffectSchema.Struct({ schema2: EffectSchema.Number }) + +it('accepts both standard schema and effect schema in ContractBuilder', () => { + const procedure = oc + .input(standardSchema1) + .input(effectSchema1) + .output(effectSchema2) + .output(standardSchema2) + + expect(toStandardSchemaSpy).toHaveBeenCalledTimes(2) + expect(toStandardSchemaSpy).toHaveBeenNthCalledWith(1, effectSchema1) + expect(toStandardSchemaSpy).toHaveBeenNthCalledWith(2, effectSchema2) + + expect(procedure['~orpc'].inputSchemas).toEqual([standardSchema1, toStandardSchemaSpy.mock.results[0]?.value]) + expect(procedure['~orpc'].outputSchemas).toEqual([toStandardSchemaSpy.mock.results[1]?.value, standardSchema2]) +}) + +it('accepts both standard schema and effect schema in Builder', () => { + const builder = os + .input(standardSchema1) + .input(effectSchema1) + .output(effectSchema2) + .output(standardSchema2) + + expect(toStandardSchemaSpy).toHaveBeenCalledTimes(2) + expect(toStandardSchemaSpy).toHaveBeenNthCalledWith(1, effectSchema1) + expect(toStandardSchemaSpy).toHaveBeenNthCalledWith(2, effectSchema2) + + expect(builder['~orpc'].inputSchemas).toEqual([standardSchema1, toStandardSchemaSpy.mock.results[0]?.value]) + expect(builder['~orpc'].outputSchemas).toEqual([toStandardSchemaSpy.mock.results[1]?.value, standardSchema2]) +}) diff --git a/packages/effect/src/extensions/input-output.ts b/packages/effect/src/extensions/input-output.ts new file mode 100644 index 000000000..74563f88e --- /dev/null +++ b/packages/effect/src/extensions/input-output.ts @@ -0,0 +1,156 @@ +import type { AnySchema, ErrorMap, MergedSchema, Schema } from '@orpc/contract' +import type { Context } from '@orpc/server' +import { ContractBuilder } from '@orpc/contract' +import { Builder } from '@orpc/server' +import { Schema as EffectSchema } from 'effect' +import { toStandardSchema } from '../schema' + +declare module '@orpc/contract' { + interface ContractBuilder< + TErrorMap extends ErrorMap, + > { + input( + schema: EffectSchema.Schema, + ): ProcedureContractBuilderWithInput, TErrorMap> + + output( + schema: EffectSchema.Schema, + ): ProcedureContractBuilderWithOutput, TErrorMap> + } + + interface ProcedureContractBuilderWithInput< + TInputSchema extends AnySchema, + TErrorMap extends ErrorMap, + > { + input( + schema: EffectSchema.Schema, + ): ProcedureContractBuilderWithInput, TInputSchema>, TErrorMap> + + output( + schema: EffectSchema.Schema, + ): ProcedureContractBuilderWithInputOutput, TErrorMap> + } + + interface ProcedureContractBuilderWithOutput< + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + > { + input( + schema: EffectSchema.Schema, + ): ProcedureContractBuilderWithInputOutput, TOutputSchema, TErrorMap> + + output( + schema: EffectSchema.Schema, + ): ProcedureContractBuilderWithOutput, TOutputSchema>, TErrorMap> + } + + interface ProcedureContractBuilderWithInputOutput< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + > { + input( + schema: EffectSchema.Schema, + ): ProcedureContractBuilderWithInputOutput, TInputSchema>, TOutputSchema, TErrorMap> + + output( + schema: EffectSchema.Schema, + ): ProcedureContractBuilderWithInputOutput, TOutputSchema>, TErrorMap> + } +} + +const OriginalContractBuilderInput = ContractBuilder.prototype.input +ContractBuilder.prototype.input = function input(schema: AnySchema | EffectSchema.Schema) { + return OriginalContractBuilderInput.bind(this)(EffectSchema.isSchema(schema) ? toStandardSchema(schema) : schema) +} + +const OriginalContractBuilderOutput = ContractBuilder.prototype.output +ContractBuilder.prototype.output = function output(schema: AnySchema | EffectSchema.Schema) { + return OriginalContractBuilderOutput.bind(this)(EffectSchema.isSchema(schema) ? toStandardSchema(schema) : schema) +} + +declare module '@orpc/server' { + interface Builder< + TInitialContext extends Context, + TErrorMap extends ErrorMap, + > { + input( + schema: EffectSchema.Schema, + ): BuilderWithInput, TErrorMap> + + output( + schema: EffectSchema.Schema, + ): BuilderWithOutput, TErrorMap> + } + + interface BuilderWithMiddlewares< + TInitialContext extends Context, + TInjectedContext extends Context, + TErrorMap extends ErrorMap, + > { + input( + schema: EffectSchema.Schema, + ): BuilderWithInput, TErrorMap> + + output( + schema: EffectSchema.Schema, + ): BuilderWithOutput, TErrorMap> + } + + interface BuilderWithInput< + TInitialContext extends Context, + TInjectedContext extends Context, + TInputSchema extends AnySchema, + TErrorMap extends ErrorMap, + > { + input( + schema: EffectSchema.Schema, + ): BuilderWithInput, TInputSchema>, TErrorMap> + + output( + schema: EffectSchema.Schema, + ): BuilderWithInputOutput, TErrorMap> + } + + interface BuilderWithOutput< + TInitialContext extends Context, + TInjectedContext extends Context, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + > { + input( + schema: EffectSchema.Schema, + ): BuilderWithInputOutput, TOutputSchema, TErrorMap> + + output( + schema: EffectSchema.Schema, + ): BuilderWithOutput, TOutputSchema>, TErrorMap> + } + + interface BuilderWithInputOutput< + TInitialContext extends Context, + TInjectedContext extends Context, + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + > { + input( + schema: EffectSchema.Schema, + ): BuilderWithInputOutput, TInputSchema>, TOutputSchema, TErrorMap> + + output( + schema: EffectSchema.Schema, + ): BuilderWithInputOutput, TOutputSchema>, TErrorMap> + + } +} + +const OriginalBuilderInput = Builder.prototype.input +Builder.prototype.input = function input(schema: AnySchema | EffectSchema.Schema) { + return OriginalBuilderInput.bind(this)(EffectSchema.isSchema(schema) ? toStandardSchema(schema) : schema) +} + +const OriginalBuilderOutput = Builder.prototype.output +Builder.prototype.output = function output(schema: AnySchema | EffectSchema.Schema) { + return OriginalBuilderOutput.bind(this)(EffectSchema.isSchema(schema) ? toStandardSchema(schema) : schema) +} diff --git a/packages/effect/src/handler.test-d.ts b/packages/effect/src/handler.test-d.ts new file mode 100644 index 000000000..fcf376551 --- /dev/null +++ b/packages/effect/src/handler.test-d.ts @@ -0,0 +1,119 @@ +import type { InitialInputSchema, Schema } from '@orpc/contract' +import type { DecoratedProcedure, DefaultInitialContext, ORPCErrorConstructorMap } from '@orpc/server' +import type { WithEffectContext } from './context' +import { ORPCError, os } from '@orpc/server' +import { Context, Effect } from 'effect' +import { z } from 'zod' +import { handlerGen } from './handler' + +class Service1 extends Context.Tag('Service1')< + Service1, + { + readonly id: 'Service1' + } +>() {} + +class Service2 extends Context.Tag('Service2')< + Service2, + { + readonly id: 'Service2' + } +>() {} + +const errorMap = { + BASE: { + data: z.object({ id: z.string() }), + message: 'base', + }, +} + +const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) +const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) + +describe('handlerGen', () => { + it('works with pure os.handler', () => { + const procedure = os + .handler(handlerGen(function* () { + // use error that has properties that ORPCError doesn't have. + yield* Effect.fail({ _tags: 'e', non_exists_in_ORPCError: 'abc' }) + + yield* Effect.fail(new ORPCError('CONFLICT', { data: 1 })) + if (Math.random() < 0.5) { + return new ORPCError('GATEWAY_TIMEOUT', { data: '1' }) + } + + return true + })) + + expectTypeOf(procedure).toEqualTypeOf< + DecoratedProcedure< + DefaultInitialContext & object, + object, + InitialInputSchema, + Schema, + Record, + ORPCError<'GATEWAY_TIMEOUT', string> | ORPCError<'CONFLICT', number> + > + >() + }) + + it('can infer correct context, input, output, errors', async () => { + const procedure = os + .$context<{ auth: boolean }>() + .input(schema1) + .output(schema2) + .errors(errorMap) + .use(({ next }) => next({ context: { extra: true } })) + .handler(handlerGen(function* ({ input, context, errors }) { + expectTypeOf(input).toEqualTypeOf<{ schema1: string }>() + expectTypeOf(context).toEqualTypeOf<{ auth: boolean } & { extra: boolean } & Omit>() + expectTypeOf(errors).toEqualTypeOf>() + + // use error that has properties that ORPCError doesn't have. + yield* Effect.fail({ _tags: 'e', non_exists_in_ORPCError: 'abc' }) + yield* Effect.fail(new ORPCError('CONFLICT', { data: 1 })) + if (Math.random() < 0.5) { + return new ORPCError('GATEWAY_TIMEOUT', { data: '1' }) + } + + return { schema2: 123 } + })) + + expectTypeOf(procedure).toEqualTypeOf< + DecoratedProcedure< + { auth: boolean } & object, + Omit & { extra: boolean }, + typeof schema1, + typeof schema2, + typeof errorMap, + ORPCError<'GATEWAY_TIMEOUT', string> | ORPCError<'CONFLICT', number> + > + >() + + void os + .$context<{ auth: boolean }>() + .input(schema1) + .output(schema2) + .errors(errorMap) + .use(({ next }) => next({ context: { extra: true } })) + // @ts-expect-error - invalid output + .handler(handlerGen(function* () { + return 'invalid' + })) + }) + + it('can strict dependant-effect-service with WithEffectContext', async () => { + void os + .$context>() + .handler(handlerGen(function* () { + yield* Service1 + })) + + void os + .$context>() + // @ts-expect-error - Random2 is not provided + .handler(handlerGen(function* () { + yield* Service2 + })) + }) +}) diff --git a/packages/effect/src/handler.test.ts b/packages/effect/src/handler.test.ts new file mode 100644 index 000000000..8e7695852 --- /dev/null +++ b/packages/effect/src/handler.test.ts @@ -0,0 +1,185 @@ +import type { WithEffectContext } from './context' +import { call, ORPCError, os, type } from '@orpc/server' +import { Context, Effect } from 'effect' +import { handlerGen } from './handler' +import * as EffectModule from './runtime' + +const runPromiseSpy = vi.spyOn(EffectModule, 'runPromise') + +beforeEach(() => { + vi.clearAllMocks() +}) + +class Service1 extends Context.Tag('Service1')< + Service1, + { + readonly id: 'Service1' + } +>() {} + +class Service2 extends Context.Tag('Service2')< + Service2, + { + readonly id: 'Service2' + } +>() {} + +describe('handlerGen', () => { + it('works with native Effect syntax, and treat return/yield ORPCError as inferable', async () => { + await expect( + call(os.handler(handlerGen(function* () { + return 'output' + }))), + ).resolves.toEqual('output') + + const inferableError = new ORPCError('__TEST__') + ;(inferableError as any).inferable = true + + await expect( + call(os.handler(handlerGen(function* () { + return new ORPCError('__TEST__') + }))), + ).rejects.toThrow(inferableError) + + await expect( + call(os.handler(handlerGen(function* () { + yield* Effect.fail(new ORPCError('__TEST__')) + }))), + ).rejects.toThrow(inferableError) + }) + + it('throw original errors without fiber failure error wrapper', async () => { + const error = new Error('__TEST__') + await expect( + call(os.handler(handlerGen(function* () { + yield* Effect.fail(error) + }))), + ).rejects.toBe(error) + + expect(runPromiseSpy).toHaveBeenCalledTimes(1) + }) + + it('forwards the provided signal to runPromise', async () => { + const controller = new AbortController() + + await expect( + call( + os.handler(handlerGen(function* () { + return 'output' + })), + undefined, + { signal: controller.signal }, + ), + ).resolves.toEqual('output') + + expect(runPromiseSpy).toHaveBeenCalledTimes(1) + expect(runPromiseSpy).toHaveBeenNthCalledWith(1, expect.anything(), { signal: controller.signal }) + }) + + it('can access context, input, errors, signal, ...', async () => { + const procedure = os.input(type()).handler(handlerGen(function* ({ context, signal, lastEventId, path }, input) { + expect(input).toEqual('input') + expect(context).toEqual({ context: true }) + expect(signal).toBeInstanceOf(AbortSignal) + expect(lastEventId).toEqual('id') + expect(path).toEqual(['path']) + + return 'success' + })) + + await expect( + call( + procedure, + 'input', + { context: { context: true }, signal: AbortSignal.timeout(0), lastEventId: 'id', path: ['path'] }, + ), + ).resolves.toEqual('success') + }) + + it('can deal with effect context', async () => { + await expect(call( + os + .$context>() + .handler(handlerGen(function* () { + const service1 = yield* Service1 + return `output:${service1.id}` + })), + undefined, + { context: { '~effect/context': Context.empty().pipe(Context.add(Service1, { id: 'Service1' })) } }, + )).resolves.toEqual('output:Service1') + + await expect(call( + os + .$context() + .handler(handlerGen(function* () { + const service1 = yield* Service1 + const service2 = yield* Service2 + return `output:${service1.id}` + })), + undefined, + { context: { '~effect/context': Context.empty().pipe(Context.add(Service1, { id: 'Service1' })) } }, + )).rejects.toThrow('Service2') + }) + + it('can wrap effect execution and receives procedure options', async () => { + let wrappedCalls = 0 + let wrappedPath: string[] | undefined + let wrappedProcedure: unknown + let wrappedSignal: unknown + + const procedure = os + .$context>() + .handler(handlerGen(function* () { + return 'output' + })) + + const signal = AbortSignal.timeout(100) + + await expect(call( + procedure, + undefined, + { + path: ['wrapped', 'procedure'], + context: { + '~effect/context': Context.empty(), + '~effect/wrap': (effect, opts) => effect.pipe(Effect.tap(() => Effect.sync(() => { + wrappedCalls += 1 + wrappedPath = opts.path + wrappedProcedure = opts.procedure + wrappedSignal = opts.signal + }))), + }, + signal, + }, + )).resolves.toEqual('output') + + expect(wrappedCalls).toBe(1) + expect(wrappedPath).toEqual(['wrapped', 'procedure']) + expect(wrappedProcedure).toBe(procedure) + expect(wrappedSignal).toBe(signal) + }) + + it('wraps after succeedOnORPCError so wrap-thrown ORPCErrors stay non-inferable', async () => { + const error = new ORPCError('__TEST__') + + const procedure = os + .$context>() + .handler(handlerGen(function* () { + return 'output' + })) + + await expect(call( + procedure, + undefined, + { + context: { + '~effect/context': Context.empty(), + '~effect/wrap': () => Effect.fail(error) as any, + }, + }, + )).rejects.toBe(error) + + expect(error.inferable).toBe(false) + expect(error.defined).toBe(false) + }) +}) diff --git a/packages/effect/src/handler.ts b/packages/effect/src/handler.ts new file mode 100644 index 000000000..b97fef1ff --- /dev/null +++ b/packages/effect/src/handler.ts @@ -0,0 +1,64 @@ +import type { AnyORPCError, Context, ORPCErrorConstructorMap, ProcedureHandler, ProcedureHandlerOptions } from '@orpc/server' +import type { YieldWrap } from 'effect/Utils' +import type { WithEffectContext } from './context' +import { ORPCError } from '@orpc/server' +import { Effect, Context as EffectContext } from 'effect' +import { runPromise } from './runtime' + +export type InferYieldError = [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? E : never + +export interface HandlerGen< + TCurrentContext extends Context, + TInput, + TYield extends YieldWrap ? S : never + >>, + TReturn, + TErrorConstructorMap extends ORPCErrorConstructorMap, +> { + ( + opts: ProcedureHandlerOptions, + input: TInput, + ): Generator< + TYield, + TReturn, + never + > +} + +const succeedOnORPCError = Effect.catchAll(error => error instanceof ORPCError ? Effect.succeed(error) : Effect.fail(error)) + +export function handlerGen< + TCurrentContext extends Context, + TInput, + TErrorConstructorMap extends ORPCErrorConstructorMap, + TYield extends YieldWrap ? S : never + >>, + TReturn, +>( + handler: HandlerGen, +): ProcedureHandler, AnyORPCError>, TErrorConstructorMap> { + return (opts, input) => { + let ef = Effect + .gen(() => handler(opts, input)) + .pipe(succeedOnORPCError) as Effect.Effect, AnyORPCError>, Exclude, AnyORPCError>> + + if (EffectContext.isContext(opts.context['~effect/context'])) { + ef = ef.pipe(Effect.provide(opts.context['~effect/context'])) + } + + // MUST wrap after `.pipe(succeedOnORPCError)`. + // Otherwise, an ORPCError thrown by intercept would be incorrectly marked as an inferable error. + if (typeof opts.context['~effect/wrap'] === 'function') { + const intercept = opts.context['~effect/wrap'] as Exclude['~effect/wrap'], undefined> + ef = intercept(ef, opts) + } + + return runPromise(ef, { signal: opts.signal }) + } +} diff --git a/packages/effect/src/index.test.ts b/packages/effect/src/index.test.ts new file mode 100644 index 000000000..e4b57a2f8 --- /dev/null +++ b/packages/effect/src/index.test.ts @@ -0,0 +1,7 @@ +it('exports EffectSchemaToJsonSchemaConverter, handlerGen, toStandardSchema', async () => { + await expect(import('./index')).resolves.toMatchObject({ + handlerGen: expect.any(Function), + EffectSchemaToJsonSchemaConverter: expect.any(Function), + toStandardSchema: expect.any(Function), + }) +}) diff --git a/packages/effect/src/index.ts b/packages/effect/src/index.ts new file mode 100644 index 000000000..5ff167d48 --- /dev/null +++ b/packages/effect/src/index.ts @@ -0,0 +1,5 @@ +export * from './context' +export * from './converter' +export * from './handler' +export * from './runtime' +export * from './schema' diff --git a/packages/effect/src/runtime.test.ts b/packages/effect/src/runtime.test.ts new file mode 100644 index 000000000..7fc59df25 --- /dev/null +++ b/packages/effect/src/runtime.test.ts @@ -0,0 +1,92 @@ +import { Cause, Effect } from 'effect' +import { describe, expect, it } from 'vitest' +import { runPromise } from './runtime' + +describe('runPromise & extractErrorFromCause', () => { + describe('success', () => { + it('resolves with the effect value', async () => { + const result = await runPromise(Effect.succeed(42)) + expect(result).toBe(42) + }) + + it('resolves with non-primitive values', async () => { + const obj = { id: 1 } + const result = await runPromise(Effect.succeed(obj)) + expect(result).toBe(obj) + }) + }) + + describe('failure - throws original error without FiberFailure wrapper', () => { + it('interrupts the effect when the provided signal aborts', async () => { + await expect( + runPromise(Effect.never, { signal: AbortSignal.timeout(0) }), + ).rejects.toThrow(/Fiber interrupted/) + }) + + it('throws the original Error instance from Effect.fail', async () => { + const original = new TypeError('typed domain error') + + await expect(runPromise(Effect.fail(original))).rejects.toThrow(original) + }) + + it('throws the original defect from Effect.die', async () => { + const defect = new RangeError('unexpected defect') + + await expect(runPromise(Effect.die(defect))).rejects.toThrow(defect) + }) + + it('throws the original defect from an unhandled throw inside Effect.sync', async () => { + const defect = new SyntaxError('bad parse') + const effect = Effect.sync(() => { + throw defect + }) + + await expect(runPromise(effect)).rejects.toThrow(defect) + }) + + it('throws a synthesized Error on interrupt', async () => { + const effect = Effect.gen(function* () { + yield* Effect.interrupt + }) + + await expect(runPromise(effect)).rejects.toThrow(/Fiber interrupted/) + }) + + it('throws the finalizer error on sequential cause (mirrors try/finally)', async () => { + const finalizerError = new Error('finalizer also failed') + + const effect = Effect.acquireUseRelease( + Effect.succeed('resource'), + () => Effect.fail(new Error('use failed')), + () => Effect.fail(finalizerError) as Effect.Effect, + ) + + await expect(runPromise(effect)).rejects.toThrow(finalizerError) + }) + + it('throws the left error on parallel cause', async () => { + const leftError = new Error('left fiber failed') + const rightError = new Error('right fiber failed') + + const effect = Effect.all( + [Effect.fail(leftError), Effect.fail(rightError)], + { concurrency: 'unbounded' }, + ) + + await expect(runPromise(effect)).rejects.toThrow(leftError) + }) + + it('does NOT wrap errors in FiberFailure', async () => { + const original = new TypeError('original') + await expect(runPromise(Effect.fail(original))).rejects.toBe(original) + }) + + it('throws a sentinel Error when cause is empty', async () => { + const effect = Effect.failCause(Cause.empty) + + await expect( + runPromise(effect), + ).rejects.toThrow(new Error('Effect failed with no error information')) + }) + }) +}) diff --git a/packages/effect/src/runtime.ts b/packages/effect/src/runtime.ts new file mode 100644 index 000000000..c6f3824f1 --- /dev/null +++ b/packages/effect/src/runtime.ts @@ -0,0 +1,38 @@ +import { AbortError } from '@orpc/shared' +import { Cause, Effect, Exit, FiberId } from 'effect' + +/** + * Extracts the most meaningful original error from an Effect Cause, + * preserving the original error instance wherever possible. + */ +export function extractErrorFromCause(cause: Cause.Cause): unknown { + return Cause.match(cause, { + onFail: error => error, + onDie: defect => defect, + onInterrupt: fiberId => new AbortError(`Fiber interrupted: ${FiberId.threadName(fiberId)}`), + onEmpty: new Error('Effect failed with no error information'), + + // Mirrors native try/finally: if the finalizer (right) also throws, + // it overwrites the original (left) — same behaviour as JS would produce + onSequential: (_left, right) => right, + onParallel: (left, _right) => left, + }) +} + +export interface RunPromiseOptions { + signal?: undefined | AbortSignal +} + +/** + * Runs an Effect as a Promise while re-throwing the original error directly, + * bypassing Effect.runPromise's FiberFailure wrapper. + */ +export async function runPromise(effect: Effect.Effect, options: RunPromiseOptions = {}): Promise { + const exit = await Effect.runPromiseExit(effect, options) + + if (Exit.isSuccess(exit)) { + return exit.value + } + + throw extractErrorFromCause(exit.cause) +} diff --git a/packages/effect/src/schema.test.ts b/packages/effect/src/schema.test.ts new file mode 100644 index 000000000..f13e3187e --- /dev/null +++ b/packages/effect/src/schema.test.ts @@ -0,0 +1,19 @@ +import { getHiddenMetaPlugins, setHiddenMetaPlugins } from '@orpc/contract' +import { Schema } from 'effect' +import { toStandardSchema } from './schema' + +describe('toStandardSchema', () => { + it('convert to standard schema', () => { + expect(toStandardSchema(Schema.Number)['~standard'].vendor).toBe('effect') + }) + + it('keep meta plugins', () => { + const schema = Schema.Number + const plugin = { name: 'plugin1' } + setHiddenMetaPlugins(schema, [plugin]) + + const converted = toStandardSchema(schema) + expect(converted['~standard'].vendor).toBe('effect') + expect(getHiddenMetaPlugins(converted)).toEqual([plugin]) + }) +}) diff --git a/packages/effect/src/schema.ts b/packages/effect/src/schema.ts new file mode 100644 index 000000000..e559bd8fb --- /dev/null +++ b/packages/effect/src/schema.ts @@ -0,0 +1,15 @@ +import type { Schema } from '@orpc/contract' +import { getHiddenMetaPlugins, setHiddenMetaPlugins } from '@orpc/contract' +import { Schema as EffectSchema } from 'effect' + +export function toStandardSchema( + schema: EffectSchema.Schema, +): Schema { + const converted = EffectSchema.standardSchemaV1(schema) + const metaPlugins = getHiddenMetaPlugins(schema) + if (metaPlugins) { + setHiddenMetaPlugins(converted, metaPlugins) + } + + return converted +} diff --git a/packages/effect/tsconfig.json b/packages/effect/tsconfig.json new file mode 100644 index 000000000..9e694d2aa --- /dev/null +++ b/packages/effect/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.lib.json", + "references": [ + { "path": "../contract" }, + { "path": "../server" }, + { "path": "../json-schema" }, + { "path": "../shared" } + ], + "include": ["package.json", "src"], + "exclude": [ + "**/*.test.*", + "**/*.test-d.ts", + "**/*.bench.*", + "**/__tests__/**", + "**/__mocks__/**", + "**/__snapshots__/**" + ] +} diff --git a/packages/evlog/.gitignore b/packages/evlog/.gitignore new file mode 100644 index 000000000..97192859c --- /dev/null +++ b/packages/evlog/.gitignore @@ -0,0 +1,29 @@ +# Hidden folders and files +.* +!.gitignore +!.*.example + +# Common generated folders +logs/ +node_modules/ +out/ +dist/ +dist-ssr/ +build/ +coverage/ +temp/ + +# Common generated files +*.log +*.log.* +*.tsbuildinfo +*.vitest-temp.json +vite.config.ts.timestamp-* +vitest.config.ts.timestamp-* + +# Common manual ignore files +*.local +*.pem + +## Hey API Code gen +/tests/client/ \ No newline at end of file diff --git a/packages/evlog/README.md b/packages/evlog/README.md new file mode 100644 index 000000000..94b06896a --- /dev/null +++ b/packages/evlog/README.md @@ -0,0 +1,188 @@ +

oRPC - Typesafe APIs Made Simple 🪄

+ + + +## Documentation + +You can read the documentation [here](https://orpc.dev). + +## Packages + +**Core** + +- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Define type-safe contracts for your API. +- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Implement your API or contract on the server. +- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API from the client with full type safety. +- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Bring OpenAPI compatibility to your APIs. + +**Schema validation** + +- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): Integrate with [Zod](https://zod.dev/). +- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): Integrate with [Valibot](https://valibot.dev/). +- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): Integrate with [ArkType](https://arktype.io/). + +**Framework & ecosystem integrations** + +- [@orpc/next](https://www.npmjs.com/package/@orpc/react): Use oRPC with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). +- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). +- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). + +**Built-in features** + +- [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub. +- [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting. +- [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. + +**Observability** + +- [@orpc/opentelemetry](https://www.npmjs.com/package/@orpc/opentelemetry): Instrument your API with [OpenTelemetry](https://opentelemetry.io/). +- [@orpc/pino](https://www.npmjs.com/package/@orpc/pino): Log with [Pino](https://getpino.io/). +- [@orpc/evlog](https://www.npmjs.com/package/@orpc/evlog): Log with [Evlog](https://evlog.dev/). + +## Sponsors + +Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). Every bit helps! 🚀 + +### 🏆 Platinum Sponsor + + + + + +
ScreenshotOne.com
ScreenshotOne.com
+ +### 🥈 Silver Sponsor + + + + + +
村上さん
村上さん
+ +### Generous Sponsors + + + + + +
LN Markets
LN Markets
+ +### Sponsors + + + + + + + + + + + + + + + + + + + + + + + + +
Reece McDonald
Reece McDonald
nk
nk
supastarter
supastarter
Dexter Miguel
Dexter Miguel
herrfugbaum
herrfugbaum
Ryota Murakami
Ryota Murakami
David Cramer
David Cramer
Valerii Petryniak
Valerii Petryniak
Valerii Strilets
Valerii Strilets
Kyle Mistele
Kyle Mistele
Andrew Peters
Andrew Peters
Ryan Vogel
Ryan Vogel
christ12938
christ12938
Peter Adam
Peter Adam
Ryan Soderberg
Ryan Soderberg
shota
shota
+ +### Backers + + + + + + + + + + + + + + + + + + + + + + + + + +
David Walsh
David Walsh
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
Alex
Alex
nattstack
nattstack
Andrey Gubanov
Andrey Gubanov
+ +### Past Sponsors + +

+ Maxie + Stijn Timmer + あわわわとーにゅ + Zuplo + motopods + Francisco Hermida + Théo LUDWIG + Abhay Ramesh + shr.ink oü + 0x4e32 + Ryuz + happyboy + yicchi + Saksham + Roman Hrynevych + rokitg + Omar Khatib + Yu-Sabo + Bapusaheb Patil + grim + Nelson Lai + Lê Cao Nguyên + Robert Soriano + SKostyukovich + Fabworks + Novak Antonijevic + Laduni Estu Syalwa + Chen, Zhi-Yuan + Illarion Koperski + Anees Iqbal + Sefa Eyeoglu + Adam Tkaczyk + plancraft +

+ +## 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/evlog/package.json b/packages/evlog/package.json new file mode 100644 index 000000000..175df7e2a --- /dev/null +++ b/packages/evlog/package.json @@ -0,0 +1,56 @@ +{ + "name": "@orpc/evlog", + "type": "module", + "version": "1.13.4", + "license": "MIT", + "homepage": "https://orpc.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/middleapi/orpc.git", + "directory": "packages/evlog" + }, + "keywords": [ + "orpc", + "evlog" + ], + "sideEffects": false, + "publishConfig": { + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + }, + "./node": { + "types": "./dist/adapters/node.d.mts", + "import": "./dist/adapters/node.mjs", + "default": "./dist/adapters/node.mjs" + } + } + }, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./node": "./src/adapters/node.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "unbuild", + "type:check": "tsc -b" + }, + "peerDependencies": { + "evlog": ">=2.18.1" + }, + "dependencies": { + "@orpc/client": "workspace:*", + "@orpc/server": "workspace:*", + "@orpc/shared": "workspace:*", + "@standardserver/core": "^0.0.24" + }, + "devDependencies": { + "evlog": "^2.18.1" + } +} diff --git a/packages/evlog/src/adapters/node.ts b/packages/evlog/src/adapters/node.ts new file mode 100644 index 000000000..63ff8b17e --- /dev/null +++ b/packages/evlog/src/adapters/node.ts @@ -0,0 +1,13 @@ +import type { StandardRequest } from '@standardserver/core' +import type { RequestLogger } from 'evlog' +import type { FrameworkIntegrationSpec } from 'evlog/toolkit' +import { createLoggerStorage as baseCreateLoggerStorage } from 'evlog/toolkit' + +export function createLoggerStorage(): { + storage: FrameworkIntegrationSpec<{ request: StandardRequest }>['storage'] + useLogger: () => Required +} { + return baseCreateLoggerStorage( + 'please configure EvlogHandlerPlugin for your handler using the created storage', + ) as any +} diff --git a/packages/evlog/src/context.test.ts b/packages/evlog/src/context.test.ts new file mode 100644 index 000000000..2439d752d --- /dev/null +++ b/packages/evlog/src/context.test.ts @@ -0,0 +1,10 @@ +import { createRequestLogger } from 'evlog' +import { getLogger, LOGGER_CONTEXT_SYMBOL } from './context' + +it('getLogger', async () => { + expect(getLogger({})).toBeUndefined() + expect(getLogger({ something: true } as any)).toBeUndefined() + + const logger = createRequestLogger() + expect(getLogger({ [LOGGER_CONTEXT_SYMBOL]: logger })).toBe(logger) +}) diff --git a/packages/evlog/src/context.ts b/packages/evlog/src/context.ts new file mode 100644 index 000000000..12f8a3eb4 --- /dev/null +++ b/packages/evlog/src/context.ts @@ -0,0 +1,11 @@ +import type { RequestLogger } from 'evlog' + +export const LOGGER_CONTEXT_SYMBOL: unique symbol = Symbol.for('ORPC_EVLOG_LOGGER_CONTEXT') + +export interface LoggerContext { + [LOGGER_CONTEXT_SYMBOL]?: undefined | RequestLogger +} + +export function getLogger(context: LoggerContext): RequestLogger | undefined { + return context[LOGGER_CONTEXT_SYMBOL] +} diff --git a/packages/evlog/src/handler-plugin.test.ts b/packages/evlog/src/handler-plugin.test.ts new file mode 100644 index 000000000..9d268ef76 --- /dev/null +++ b/packages/evlog/src/handler-plugin.test.ts @@ -0,0 +1,491 @@ +import { AbortError, ORPC_NAME, sleep } from '@orpc/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { LOGGER_CONTEXT_SYMBOL } from './context' +import { EvlogHandlerPlugin } from './handler-plugin' + +const mocks = vi.hoisted(() => ({ + defineFrameworkIntegration: vi.fn(), + start: vi.fn(), + spec: undefined as any, +})) + +vi.mock('evlog/toolkit', () => ({ + defineFrameworkIntegration: mocks.defineFrameworkIntegration.mockImplementation((spec) => { + mocks.spec = spec + + return { + start: mocks.start, + } + }), +})) + +function createRequest( + url: string, + options: { headers?: Record, signal?: AbortSignal } = {}, +) { + return { + method: 'GET', + url, + headers: options.headers ?? {}, + signal: options.signal, + } as any +} + +function createLogger() { + return { + set: vi.fn(), + error: vi.fn(), + setLevel: vi.fn(), + } +} + +function mockIntegration(logger = createLogger()) { + const finish = vi.fn().mockResolvedValue(null) + const runWith = vi.fn(async (run: () => Promise) => await run()) + + mocks.start.mockReturnValue({ + skipped: false, + finish, + runWith, + logger, + }) + + return { + finish, + runWith, + logger, + } +} + +function getPluginHooks(plugin: EvlogHandlerPlugin, options: Record = {}) { + const initialized = plugin.init(options as any) + + return { + routing: initialized.routingInterceptors?.[0] as any, + interceptor: initialized.interceptors?.[0] as any, + client: initialized.clientInterceptors?.[0] as any, + initialized, + } +} + +async function collectIterator(iterator: AsyncIterable) { + const values: T[] = [] + + for await (const value of iterator) { + values.push(value) + } + + return values +} + +async function collectStream(stream: ReadableStream) { + const reader = stream.getReader() + const values: T[] = [] + + while (true) { + const result = await reader.read() + + if (result.done) { + return values + } + + values.push(result.value) + } +} + +describe('evlogHandlerPlugin', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.start.mockReset() + mocks.spec = undefined + vi.useRealTimers() + }) + + it('configures the framework integration and prepends its interceptors', () => { + const storage = {} as any + const existingRouting = vi.fn() + const existingInterceptor = vi.fn() + const existingClientInterceptor = vi.fn() + + const plugin = new EvlogHandlerPlugin({ + storage, + logAbort: true, + include: ['/rpc/*'], + }) + + expect(mocks.defineFrameworkIntegration).toHaveBeenCalledTimes(1) + expect(mocks.spec.name).toBe(ORPC_NAME) + expect(mocks.spec.storage).toBe(storage) + expect(mocks.spec.extractRequest({ + request: createRequest('/hello/world?debug=1', { + headers: { 'x-request-id': 'request-id' }, + }), + })).toEqual({ + method: 'GET', + path: '/hello/world', + headers: { 'x-request-id': 'request-id' }, + requestId: 'request-id', + }) + expect(mocks.spec.attachLogger({}, createLogger())).toBeUndefined() + + const initialized = plugin.init({ + routingInterceptors: [existingRouting], + interceptors: [existingInterceptor], + clientInterceptors: [existingClientInterceptor], + } as any) + + expect(initialized.routingInterceptors).toEqual([ + expect.any(Function), + existingRouting, + ]) + expect(initialized.interceptors).toEqual([ + expect.any(Function), + existingInterceptor, + ]) + expect(initialized.clientInterceptors).toEqual([ + expect.any(Function), + existingClientInterceptor, + ]) + }) + + it('bypasses the routing interceptor when evlog skips the request', async () => { + const plugin = new EvlogHandlerPlugin() + const { routing } = getPluginHooks(plugin) + const next = vi.fn().mockResolvedValue('skipped') + + mocks.start.mockReturnValue({ + skipped: true, + finish: vi.fn(), + runWith: vi.fn(), + logger: createLogger(), + }) + + await expect(routing({ + next, + context: { existing: true }, + request: createRequest('/skip'), + })).resolves.toBe('skipped') + + expect(mocks.start).toHaveBeenCalledWith(expect.objectContaining({ + context: { existing: true }, + request: expect.objectContaining({ url: '/skip' }), + }), {}) + expect(next).toHaveBeenCalledWith() + }) + + it('injects the request logger into matched plain responses and finishes immediately', async () => { + const plugin = new EvlogHandlerPlugin({ include: ['/rpc/*'] }) + const { routing } = getPluginHooks(plugin) + const { finish, logger, runWith } = mockIntegration() + const next = vi.fn(async (options: any) => { + expect(options.context.existing).toBe(true) + expect(options.context[LOGGER_CONTEXT_SYMBOL]).toBe(logger) + + return { + matched: true, + response: { + status: 204, + body: 'pong', + }, + } + }) + + const result = await routing({ + next, + context: { existing: true }, + request: createRequest('/ping'), + }) + + expect(result).toEqual({ + matched: true, + response: { + status: 204, + body: 'pong', + }, + }) + expect(runWith).toHaveBeenCalledTimes(1) + expect(finish).toHaveBeenCalledWith({ status: 204 }) + expect(mocks.start).toHaveBeenCalledWith(expect.any(Object), { include: ['/rpc/*'] }) + }) + + it('marks unmatched requests before finishing', async () => { + const plugin = new EvlogHandlerPlugin() + const { routing } = getPluginHooks(plugin) + const { finish, logger } = mockIntegration() + + const result = await routing({ + next: vi.fn().mockResolvedValue({ matched: false, response: undefined }), + context: {}, + request: createRequest('/missing'), + }) + + expect(result).toEqual({ matched: false, response: undefined }) + expect(logger.set).toHaveBeenCalledWith({ message: 'No procedure matched' }) + expect(finish).toHaveBeenCalledWith({ status: undefined }) + }) + + it('logs internal routing failures and rethrows them', async () => { + const plugin = new EvlogHandlerPlugin() + const { routing } = getPluginHooks(plugin) + const { finish, logger } = mockIntegration() + const error = new Error('internal-error') + + await expect(routing({ + next: vi.fn().mockRejectedValue(error), + context: {}, + request: createRequest('/ping'), + })).rejects.toThrow(error) + + expect(logger.error).toHaveBeenCalledWith(error) + expect(finish).toHaveBeenCalledWith() + }) + + it('wraps matched async iterators and finishes after successful consumption', async () => { + const plugin = new EvlogHandlerPlugin() + const { routing } = getPluginHooks(plugin) + const { finish } = mockIntegration() + + async function* source() { + yield 1 + yield 2 + } + + const iterator = source() as unknown as AsyncIterable & { meta: string } + iterator.meta = 'preserved' + + const result = await routing({ + next: vi.fn().mockResolvedValue({ + matched: true, + response: { + status: 200, + body: iterator, + }, + }), + context: {}, + request: createRequest('/iterable'), + }) + + expect((result.response.body as typeof iterator).meta).toBe('preserved') + await expect(collectIterator(result.response.body as AsyncIterable)).resolves.toEqual([1, 2]) + expect(finish).toHaveBeenCalledWith({ status: 200 }) + }) + + it('logs async iterator wrapper errors as internal failures', async () => { + const plugin = new EvlogHandlerPlugin() + const { routing } = getPluginHooks(plugin) + const { finish, logger } = mockIntegration() + + async function* source() { + // eslint-disable-next-line no-throw-literal + throw 'iterator-failure' + } + + const result = await routing({ + next: vi.fn().mockResolvedValue({ + matched: true, + response: { + status: 200, + body: source(), + }, + }), + context: {}, + request: createRequest('/iterable-error'), + }) + + await expect(collectIterator(result.response.body as AsyncIterable)).rejects.toBe('iterator-failure') + expect(logger.error).toHaveBeenCalledWith('iterator-failure') + expect(finish).toHaveBeenCalledWith({ status: 200 }) + expect(logger.error).toHaveBeenCalledBefore(finish) + }) + + it('wraps matched readable streams and finishes after they close', async () => { + const plugin = new EvlogHandlerPlugin() + const { routing } = getPluginHooks(plugin) + const { finish } = mockIntegration() + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue('chunk-1') + controller.enqueue('chunk-2') + controller.close() + }, + }) as ReadableStream & { meta: string } + + stream.meta = 'preserved' + + const result = await routing({ + next: vi.fn().mockResolvedValue({ + matched: true, + response: { + status: 206, + body: stream, + }, + }), + context: {}, + request: createRequest('/stream'), + }) + + expect((result.response.body as typeof stream).meta).toBe('preserved') + await expect(collectStream(result.response.body as ReadableStream)).resolves.toEqual(['chunk-1', 'chunk-2']) + await sleep(0) + expect(finish).toHaveBeenCalledWith({ status: 206 }) + }) + + it('logs readable stream wrapper errors as internal failures', async () => { + const plugin = new EvlogHandlerPlugin() + const { routing } = getPluginHooks(plugin) + const { finish, logger } = mockIntegration() + const streamError = new Error('stream-error') + + const result = await routing({ + next: vi.fn().mockResolvedValue({ + matched: true, + response: { + status: 200, + body: new ReadableStream({ + start(controller) { + controller.error(streamError) + }, + }), + }, + }), + context: {}, + request: createRequest('/stream-error'), + }) + + await expect(collectStream(result.response.body as ReadableStream)).rejects.toThrow(streamError) + await sleep(0) + expect(logger.error).toHaveBeenCalledWith(streamError) + expect(finish).toHaveBeenCalledWith({ status: 200 }) + expect(logger.error).toHaveBeenCalledBefore(finish) + }) + + it('sets rpc metadata and logs abort state', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-30T12:00:00.000Z')) + + const controller = new AbortController() + const logger = createLogger() + const plugin = new EvlogHandlerPlugin({ logAbort: true }) + const { interceptor } = getPluginHooks(plugin) + + await expect(interceptor({ + next: vi.fn(async () => { + controller.abort('manual') + return 'pong' + }), + context: { [LOGGER_CONTEXT_SYMBOL]: logger }, + path: ['nested', 'ping'], + request: createRequest('/ping', { signal: controller.signal }), + })).resolves.toBe('pong') + + expect(logger.set).toHaveBeenNthCalledWith(1, { + rpc: { system: ORPC_NAME, method: 'nested.ping' }, + }) + expect(logger.set).toHaveBeenNthCalledWith(2, { + abort: { + reason: 'manual', + abortedAt: '2026-05-30T12:00:00.000Z', + }, + }) + + const alreadyAborted = new AbortController() + alreadyAborted.abort('before') + + await expect(interceptor({ + next: vi.fn().mockResolvedValue('done'), + context: { [LOGGER_CONTEXT_SYMBOL]: logger }, + path: ['ping'], + request: createRequest('/ping', { signal: alreadyAborted.signal }), + })).resolves.toBe('done') + + expect(logger.set).toHaveBeenCalledWith({ + abort: { + message: 'request was aborted before handling', + reason: 'before', + }, + }) + }) + + it('logs business logic errors and downgrades abort errors to info level', async () => { + const logger = createLogger() + const plugin = new EvlogHandlerPlugin() + const { interceptor } = getPluginHooks(plugin) + const businessError = new Error('boom') + + await expect(interceptor({ + next: vi.fn().mockRejectedValue(businessError), + context: { [LOGGER_CONTEXT_SYMBOL]: logger }, + path: ['ping'], + request: createRequest('/ping'), + })).rejects.toThrow(businessError) + + expect(logger.error).toHaveBeenCalledWith(businessError) + expect(logger.setLevel).not.toHaveBeenCalled() + + logger.error.mockClear() + logger.setLevel.mockClear() + + const abortError = new AbortError('reason') + + await expect(interceptor({ + next: vi.fn().mockRejectedValue(abortError), + context: { [LOGGER_CONTEXT_SYMBOL]: logger }, + path: ['ping'], + request: createRequest('/ping'), + })).rejects.toThrow(abortError) + + expect(logger.error).toHaveBeenCalledWith(abortError) + expect(logger.setLevel).toHaveBeenCalledWith('info') + }) + + it('returns non-stream client outputs unchanged', async () => { + const plugin = new EvlogHandlerPlugin() + const { client } = getPluginHooks(plugin) + + await expect(client({ + next: vi.fn().mockResolvedValue('pong'), + context: {}, + })).resolves.toBe('pong') + }) + + it('logs client async iterator errors', async () => { + const logger = createLogger() + const plugin = new EvlogHandlerPlugin() + const { client } = getPluginHooks(plugin) + const streamError = new Error('stream-error') + + async function* source() { + throw streamError + } + + const output = await client({ + next: vi.fn().mockResolvedValue(source()), + context: { [LOGGER_CONTEXT_SYMBOL]: logger }, + }) + + await expect(collectIterator(output as AsyncIterable)).rejects.toThrow(streamError) + expect(logger.error).toHaveBeenCalledWith(streamError) + expect(logger.setLevel).not.toHaveBeenCalled() + }) + + it('logs client readable stream abort errors and downgrades them to info level', async () => { + const logger = createLogger() + const plugin = new EvlogHandlerPlugin() + const { client } = getPluginHooks(plugin) + const abortError = new AbortError('stream-abort') + + const output = await client({ + next: vi.fn().mockResolvedValue(new ReadableStream({ + start(controller) { + controller.error(abortError) + }, + })), + context: { [LOGGER_CONTEXT_SYMBOL]: logger }, + }) + + await expect(collectStream(output as ReadableStream)).rejects.toThrow(abortError) + expect(logger.error).toHaveBeenCalledWith(abortError) + expect(logger.setLevel).toHaveBeenCalledWith('info') + }) +}) diff --git a/packages/evlog/src/handler-plugin.ts b/packages/evlog/src/handler-plugin.ts new file mode 100644 index 000000000..5a1e51170 --- /dev/null +++ b/packages/evlog/src/handler-plugin.ts @@ -0,0 +1,256 @@ +import type { Context, ErrorMap, ProcedureClientInterceptor, Schema } from '@orpc/server' +import type { StandardHandlerInterceptor, StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor } from '@orpc/server/standard' +import type { StandardRequest } from '@standardserver/core' +import type { RequestLogger } from 'evlog' +import type { BaseEvlogOptions, FrameworkIntegrationHelpers, FrameworkIntegrationSpec } from 'evlog/toolkit' +import { wrapEventIteratorPreservingMeta } from '@orpc/client' +import { isAbortError, isAsyncIteratorObject, ORPC_NAME, override, sleep, toArray, wrapReadableStream } from '@orpc/shared' +import { flattenStandardHeader, parseStandardUrl } from '@standardserver/core' +import { defineFrameworkIntegration } from 'evlog/toolkit' +import { getLogger, LOGGER_CONTEXT_SYMBOL } from './context' + +export interface EvlogHandlerPluginOptions<_T extends Context> extends BaseEvlogOptions { + /** + * AsyncLocalStorage instance backing `useLogger()`. + */ + storage?: FrameworkIntegrationSpec<{ request: StandardRequest }>['storage'] + + /** + * If true, this plugin will log when a request signal is aborted. + * + * @default false + */ + logAbort?: boolean +} + +export class EvlogHandlerPlugin implements StandardHandlerPlugin { + name = '~evlog' + + /** + * - Logging interceptors should run after OpenTelemetry interceptors + * so they execute within the active request span. + * - Logging interceptors should run after batch interceptors + * so they log each individual request instead of the batch request. + */ + before = ['~opentelemetry', '~batch'] + + private readonly logAbort: Exclude['logAbort'], undefined> + private readonly integration: FrameworkIntegrationHelpers<{ request: StandardRequest }> + private readonly evlogOptions: BaseEvlogOptions + + constructor( + { storage, logAbort, ...evlogOptions }: EvlogHandlerPluginOptions = {}, + ) { + this.evlogOptions = evlogOptions + this.logAbort = logAbort ?? false + this.integration = defineFrameworkIntegration({ + name: ORPC_NAME, + storage, + extractRequest: ({ request }) => { + const [pathname] = parseStandardUrl(request.url) + + return { + method: request.method, + path: pathname, + headers: request.headers, + requestId: flattenStandardHeader(request.headers['x-request-id']), + } + }, + attachLogger: () => { + /* logger is manually injected into the oRPC context */ + }, + }) + } + + init(options: StandardHandlerOptions): StandardHandlerOptions { + const routingInterceptor: StandardHandlerRoutingInterceptor = async ({ next, ...interceptorOptions }) => { + const { skipped, finish, runWith, logger } = this.integration.start(interceptorOptions, this.evlogOptions) + + if (skipped) { + return next() + } + + try { + const result = await runWith(() => next({ + ...interceptorOptions, + context: { + ...interceptorOptions.context, + [LOGGER_CONTEXT_SYMBOL]: logger, + }, + })) + + if (result.matched) { + if (isAsyncIteratorObject(result.response.body)) { + return { + ...result, + response: { + ...result.response, + /** + * @warning + * Remember use `override` for event iterator to remain other special properties + */ + body: override(result.response.body, wrapEventIteratorPreservingMeta(result.response.body, { + runWith, + onError: (error) => { + /** + * Any error here is internal (interceptor/framework), not business logic. + * Indicates unexpected handler failure. + */ + logger.error(toErrorOrString(error)) + }, + onFinish: async () => { + await sleep(0) // dealing with "log.error() called after the wide event was emitted" + await finish({ status: result.response?.status }) + }, + })), + }, + } + } + + if (result.response.body instanceof ReadableStream) { + return { + ...result, + response: { + ...result.response, + /** + * @warning + * Remember use `override` for event iterator to remain other special properties + */ + body: override(result.response.body, wrapReadableStream(result.response.body, { + runWith, + onError: (error) => { + /** + * Any error here is internal (interceptor/framework), not business logic. + * Indicates unexpected handler failure. + */ + logger.error(toErrorOrString(error)) + }, + onFinish: async () => { + await sleep(0) // dealing with "log.error() called after the wide event was emitted" + await finish({ status: result.response?.status }) + }, + })), + }, + } + } + } + else { + logger.set({ message: 'No procedure matched' }) + } + + await finish({ status: result.response?.status }) + + return result + } + catch (error) { + /** + * Any error here is internal (interceptor/framework), not business logic. + * Indicates unexpected handler failure. + */ + logger.error(toErrorOrString(error)) + await finish() + throw error + } + } + + const interceptor: StandardHandlerInterceptor = async ({ next, context, path, request }) => { + const logger = getLogger(context) + logger?.set({ rpc: { system: ORPC_NAME, method: path.join('.') } }) + + if (this.logAbort) { + const signal = request.signal + + if (signal?.aborted) { + logger?.set({ + abort: { + message: `request was aborted before handling`, + reason: String(signal.reason), + }, + }) + } + else { + signal?.addEventListener('abort', () => { + logger?.set({ + abort: { + reason: String(signal.reason), + abortedAt: new Date().toISOString(), + }, + }) + }, { once: true }) + } + } + + try { + return await next() + } + catch (error) { + logBusinessLogicError(logger, error) + throw error + } + } + + const clientInterceptor: ProcedureClientInterceptor, ErrorMap, any> = async ({ next, context }) => { + const logger = getLogger(context) + const output = await next() + + if (isAsyncIteratorObject(output)) { + /** + * @warning + * Remember use `override` for event iterator to remain other special properties + */ + return override(output, wrapEventIteratorPreservingMeta(output, { + onError: (error) => { + logBusinessLogicError(logger, error) + }, + })) + } + + if (output instanceof ReadableStream) { + /** + * @warning + * Remember use `override` for event iterator to remain other special properties + */ + return override(output, wrapReadableStream(output, { + onError: (error) => { + logBusinessLogicError(logger, error) + }, + })) + } + + return output + } + + return { + ...options, + routingInterceptors: [ + routingInterceptor, + ...toArray(options.routingInterceptors), + ], + interceptors: [ + interceptor, + ...toArray(options.interceptors), + ], + clientInterceptors: [ + clientInterceptor, + ...toArray(options.clientInterceptors), + ], + } + } +} + +function toErrorOrString(error: unknown) { + if (error instanceof Error) { + return error + } + + return String(error) +} + +function logBusinessLogicError(logger: RequestLogger | undefined, error: unknown) { + logger?.error(toErrorOrString(error)) + + // DO NOT treat aborted error as error if happen during business logic + if (isAbortError(error)) { + logger?.setLevel('info') + } +} diff --git a/packages/evlog/src/index.test.ts b/packages/evlog/src/index.test.ts new file mode 100644 index 000000000..7a805f3a3 --- /dev/null +++ b/packages/evlog/src/index.test.ts @@ -0,0 +1,3 @@ +it('exports EvlogHandlerPlugin', async () => { + expect(Object.keys(await import('./index'))).toContain('EvlogHandlerPlugin') +}) diff --git a/packages/evlog/src/index.ts b/packages/evlog/src/index.ts new file mode 100644 index 000000000..baf5a0d9b --- /dev/null +++ b/packages/evlog/src/index.ts @@ -0,0 +1,2 @@ +export * from './context' +export * from './handler-plugin' diff --git a/packages/evlog/tsconfig.json b/packages/evlog/tsconfig.json new file mode 100644 index 000000000..075f89b34 --- /dev/null +++ b/packages/evlog/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.lib.json", + "references": [ + { "path": "../server" }, + { "path": "../client" }, + { "path": "../shared" } + ], + "include": ["package.json", "src"], + "exclude": [ + "**/*.test.*", + "**/*.test-d.ts", + "**/*.bench.*", + "**/__tests__/**", + "**/__mocks__/**", + "**/__snapshots__/**" + ] +} diff --git a/packages/hey-api/README.md b/packages/hey-api/README.md deleted file mode 100644 index 4a49ae4d9..000000000 --- a/packages/hey-api/README.md +++ /dev/null @@ -1,194 +0,0 @@ -
- oRPC logo -
- -

- - - -

Typesafe APIs Made Simple 🪄

- -**oRPC is a powerful combination of RPC and OpenAPI**, makes it easy to build APIs that are end-to-end type-safe and adhere to OpenAPI standards - ---- - -## Highlights - -- **🔗 End-to-End Type Safety**: Ensure type-safe inputs, outputs, and errors from client to server. -- **📘 First-Class OpenAPI**: Built-in support that fully adheres to the OpenAPI standard. -- **📝 Contract-First Development**: Optionally define your API contract before implementation. -- **🔍 First-Class OpenTelemetry**: Seamlessly integrate with OpenTelemetry for observability. -- **⚙️ Framework Integrations**: Seamlessly integrate with TanStack Query (React, Vue, Solid, Svelte, Angular), SWR, Pinia Colada, and more. -- **🚀 Server Actions**: Fully compatible with React Server Actions on Next.js, TanStack Start, and other platforms. -- **🔠 Standard Schema Support**: Works out of the box with Zod, Valibot, ArkType, and other schema validators. -- **🗃️ Native Types**: Supports native types like Date, File, Blob, BigInt, URL, and more. -- **⏱️ Lazy Router**: Enhance cold start times with our lazy routing feature. -- **📡 SSE & Streaming**: Enjoy full type-safe support for SSE and streaming. -- **🌍 Multi-Runtime Support**: Fast and lightweight on Cloudflare, Deno, Bun, Node.js, and beyond. -- **🔌 Extendability**: Easily extend functionality with plugins, middleware, and interceptors. - -## Documentation - -You can find the full documentation [here](https://orpc.dev). - -## Packages - -- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Build your API contract. -- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build your API or implement API contract. -- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API on the client with type-safety. -- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Generate OpenAPI specs and handle OpenAPI requests. -- [@orpc/otel](https://www.npmjs.com/package/@orpc/otel): [OpenTelemetry](https://opentelemetry.io/) integration for observability. -- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Deeply integrate oRPC with [NestJS](https://nestjs.com/). -- [@orpc/react](https://www.npmjs.com/package/@orpc/react): Utilities for integrating oRPC with React and React Server Actions. -- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): [TanStack Query](https://tanstack.com/query/latest) integration. -- [@orpc/experimental-react-swr](https://www.npmjs.com/package/@orpc/experimental-react-swr): [SWR](https://swr.vercel.app/) integration. -- [@orpc/vue-colada](https://www.npmjs.com/package/@orpc/vue-colada): Integration with [Pinia Colada](https://pinia-colada.esm.dev/). -- [@orpc/hey-api](https://www.npmjs.com/package/@orpc/hey-api): [Hey API](https://heyapi.dev/) integration. -- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): More schemas that [Zod](https://zod.dev/) doesn't support yet. -- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): OpenAPI spec generation from [Valibot](https://valibot.dev/). -- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): OpenAPI spec generation from [ArkType](https://arktype.io/). - -## `@orpc/hey-api` - -Integration with [Hey API](https://heyapi.dev/). - -## Sponsors - -If you find oRPC valuable and would like to support its development, you can do so here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). - -### 🏆 Platinum Sponsor - - - - - -
ScreenshotOne.com
ScreenshotOne.com
- -### 🥈 Silver Sponsor - - - - - -
村上さん
村上さん
- -### Generous Sponsors - - - - - -
LN Markets
LN Markets
- -### Sponsors - - - - - - - - - - - - - - - - - - - - - - - - -
Reece McDonald
Reece McDonald
nk
nk
supastarter
supastarter
Dexter Miguel
Dexter Miguel
herrfugbaum
herrfugbaum
Ryota Murakami
Ryota Murakami
David Cramer
David Cramer
Valerii Petryniak
Valerii Petryniak
Valerii Strilets
Valerii Strilets
Kyle Mistele
Kyle Mistele
Andrew Peters
Andrew Peters
Ryan Vogel
Ryan Vogel
christ12938
christ12938
Peter Adam
Peter Adam
Ryan Soderberg
Ryan Soderberg
shota
shota
- -### Backers - - - - - - - - - - - - - - - - - - - - - - - - - -
David Walsh
David Walsh
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
Alex
Alex
nattstack
nattstack
Andrey Gubanov
Andrey Gubanov
- -### Past Sponsors - -

- Maxie - Stijn Timmer - あわわわとーにゅ - Zuplo - motopods - Francisco Hermida - Théo LUDWIG - Abhay Ramesh - shr.ink oü - 0x4e32 - Ryuz - happyboy - yicchi - Saksham - Roman Hrynevych - rokitg - Omar Khatib - Yu-Sabo - Bapusaheb Patil - grim - Nelson Lai - Lê Cao Nguyên - Robert Soriano - SKostyukovich - Fabworks - Novak Antonijevic - Laduni Estu Syalwa - Chen, Zhi-Yuan - Illarion Koperski - Anees Iqbal - Sefa Eyeoglu - Adam Tkaczyk - plancraft -

- -## License - -Distributed under the MIT License. See [LICENSE](https://github.com/middleapi/orpc/blob/main/LICENSE) for more information. diff --git a/packages/hey-api/package.json b/packages/hey-api/package.json deleted file mode 100644 index 2fef20506..000000000 --- a/packages/hey-api/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@orpc/hey-api", - "type": "module", - "version": "1.14.6", - "license": "MIT", - "homepage": "https://orpc.dev", - "repository": { - "type": "git", - "url": "git+https://github.com/middleapi/orpc.git", - "directory": "packages/hey-api" - }, - "keywords": [ - "orpc", - "Hey API" - ], - "sideEffects": false, - "publishConfig": { - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" - } - } - }, - "exports": { - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], - "scripts": { - "build": "unbuild", - "build:watch": "pnpm run build --watch", - "type:check": "tsc -b", - "prepare": "openapi-ts -i ./tests/spec.json -o ./tests/client" - }, - "dependencies": { - "@orpc/client": "workspace:*", - "@orpc/shared": "workspace:*" - }, - "devDependencies": { - "@hey-api/openapi-ts": "^0.80.18" - } -} diff --git a/packages/hey-api/src/index.ts b/packages/hey-api/src/index.ts deleted file mode 100644 index 80fdd1cd6..000000000 --- a/packages/hey-api/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './to-orpc-client' diff --git a/packages/hey-api/src/to-orpc-client.test-d.ts b/packages/hey-api/src/to-orpc-client.test-d.ts deleted file mode 100644 index cae97c9a7..000000000 --- a/packages/hey-api/src/to-orpc-client.test-d.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { NestedClient } from '../../client/src/types' -import type { Planet } from '../tests/client/types.gen' -import * as sdk from '../tests/client/sdk.gen' -import { experimental_toORPCClient } from './to-orpc-client' - -describe('toORPCClient', () => { - const client = experimental_toORPCClient({ - ...sdk, - somethingElse: 123, - }) - - const c = sdk.planetList() - - it('satisfies nested client', () => { - const _b: NestedClient> = client - }) - - it('inputs', async () => { - client.planetList() - - client.planetList({ - query: { - limit: 10, - offset: 0, - }, - }) - - client.planetList({ - query: { - // @ts-expect-error - invalid type - limit: 'invalid', - }, - }) - - client.getPlanet({ path: { planetId: 'earth' } }) - // @ts-expect-error - path is required - client.getPlanet() - // @ts-expect-error - invalid type - client.getPlanet({ path: { planetId: 123 } }) - }) - - it('outputs', async () => { - expectTypeOf(await client.planetList()).toEqualTypeOf<{ body: Planet[], request: Request, response: Response }>() - expectTypeOf(await client.getPlanet({ path: { planetId: 'earth' } })).toEqualTypeOf<{ body: Planet, request: Request, response: Response }>() - expectTypeOf(await client.planetCreate({ body: { name: 'Earth' } })).toEqualTypeOf<{ body: Planet | { id: string }, request: Request, response: Response }>() - }) -}) diff --git a/packages/hey-api/src/to-orpc-client.test.ts b/packages/hey-api/src/to-orpc-client.test.ts deleted file mode 100644 index 041e515b1..000000000 --- a/packages/hey-api/src/to-orpc-client.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { http, HttpResponse } from 'msw' -import { setupServer } from 'msw/node' -import { client } from '../tests/client/client.gen' -import * as sdk from '../tests/client/sdk.gen' -import { experimental_toORPCClient } from './to-orpc-client' - -client.setConfig({ - baseUrl: 'https://example.com', -}) - -const server = setupServer( - http.get('https://example.com/planets', (req) => { - if (req.request.url.includes('throwOnError=1')) { - return HttpResponse.json(null, { status: 500 }) - } - - return HttpResponse.json([{ id: 'earth', name: 'Earth' }], { - headers: { - 'X-Rate-Limit': '10', - 'Last-Event-ID': req.request.headers.get('Last-Event-ID') ?? 'EMPTY', - }, - }) - }), - http.post('https://example.com/planets', async (req) => { - const body = await req.request.json() as any - return HttpResponse.json({ id: body.name, name: body.name }) - }), - http.get('https://example.com/planets/:planetId', (req) => { - return HttpResponse.json({ id: req.params.planetId, name: req.params.planetId }) - }), -) - -beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) - -afterEach(() => server.resetHandlers()) - -afterAll(() => server.close()) - -describe('toORPCClient', () => { - const client = experimental_toORPCClient({ - ...sdk, - somethingElse: 123, - }) - - it('should ignore non-function properties', () => { - expect(client.somethingElse).toBeUndefined() - }) - - it('works', async () => { - const result = await client.planetList() - expect(result).toEqual({ - body: [{ id: 'earth', name: 'Earth' }], - request: expect.any(Request), - response: expect.any(Response), - }) - }) - - it('with lastEventId', async () => { - const result = await client.planetList({ - headers: { - 'x-something': 'value', - 'last-event-id': '123', - }, - }, { lastEventId: '456' }) - - expect(result.request.headers.get('x-something')).toBe('value') - expect(result.request.headers.get('last-event-id')).toBe('456') - expect(result.response.headers.get('last-event-id')).toBe('456') - }) - - it('with query', async () => { - const result = await client.planetList({ - query: { - limit: 10, - offset: 0, - }, - }) - - expect(result.request.url).toBe('https://example.com/planets?limit=10&offset=0') - expect(result.body).toEqual([{ id: 'earth', name: 'Earth' }]) - }) - - it('with params', async () => { - const result = await client.getPlanet({ - path: { planetId: 'earth' }, - }) - - expect(result.request.url).toBe('https://example.com/planets/earth') - expect(result.body).toEqual({ id: 'earth', name: 'earth' }) - }) - - it('with body', async () => { - const result = await client.planetCreate({ - body: { name: 'Bob' }, - }) - - expect(result.body).toEqual({ id: 'Bob', name: 'Bob' }) - }) - - describe('abort signal', () => { - it('case 1', async () => { - const controller1 = new AbortController() - const controller2 = new AbortController() - - const result = await client.planetCreate({ - body: { name: 'Bob' }, - signal: controller1.signal, - }, { signal: controller2.signal }) - - expect(result.request.signal.aborted).toEqual(false) - controller1.abort() - expect(result.request.signal.aborted).toEqual(true) - }) - - it('case 2', async () => { - const controller1 = new AbortController() - const controller2 = new AbortController() - - const result = await client.planetCreate({ - body: { name: 'Bob' }, - signal: controller1.signal, - }, { signal: controller2.signal }) - - expect(result.request.signal.aborted).toEqual(false) - controller2.abort() - expect(result.request.signal.aborted).toEqual(true) - }) - - it('case 3', async () => { - const controller1 = new AbortController() - const controller2 = new AbortController() - controller1.abort() - - await expect( - client.planetCreate({ - body: { name: 'Bob' }, - signal: controller1.signal, - }, { signal: controller2.signal }), - ).rejects.toThrowError('This operation was aborted') - }) - - it('case 4', async () => { - const controller1 = new AbortController() - const controller2 = new AbortController() - controller2.abort() - - await expect( - client.planetCreate({ - body: { name: 'Bob' }, - signal: controller1.signal, - }, { signal: controller2.signal }), - ).rejects.toThrowError('This operation was aborted') - }) - }) - - it('throws on error', async () => { - await expect(client.planetList({ query: { throwOnError: 1 } as any })).rejects.toThrowError() - }) -}) diff --git a/packages/hey-api/src/to-orpc-client.ts b/packages/hey-api/src/to-orpc-client.ts deleted file mode 100644 index 3335a0da3..000000000 --- a/packages/hey-api/src/to-orpc-client.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Client, ThrowableError } from '@orpc/client' - -export type experimental_ToORPCClientResult> = { - [K in keyof T]: - T[K] extends (options: infer UInput) - => Promise - ? Client, UInput, { - body: UResult extends { data: infer USuccess } ? Exclude : never - request: Request - response: Response - }, ThrowableError> - : T[K] extends Record - ? experimental_ToORPCClientResult - : never -} - -/** - * Convert a Hey API SDK to an oRPC client. - * - * @see {@link https://orpc.dev/docs/integrations/hey-api Hey API Docs} - */ -export function experimental_toORPCClient>(sdk: T): experimental_ToORPCClientResult { - const client = {} as Record, undefined | Record, any, any>> - - for (const key in sdk) { - const fn = sdk[key] - - if (!fn || typeof fn !== 'function') { - continue - } - - client[key] = async (input, options) => { - const controller = new AbortController() - - if (input?.signal?.aborted || options?.signal?.aborted) { - controller.abort() - } - else { - input?.signal?.addEventListener('abort', () => controller.abort()) - options?.signal?.addEventListener('abort', () => controller.abort()) - } - - const result = await fn({ - ...input, - signal: controller.signal, - headers: { - ...input?.headers, - ...typeof options?.lastEventId === 'string' ? { 'last-event-id': options.lastEventId } : {}, - }, - throwOnError: true, - }) - - return { - body: result.data, - request: result.request, - response: result.response, - } - } - } - - return client as experimental_ToORPCClientResult -} diff --git a/packages/hey-api/tests/spec.json b/packages/hey-api/tests/spec.json deleted file mode 100644 index 60f2ea72a..000000000 --- a/packages/hey-api/tests/spec.json +++ /dev/null @@ -1,162 +0,0 @@ -{ - "openapi": "3.1.1", - "info": { - "title": "Hey API Test", - "version": "1.0.0" - }, - "paths": { - "/planets": { - "get": { - "operationId": "planet/list", - "parameters": [ - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer", - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "A list of planets", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Planet" - } - } - } - }, - "headers": { - "X-Rate-Limit": { - "schema": { - "type": "integer" - }, - "required": true - } - } - } - } - }, - "post": { - "operationId": "planet.create", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewPlanet" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "A created planet", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Planet" - } - } - } - }, - "200": { - "description": "An updated planet", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdatedPlanet" - } - } - } - } - } - } - }, - "/planets/{planetId}": { - "get": { - "operationId": "getPlanet", - "parameters": [ - { - "name": "planetId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "A planet", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Planet" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "type": "number" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "Planet": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": ["id", "name"] - }, - "NewPlanet": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - }, - "UpdatedPlanet": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": ["id"] - } - } - } -} diff --git a/packages/hey-api/tsconfig.json b/packages/hey-api/tsconfig.json deleted file mode 100644 index 076e6f9ef..000000000 --- a/packages/hey-api/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../client" }, - { "path": "../shared" } - ], - "include": ["src"], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/*.bench.*", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] -} diff --git a/packages/interop/README.md b/packages/interop/README.md index 75a60a17e..0ab5e90c7 100644 --- a/packages/interop/README.md +++ b/packages/interop/README.md @@ -1,11 +1,4 @@ -> [!WARNING] -> This is an internal package. Breaking changes may be introduced without notice - use at your own risk. - -
- oRPC logo -
- -

+

oRPC - Typesafe APIs Made Simple 🪄

-

Typesafe APIs Made Simple 🪄

+## Documentation -**oRPC is a powerful combination of RPC and OpenAPI**, makes it easy to build APIs that are end-to-end type-safe and adhere to OpenAPI standards +You can read the documentation [here](https://orpc.dev). ---- +## Packages -## Highlights +**Core** -- **🔗 End-to-End Type Safety**: Ensure type-safe inputs, outputs, and errors from client to server. -- **📘 First-Class OpenAPI**: Built-in support that fully adheres to the OpenAPI standard. -- **📝 Contract-First Development**: Optionally define your API contract before implementation. -- **🔍 First-Class OpenTelemetry**: Seamlessly integrate with OpenTelemetry for observability. -- **⚙️ Framework Integrations**: Seamlessly integrate with TanStack Query (React, Vue, Solid, Svelte, Angular), SWR, Pinia Colada, and more. -- **🚀 Server Actions**: Fully compatible with React Server Actions on Next.js, TanStack Start, and other platforms. -- **🔠 Standard Schema Support**: Works out of the box with Zod, Valibot, ArkType, and other schema validators. -- **🗃️ Native Types**: Supports native types like Date, File, Blob, BigInt, URL, and more. -- **⏱️ Lazy Router**: Enhance cold start times with our lazy routing feature. -- **📡 SSE & Streaming**: Enjoy full type-safe support for SSE and streaming. -- **🌍 Multi-Runtime Support**: Fast and lightweight on Cloudflare, Deno, Bun, Node.js, and beyond. -- **🔌 Extendability**: Easily extend functionality with plugins, middleware, and interceptors. +- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Define type-safe contracts for your API. +- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Implement your API or contract on the server. +- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API from the client with full type safety. +- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Bring OpenAPI compatibility to your APIs. -## Documentation +**Schema validation** -You can find the full documentation [here](https://orpc.dev). +- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): Integrate with [Zod](https://zod.dev/). +- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): Integrate with [Valibot](https://valibot.dev/). +- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): Integrate with [ArkType](https://arktype.io/). -## Packages +**Framework & ecosystem integrations** -- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Build your API contract. -- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build your API or implement API contract. -- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API on the client with type-safety. -- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Generate OpenAPI specs and handle OpenAPI requests. -- [@orpc/otel](https://www.npmjs.com/package/@orpc/otel): [OpenTelemetry](https://opentelemetry.io/) integration for observability. -- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Deeply integrate oRPC with [NestJS](https://nestjs.com/). -- [@orpc/react](https://www.npmjs.com/package/@orpc/react): Utilities for integrating oRPC with React and React Server Actions. -- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): [TanStack Query](https://tanstack.com/query/latest) integration. -- [@orpc/experimental-react-swr](https://www.npmjs.com/package/@orpc/experimental-react-swr): [SWR](https://swr.vercel.app/) integration. -- [@orpc/vue-colada](https://www.npmjs.com/package/@orpc/vue-colada): Integration with [Pinia Colada](https://pinia-colada.esm.dev/). -- [@orpc/hey-api](https://www.npmjs.com/package/@orpc/hey-api): [Hey API](https://heyapi.dev/) integration. -- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): More schemas that [Zod](https://zod.dev/) doesn't support yet. -- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): OpenAPI spec generation from [Valibot](https://valibot.dev/). -- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): OpenAPI spec generation from [ArkType](https://arktype.io/). +- [@orpc/next](https://www.npmjs.com/package/@orpc/react): Use oRPC with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). +- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). +- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). -## `@orpc/interop` +**Built-in features** -A compatibility layer that builds & re-exports upstream packages that don't yet meet oRPC's requirements. +- [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub. +- [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting. +- [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. -**Included packages:** +**Observability** -- [compression](https://www.npmjs.com/package/compression) for esm compatibility +- [@orpc/opentelemetry](https://www.npmjs.com/package/@orpc/opentelemetry): Instrument your API with [OpenTelemetry](https://opentelemetry.io/). +- [@orpc/pino](https://www.npmjs.com/package/@orpc/pino): Log with [Pino](https://getpino.io/). +- [@orpc/evlog](https://www.npmjs.com/package/@orpc/evlog): Log with [Evlog](https://evlog.dev/). ## Sponsors -If you find oRPC valuable and would like to support its development, you can do so here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). +Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). Every bit helps! 🚀 ### 🏆 Platinum Sponsor @@ -196,6 +176,13 @@ If you find oRPC valuable and would like to support its development, you can do plancraft

+## 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/interop/package.json b/packages/interop/package.json index f499ce4a4..4ab9bb143 100644 --- a/packages/interop/package.json +++ b/packages/interop/package.json @@ -1,7 +1,7 @@ { "name": "@orpc/interop", "type": "module", - "version": "1.14.6", + "version": "1.13.4", "license": "MIT", "homepage": "https://orpc.dev", "repository": { @@ -15,6 +15,7 @@ "sideEffects": false, "publishConfig": { "exports": { + "./package.json": "./package.json", "./compression": { "types": "./dist/compression/index.d.mts", "import": "./dist/compression/index.mjs", @@ -23,6 +24,7 @@ } }, "exports": { + "./package.json": "./package.json", "./compression": "./src/compression/index.ts" }, "files": [ @@ -30,7 +32,6 @@ ], "scripts": { "build": "unbuild", - "build:watch": "pnpm run build --watch", "type:check": "tsc -b" }, "devDependencies": { diff --git a/packages/interop/tsconfig.json b/packages/interop/tsconfig.json index df850ef68..0fb4b9f6e 100644 --- a/packages/interop/tsconfig.json +++ b/packages/interop/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../tsconfig.lib.json", - "include": ["src"], + "include": ["package.json", "src"], "exclude": [ "**/*.test.*", "**/*.test-d.ts", diff --git a/packages/json-schema/README.md b/packages/json-schema/README.md index b710326e6..3200c0619 100644 --- a/packages/json-schema/README.md +++ b/packages/json-schema/README.md @@ -1,8 +1,4 @@ -
- oRPC logo -
- -

+

oRPC - Typesafe APIs Made Simple 🪄

-

Typesafe APIs Made Simple 🪄

+## Documentation -**oRPC is a powerful combination of RPC and OpenAPI**, makes it easy to build APIs that are end-to-end type-safe and adhere to OpenAPI standards +You can read the documentation [here](https://orpc.dev). ---- +## Packages -## Highlights +**Core** -- **🔗 End-to-End Type Safety**: Ensure type-safe inputs, outputs, and errors from client to server. -- **📘 First-Class OpenAPI**: Built-in support that fully adheres to the OpenAPI standard. -- **📝 Contract-First Development**: Optionally define your API contract before implementation. -- **🔍 First-Class OpenTelemetry**: Seamlessly integrate with OpenTelemetry for observability. -- **⚙️ Framework Integrations**: Seamlessly integrate with TanStack Query (React, Vue, Solid, Svelte, Angular), SWR, Pinia Colada, and more. -- **🚀 Server Actions**: Fully compatible with React Server Actions on Next.js, TanStack Start, and other platforms. -- **🔠 Standard Schema Support**: Works out of the box with Zod, Valibot, ArkType, and other schema validators. -- **🗃️ Native Types**: Supports native types like Date, File, Blob, BigInt, URL, and more. -- **⏱️ Lazy Router**: Enhance cold start times with our lazy routing feature. -- **📡 SSE & Streaming**: Enjoy full type-safe support for SSE and streaming. -- **🌍 Multi-Runtime Support**: Fast and lightweight on Cloudflare, Deno, Bun, Node.js, and beyond. -- **🔌 Extendability**: Easily extend functionality with plugins, middleware, and interceptors. +- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Define type-safe contracts for your API. +- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Implement your API or contract on the server. +- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API from the client with full type safety. +- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Bring OpenAPI compatibility to your APIs. -## Documentation +**Schema validation** -You can find the full documentation [here](https://orpc.dev). +- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): Integrate with [Zod](https://zod.dev/). +- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): Integrate with [Valibot](https://valibot.dev/). +- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): Integrate with [ArkType](https://arktype.io/). -## Packages +**Framework & ecosystem integrations** -- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Build your API contract. -- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build your API or implement API contract. -- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API on the client with type-safety. -- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Generate OpenAPI specs and handle OpenAPI requests. -- [@orpc/otel](https://www.npmjs.com/package/@orpc/otel): [OpenTelemetry](https://opentelemetry.io/) integration for observability. -- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Deeply integrate oRPC with [NestJS](https://nestjs.com/). -- [@orpc/react](https://www.npmjs.com/package/@orpc/react): Utilities for integrating oRPC with React and React Server Actions. -- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): [TanStack Query](https://tanstack.com/query/latest) integration. -- [@orpc/experimental-react-swr](https://www.npmjs.com/package/@orpc/experimental-react-swr): [SWR](https://swr.vercel.app/) integration. -- [@orpc/vue-colada](https://www.npmjs.com/package/@orpc/vue-colada): Integration with [Pinia Colada](https://pinia-colada.esm.dev/). -- [@orpc/hey-api](https://www.npmjs.com/package/@orpc/hey-api): [Hey API](https://heyapi.dev/) integration. -- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): More schemas that [Zod](https://zod.dev/) doesn't support yet. -- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): OpenAPI spec generation from [Valibot](https://valibot.dev/). -- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): OpenAPI spec generation from [ArkType](https://arktype.io/). - -## `@orpc/json-schema` - -Json Schema related utilities for oRPC. +- [@orpc/next](https://www.npmjs.com/package/@orpc/react): Use oRPC with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). +- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). +- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). + +**Built-in features** + +- [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub. +- [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting. +- [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. + +**Observability** + +- [@orpc/opentelemetry](https://www.npmjs.com/package/@orpc/opentelemetry): Instrument your API with [OpenTelemetry](https://opentelemetry.io/). +- [@orpc/pino](https://www.npmjs.com/package/@orpc/pino): Log with [Pino](https://getpino.io/). +- [@orpc/evlog](https://www.npmjs.com/package/@orpc/evlog): Log with [Evlog](https://evlog.dev/). ## Sponsors -If you find oRPC valuable and would like to support its development, you can do so here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). +Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). Every bit helps! 🚀 ### 🏆 Platinum Sponsor @@ -189,6 +176,13 @@ If you find oRPC valuable and would like to support its development, you can do plancraft

+## 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/json-schema/package.json b/packages/json-schema/package.json index b3f6d465f..eedd7c1d3 100644 --- a/packages/json-schema/package.json +++ b/packages/json-schema/package.json @@ -1,7 +1,7 @@ { "name": "@orpc/json-schema", "type": "module", - "version": "1.14.6", + "version": "1.13.4", "license": "MIT", "homepage": "https://orpc.dev", "repository": { @@ -15,6 +15,7 @@ "sideEffects": false, "publishConfig": { "exports": { + "./package.json": "./package.json", ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs", @@ -23,6 +24,7 @@ } }, "exports": { + "./package.json": "./package.json", ".": "./src/index.ts" }, "files": [ @@ -30,18 +32,17 @@ ], "scripts": { "build": "unbuild", - "build:watch": "pnpm run build --watch", "type:check": "tsc -b" }, "dependencies": { + "@orpc/client": "workspace:*", "@orpc/contract": "workspace:*", - "@orpc/interop": "workspace:*", - "@orpc/openapi": "workspace:*", "@orpc/server": "workspace:*", "@orpc/shared": "workspace:*", + "@standard-schema/spec": "^1.1.0", "json-schema-typed": "^8.0.2" }, "devDependencies": { - "zod": "^4.3.6" + "zod": "^4.4.3" } } diff --git a/packages/json-schema/src/coercer.test.ts b/packages/json-schema/src/coercer.test.ts index 4bfc06648..a35559ecb 100644 --- a/packages/json-schema/src/coercer.test.ts +++ b/packages/json-schema/src/coercer.test.ts @@ -1,112 +1,132 @@ -/* eslint-disable prefer-regex-literals */ +import type { JsonSchema } from './types' import { JsonSchemaCoercer } from './coercer' describe('jsonSchemaCoercer', () => { const coercer = new JsonSchemaCoercer() it('do no thing with boolean/any schema', () => { - expect(coercer.coerce(true, '123')).toEqual('123') - expect(coercer.coerce(false, '123')).toEqual('123') - expect(coercer.coerce({}, '123')).toEqual('123') - expect(coercer.coerce({ not: {} }, '123')).toEqual('123') + expect(coercer.coerce([true, false], '123')).toEqual('123') + expect(coercer.coerce([false, false], '123')).toEqual('123') + expect(coercer.coerce([{}, false], '123')).toEqual('123') + expect(coercer.coerce([{ not: {} }, false], '123')).toEqual('123') + }) + + it('do no thing with optional schema and undefined value', () => { + expect(coercer.coerce([{ type: 'number' }, true], undefined)).toEqual(undefined) + expect(coercer.coerce([{ type: 'array' }, true], undefined)).toEqual(undefined) + expect(coercer.coerce([{ type: 'object' }, true], undefined)).toEqual(undefined) }) it('can coerce primitive types', () => { - expect(coercer.coerce({ type: 'boolean' }, 'true')).toEqual(true) - expect(coercer.coerce({ type: 'boolean' }, 'false')).toEqual(false) - expect(coercer.coerce({ type: 'boolean' }, 'invalid')).toEqual('invalid') + expect(coercer.coerce([{ type: 'boolean' }, false], 'true')).toEqual(true) + expect(coercer.coerce([{ type: 'boolean' }, false], 'false')).toEqual(false) + expect(coercer.coerce([{ type: 'boolean' }, false], 'invalid')).toEqual('invalid') - expect(coercer.coerce({ type: 'number' }, '123.4')).toEqual(123.4) - expect(coercer.coerce({ type: 'number' }, 'invalid')).toEqual('invalid') + expect(coercer.coerce([{ type: 'number' }, false], '123.4')).toEqual(123.4) + expect(coercer.coerce([{ type: 'number' }, false], 'invalid')).toEqual('invalid') - expect(coercer.coerce({ type: 'integer' }, '123')).toEqual(123) - expect(coercer.coerce({ type: 'integer' }, '123.4')).toEqual('123.4') - expect(coercer.coerce({ type: 'integer' }, 'invalid')).toEqual('invalid') + expect(coercer.coerce([{ type: 'integer' }, false], '123')).toEqual(123) + expect(coercer.coerce([{ type: 'integer' }, false], '123.4')).toEqual('123.4') + expect(coercer.coerce([{ type: 'integer' }, false], 'invalid')).toEqual('invalid') + expect(coercer.coerce([{ type: 'integer' }, false], [])).toEqual([]) // -- no coercion - expect(coercer.coerce({ type: 'null' }, null)).toEqual(null) - expect(coercer.coerce({ type: 'null' }, undefined)).toEqual(undefined) - expect(coercer.coerce({ type: 'number' }, undefined)).toEqual(undefined) - expect(coercer.coerce({ type: 'boolean' }, undefined)).toEqual(undefined) + expect(coercer.coerce([{ type: 'null' }, false], null)).toEqual(null) + expect(coercer.coerce([{ type: 'null' }, false], undefined)).toEqual(undefined) + expect(coercer.coerce([{ type: 'number' }, false], undefined)).toEqual(undefined) + expect(coercer.coerce([{ type: 'boolean' }, false], undefined)).toEqual(undefined) }) it('can coerce multiple types', () => { - // TODO - // expect(coercer.coerce({ type: ['boolean', 'null'] }, 'true')).toEqual(true) - expect(coercer.coerce({ type: ['number', 'boolean'] }, '123')).toEqual(123) + expect(coercer.coerce([{ type: ['boolean', 'null'] }, false], 'true')).toEqual(true) + expect(coercer.coerce([{ type: ['number', 'boolean'] }, false], '123')).toEqual(123) }) it('can coerce native types', () => { const date = new Date() - expect(coercer.coerce({ 'type': 'string', 'x-native-type': 'date' } as any, date.toISOString())).toEqual(date) - expect(coercer.coerce({ 'type': 'string', 'x-native-type': 'date' } as any, '1972-01-01')).toEqual(new Date('1972-01-01')) - expect(coercer.coerce({ 'type': 'string', 'x-native-type': 'date' } as any, '2018-06-12T19:30')).toEqual(new Date('2018-06-12T19:30')) - expect(coercer.coerce({ 'type': 'string', 'x-native-type': 'date' } as any, '2018-06-')).toEqual('2018-06-') - expect(coercer.coerce({ 'type': 'string', 'x-native-type': 'date' } as any, 'Invalid Date')).toEqual('Invalid Date') - - expect(coercer.coerce({ 'type': 'string', 'x-native-type': 'bigint' } as any, '123')).toEqual(123n) - expect(coercer.coerce({ 'type': 'string', 'x-native-type': 'bigint' } as any, 123)).toEqual(123n) - expect(coercer.coerce({ 'type': 'string', 'x-native-type': 'bigint' } as any, Infinity)).toEqual(Infinity) - expect(coercer.coerce({ 'type': 'string', 'x-native-type': 'bigint' } as any, 'invalid')).toEqual('invalid') - - expect(coercer.coerce({ 'type': 'string', 'x-native-type': 'url' } as any, 'https://example.com')).toEqual(new URL('https://example.com')) - expect(coercer.coerce({ 'type': 'string', 'x-native-type': 'url' } as any, 'invalid')).toEqual('invalid') - - expect(coercer.coerce({ 'type': 'string', 'x-native-type': 'regexp' } as any, '/abc/i')).toEqual(new RegExp('abc', 'i')) - expect(coercer.coerce({ 'type': 'string', 'x-native-type': 'regexp' } as any, '/abc/invalid')).toEqual('/abc/invalid') - expect(coercer.coerce({ 'type': 'string', 'x-native-type': 'regexp' } as any, 'invalid')).toEqual('invalid') + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'date' } as any, false], date.toISOString())).toEqual(date) + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'date' } as any, false], '1972-01-01')).toEqual(new Date('1972-01-01')) + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'date' } as any, false], '2018-06-12T19:30')).toEqual(new Date('2018-06-12T19:30')) + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'date' } as any, false], '2018-06-')).toEqual('2018-06-') + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'date' } as any, false], 'Invalid Date')).toEqual('Invalid Date') + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'date' } as any, false], [])).toEqual([]) + + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'bigint' } as any, false], '123')).toEqual(123n) + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'bigint' } as any, false], 123)).toEqual(123n) + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'bigint' } as any, false], Infinity)).toEqual(Infinity) + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'bigint' } as any, false], 'invalid')).toEqual('invalid') + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'bigint' } as any, false], [])).toEqual([]) + + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'url' } as any, false], 'https://example.com')).toEqual(new URL('https://example.com')) + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'url' } as any, false], 'invalid')).toEqual('invalid') + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'url' } as any, false], [])).toEqual([]) + + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'regexp' } as any, false], '/abc/i')).toEqual(/abc/i) + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'regexp' } as any, false], '/abc/invalid')).toEqual('/abc/invalid') + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'regexp' } as any, false], 'invalid')).toEqual('invalid') + expect(coercer.coerce([{ 'type': 'string', 'x-native-type': 'regexp' } as any, false], [])).toEqual([]) expect(coercer.coerce( - { 'type': 'array', 'items': { type: 'number' }, 'x-native-type': 'set' } as any, + [{ 'type': 'array', 'items': { type: 'number' }, 'x-native-type': 'set' } as any, false], ['1', '2', '3', '4'], )).toEqual(new Set([1, 2, 3, 4])) expect(coercer.coerce( - { 'type': 'array', 'items': { type: 'number' }, 'x-native-type': 'set' } as any, + [{ 'type': 'array', 'items': { type: 'number' }, 'x-native-type': 'set' } as any, false], ['1', '2', '3', '4', '4'], )).toEqual([1, 2, 3, 4, 4]) expect(coercer.coerce( - { 'type': 'array', 'items': { type: 'array', prefixItems: [{ type: 'number' }, { type: 'boolean' }] }, 'x-native-type': 'map' } as any, + [{ 'type': 'array', 'items': { type: 'number' }, 'x-native-type': 'set' } as any, false], + {}, + )).toEqual({}) + + expect(coercer.coerce( + [{ 'type': 'array', 'items': { type: 'array', prefixItems: [{ type: 'number' }, { type: 'boolean' }] }, 'x-native-type': 'map' } as any, false], [['1', 'true'], ['2', 'false'], ['invalid', 'invalid']], )).toEqual(new Map([[1, true], [2, false], ['invalid', 'invalid']] as any)) expect(coercer.coerce( - { 'type': 'array', 'items': { type: 'array', prefixItems: [{ type: 'number' }, { type: 'boolean' }] }, 'x-native-type': 'map' } as any, + [{ 'type': 'array', 'items': { type: 'array', prefixItems: [{ type: 'number' }, { type: 'boolean' }] }, 'x-native-type': 'map' } as any, false], ['1'], )).toEqual(['1']) expect(coercer.coerce( - { 'type': 'array', 'items': { type: 'array', prefixItems: [{ type: 'number' }, { type: 'boolean' }] }, 'x-native-type': 'map' } as any, + [{ 'type': 'array', 'items': { type: 'array', prefixItems: [{ type: 'number' }, { type: 'boolean' }] }, 'x-native-type': 'map' } as any, false], + {}, + )).toEqual({}) + + expect(coercer.coerce( + [{ 'type': 'array', 'items': { type: 'array', prefixItems: [{ type: 'number' }, { type: 'boolean' }] }, 'x-native-type': 'map' } as any, false], [['1', 'true'], ['2', 'false'], ['1', 'false']], )).toEqual([[1, true], [2, false], [1, false]]) }) it('can coerce enum/const values', () => { - expect(coercer.coerce({ enum: [123, '234', true] }, 123)).toEqual(123) - expect(coercer.coerce({ enum: [123, '234', true] }, '234')).toEqual('234') - expect(coercer.coerce({ enum: [123, '234', true] }, '123')).toEqual(123) - expect(coercer.coerce({ enum: [123, '234', true] }, 'off')).toEqual('off') - expect(coercer.coerce({ enum: [123, '234', true] }, 'on')).toEqual(true) - expect(coercer.coerce({ enum: [123, '234', true] }, ['on'])).toEqual(['on']) - - expect(coercer.coerce({ const: true }, 'off')).toEqual('off') - expect(coercer.coerce({ const: true }, 'on')).toEqual(true) - expect(coercer.coerce({ const: true }, ['on'])).toEqual(['on']) + expect(coercer.coerce([{ enum: [123, '234', true] }, false], 123)).toEqual(123) + expect(coercer.coerce([{ enum: [123, '234', true] }, false], '234')).toEqual('234') + expect(coercer.coerce([{ enum: [123, '234', true] }, false], '123')).toEqual(123) + expect(coercer.coerce([{ enum: [123, '234', true] }, false], 'off')).toEqual('off') + expect(coercer.coerce([{ enum: [123, '234', true] }, false], 'on')).toEqual(true) + expect(coercer.coerce([{ enum: [123, '234', true] }, false], ['on'])).toEqual(['on']) + + expect(coercer.coerce([{ const: true }, false], 'off')).toEqual('off') + expect(coercer.coerce([{ const: true }, false], 'on')).toEqual(true) + expect(coercer.coerce([{ const: true }, false], ['on'])).toEqual(['on']) }) it('can coerce arrays/tuples', () => { expect( - coercer.coerce({ type: 'array', items: { type: 'number' } }, ['1', '2', '3']), + coercer.coerce([{ type: 'array', items: { type: 'number' } }, false], ['1', '2', '3']), ).toEqual([1, 2, 3]) expect( - coercer.coerce({ type: 'array', items: { type: 'string' } }, ['1', '2', '3']), + coercer.coerce([{ type: 'array', items: { type: 'string' } }, false], ['1', '2', '3']), ).toEqual(['1', '2', '3']) // draft-07 expect( coercer.coerce( - { type: 'array', items: [{ type: 'number' }, { type: 'boolean' }], additionalItems: { type: 'number' } }, + [{ type: 'array', items: [{ type: 'number' }, { type: 'boolean' }], additionalItems: { type: 'number' } } as any, false], ['1', 'true', '2', 'false'], ), ).toEqual([1, true, 2, 'false']) @@ -114,14 +134,14 @@ describe('jsonSchemaCoercer', () => { // draft-2020 expect( coercer.coerce( - { type: 'array', prefixItems: [{ type: 'number' }, { type: 'boolean' }], items: { type: 'number' } }, + [{ type: 'array', prefixItems: [{ type: 'number' }, { type: 'boolean' }], items: { type: 'number' } }, false], ['1', 'true', '2', 'false'], ), ).toEqual([1, true, 2, 'false']) expect( coercer.coerce( - { type: 'array', prefixItems: [{ type: 'number' }, { type: 'boolean' }] }, + [{ type: 'array', prefixItems: [{ type: 'number' }, { type: 'boolean' }] }, false], ['1', 'true', '2', 'false'], ), ).toEqual([1, true, '2', 'false']) @@ -130,33 +150,33 @@ describe('jsonSchemaCoercer', () => { it('can coerce objects', () => { expect( coercer.coerce( - { type: 'object', properties: { a: { type: 'number' }, b: { type: 'boolean' } } }, + [{ type: 'object', properties: { a: { type: 'number' }, b: { type: 'boolean' } } }, false], { a: '123', b: 'true' }, ), ).toEqual({ a: 123, b: true }) expect( coercer.coerce( - { type: 'object', properties: { a: { type: 'number' }, b: { type: 'boolean' } }, required: ['a'] }, + [{ type: 'object', properties: { a: { type: 'number' }, b: { type: 'boolean' } }, required: ['a'] }, false], { a: undefined, b: 'true' }, ), ).toEqual({ a: undefined, b: true }) expect( coercer.coerce( - { + [{ type: 'object', properties: { a: { type: 'number' } }, patternProperties: { '^b': { 'type': 'string', 'x-native-type': 'bigint' } as any }, additionalProperties: { type: 'boolean' }, - }, + }, false], { a: '123', b: '123', b1: '123', c: 'false' }, ), ).toEqual({ a: 123, b: 123n, b1: 123n, c: false }) expect( coercer.coerce( - { type: 'object', properties: { 0: { type: 'number' }, 1: { type: 'boolean' } } }, + [{ type: 'object', properties: { 0: { type: 'number' }, 1: { type: 'boolean' } } }, false], ['123', 'true'], ), ).toEqual({ 0: 123, 1: true }) @@ -172,14 +192,14 @@ describe('jsonSchemaCoercer', () => { ], } as any - expect(coercer.coerce(schema, 123)).toEqual(123) - expect(coercer.coerce(schema, '123')).toEqual(123) - expect(coercer.coerce(schema, true)).toEqual(true) - expect(coercer.coerce(schema, 'true')).toEqual(true) - expect(coercer.coerce(schema, { a: '123' })).toEqual({ a: 123 }) - expect(coercer.coerce(schema, { a: '123', b: undefined })).toEqual({ a: 123, b: undefined }) - expect(coercer.coerce(schema, { a: '123', b: '456' })).toEqual({ a: 123, b: 456 }) - expect(coercer.coerce(schema, 'invalid')).toEqual('invalid') + expect(coercer.coerce([schema, false], 123)).toEqual(123) + expect(coercer.coerce([schema, false], '123')).toEqual(123) + expect(coercer.coerce([schema, false], true)).toEqual(true) + expect(coercer.coerce([schema, false], 'true')).toEqual(true) + expect(coercer.coerce([schema, false], { a: '123' })).toEqual({ a: 123 }) + expect(coercer.coerce([schema, false], { a: '123', b: undefined })).toEqual({ a: 123, b: undefined }) + expect(coercer.coerce([schema, false], { a: '123', b: '456' })).toEqual({ a: 123, b: 456 }) + expect(coercer.coerce([schema, false], 'invalid')).toEqual('invalid') const schema2 = { anyOf: [ @@ -188,8 +208,8 @@ describe('jsonSchemaCoercer', () => { ], } as any - expect(coercer.coerce(schema2, { a: 'true', b: '123' })).toEqual({ a: true, b: 123 }) - expect(coercer.coerce(schema2, { a: '123' })).toEqual({ a: 123 }) + expect(coercer.coerce([schema2, false], { a: 'true', b: '123' })).toEqual({ a: true, b: 123 }) + expect(coercer.coerce([schema2, false], { a: '123' })).toEqual({ a: 123 }) const schema3 = { anyOf: [ @@ -198,8 +218,19 @@ describe('jsonSchemaCoercer', () => { ], } as any - expect(coercer.coerce(schema3, ['1', 'true', 'true', '2'])).toEqual([1, true, true, 2]) - expect(coercer.coerce(schema3, ['1', '2'])).toEqual([1, 2]) + expect(coercer.coerce([schema3, false], ['1', 'true', 'true', '2'])).toEqual([1, true, true, 2]) + expect(coercer.coerce([schema3, false], ['1', '2'])).toEqual([1, 2]) + + const schema4 = { + oneOf: [ + { type: 'number', not: { const: 1 } }, + { 'type': 'number', 'x-native-type': 'bigint', 'not': { const: 2n } }, + ], + } + + expect(coercer.coerce([schema4, false], '1')).toEqual(1n) + expect(coercer.coerce([schema4, false], '2')).toEqual(2) + expect(coercer.coerce([schema4, false], '3')).toEqual(3) }) it('can handle discriminated union types', () => { @@ -210,8 +241,8 @@ describe('jsonSchemaCoercer', () => { ], } as any - expect(coercer.coerce(schema, { t: '1', v: '123' })).toEqual({ t: 1, v: 123 }) - expect(coercer.coerce(schema, { t: '2', v: '123' })).toEqual({ t: 2, v: 123n }) + expect(coercer.coerce([schema, false], { t: '1', v: '123' })).toEqual({ t: 1, v: 123 }) + expect(coercer.coerce([schema, false], { t: '2', v: '123' })).toEqual({ t: 2, v: 123n }) }) it('can coerce intersection types', () => { @@ -222,56 +253,26 @@ describe('jsonSchemaCoercer', () => { ], } as any - expect(coercer.coerce(schema, { a: '123', b: '456', c: '789' })).toEqual({ a: 123, b: 456, c: '789' }) - expect(coercer.coerce(schema, { a: '123' })).toEqual({ a: 123 }) - expect(coercer.coerce(schema, { b: '456' })).toEqual({ b: 456 }) - expect(coercer.coerce(schema, 'invalid')).toEqual('invalid') + expect(coercer.coerce([schema, false], { a: '123', b: '456', c: '789' })).toEqual({ a: 123, b: 456, c: '789' }) + expect(coercer.coerce([schema, false], { a: '123' })).toEqual({ a: 123 }) + expect(coercer.coerce([schema, false], { b: '456' })).toEqual({ b: 456 }) + expect(coercer.coerce([schema, false], 'invalid')).toEqual('invalid') }) - it('can coerce recursive types', () => { + it('can coerce complex structures', () => { const schema = { - type: 'object', - properties: { - a: { type: 'boolean' }, - b: { $ref: '#/components/schema/Test' }, - }, - required: ['a'], - } as any - - expect(coercer.coerce(schema, { - a: 'true', - b: { - a: 'off', - b: { - a: 'invalid', - b: 'invalid', - }, - }, - }, { - components: { - '#/components/schema/Test': schema, - }, - })).toEqual({ - a: true, - b: { - a: false, - b: { - a: 'invalid', - b: 'invalid', + $defs: { + ArrayOfDate: { + type: 'array', + items: { 'type': 'string', 'x-native-type': 'date' }, }, }, - }) - }) - - it('can coerce complex structures', () => { - const schema = { type: 'object', properties: { a: { type: 'boolean' }, b: { type: 'number' }, c: { - type: 'array', - items: { 'type': 'string', 'x-native-type': 'date' }, + $ref: '#/$defs/ArrayOfDate', }, d: { type: 'object', @@ -287,7 +288,7 @@ describe('jsonSchemaCoercer', () => { required: ['a'], } - expect(coercer.coerce(schema, { + expect(coercer.coerce([schema, false], { a: 'true', b: '123', c: ['2020-01-01', '2020-01-02'], @@ -303,4 +304,98 @@ describe('jsonSchemaCoercer', () => { }, }) }) + + it('can coerce recursive types', () => { + const schema: JsonSchema = { + $defs: { + get Test() { + return schema + }, + }, + type: 'object', + properties: { + a: { type: 'boolean' }, + b: { $ref: '#/$defs/Test' }, + }, + required: ['a'], + } + + expect(coercer.coerce([schema, false], { + a: 'true', + b: { + a: 'off', + b: { + a: 'invalid', + b: { + a: 'true', + b: 'invalid', + }, + }, + }, + })).toEqual({ + a: true, + b: { + a: false, + b: { + a: 'invalid', + b: { + a: true, + b: 'invalid', + }, + }, + }, + }) + + const schema2: JsonSchema = { + type: 'object', + properties: { + a: { type: 'boolean' }, + b: { $ref: '#' }, + }, + required: ['a'], + } + + expect(coercer.coerce([schema2, false], { + a: 'true', + b: { + a: 'off', + b: { + a: 'invalid', + b: { + a: 'true', + b: 'invalid', + }, + }, + }, + })).toEqual({ + a: true, + b: { + a: false, + b: { + a: 'invalid', + b: { + a: true, + b: 'invalid', + }, + }, + }, + }) + }) + + it('ignore unresolvable $ref', () => { + const schema: JsonSchema = { + $ref: '#/$defs/unExisted', + } + + expect(coercer.coerce([schema, false], { a: true })).toEqual({ + a: true, + }) + + const schema2: JsonSchema = { + $ref: 'canNotResolve', + } + expect(coercer.coerce([schema2, false], { a: true })).toEqual({ + a: true, + }) + }) }) diff --git a/packages/json-schema/src/coercer.ts b/packages/json-schema/src/coercer.ts index c0066ff1c..3cb1c1a18 100644 --- a/packages/json-schema/src/coercer.ts +++ b/packages/json-schema/src/coercer.ts @@ -1,38 +1,46 @@ import type { JsonSchema } from './types' -import { guard, isObject, toArray } from '@orpc/shared' +import { get, isPlainObject, toArray, tryOrUndefined } from '@orpc/shared' +import { decodeJsonPointerSegment } from './ref-utils' import { JsonSchemaXNativeType } from './types' const FLEXIBLE_DATE_FORMAT_REGEX = /^[^-]+-[^-]+-[^-]+$/ -export interface JsonSchemaCoerceOptions { - components?: Record -} - export class JsonSchemaCoercer { - coerce(schema: JsonSchema, value: unknown, options: JsonSchemaCoerceOptions = {}): unknown { - const [, coerced] = this.#coerce(schema, value, options) + coerce([schema, optional]: [schema: JsonSchema, optional: boolean], value: unknown): unknown { + if (optional && value === undefined) { + return value + } + + const [, coerced] = this.coerceInternal(schema, schema, value) return coerced } - #coerce(schema: JsonSchema, originalValue: unknown, options: JsonSchemaCoerceOptions): [satisfied: boolean, coerced: unknown] { + private coerceInternal(rootSchema: JsonSchema, schema: JsonSchema, value: unknown): [satisfied: boolean, coerced: unknown] { if (typeof schema === 'boolean') { - return [schema, originalValue] + return [schema, value] } if (Array.isArray(schema.type)) { - return this.#coerce({ - anyOf: schema.type.map(type => ({ ...schema, type })), - }, originalValue, options) + return this.coerceInternal( + rootSchema, + { anyOf: schema.type.map(type => ({ ...schema, type })) }, + value, + ) } - let coerced = originalValue + let coerced = value let satisfied = true if (typeof schema.$ref === 'string') { - const refSchema = options?.components?.[schema.$ref] + const resolved + = schema.$ref.startsWith('#/') + ? get(rootSchema, schema.$ref.slice('#/'.length).split('/').map(decodeJsonPointerSegment)) as JsonSchema | undefined + : schema.$ref === '#' + ? rootSchema + : undefined - if (refSchema !== undefined) { - const [subSatisfied, subCoerced] = this.#coerce(refSchema, coerced, options) + if (resolved !== undefined) { + const [subSatisfied, subCoerced] = this.coerceInternal(rootSchema, resolved, coerced) coerced = subCoerced satisfied = subSatisfied @@ -42,13 +50,13 @@ export class JsonSchemaCoercer { const enumValues = schema.const !== undefined ? [schema.const] : schema.enum if (enumValues !== undefined && !enumValues.includes(coerced)) { if (typeof coerced === 'string') { - const numberValue = this.#stringToNumber(coerced) + const numberValue = stringToNumber(coerced) if (enumValues.includes(numberValue)) { coerced = numberValue } else { - const booleanValue = this.#stringToBoolean(coerced) + const booleanValue = stringToBoolean(coerced) if (enumValues.includes(booleanValue)) { coerced = booleanValue @@ -63,7 +71,7 @@ export class JsonSchemaCoercer { } } - if (typeof schema.type === 'string') { + if (schema.type) { switch (schema.type) { case 'null': { if (coerced !== null) { @@ -81,7 +89,7 @@ export class JsonSchemaCoercer { } case 'number': { if (typeof coerced === 'string') { - coerced = this.#stringToNumber(coerced) + coerced = stringToNumber(coerced) } if (typeof coerced !== 'number') { @@ -92,7 +100,7 @@ export class JsonSchemaCoercer { } case 'integer': { if (typeof coerced === 'string') { - coerced = this.#stringToInteger(coerced) + coerced = stringToInteger(coerced) } if (typeof coerced !== 'number' || !Number.isInteger(coerced)) { @@ -103,7 +111,7 @@ export class JsonSchemaCoercer { } case 'boolean': { if (typeof coerced === 'string') { - coerced = this.#stringToBoolean(coerced) + coerced = stringToBoolean(coerced) } if (typeof coerced !== 'boolean') { @@ -133,7 +141,7 @@ export class JsonSchemaCoercer { return item } - const [subSatisfied, subCoerced] = this.#coerce(subSchema, item, options) + const [subSatisfied, subCoerced] = this.coerceInternal(rootSchema, subSchema, item) if (!subSatisfied) { satisfied = false @@ -164,7 +172,7 @@ export class JsonSchemaCoercer { coerced = { ...coerced } } - if (isObject(coerced)) { + if (isPlainObject(coerced)) { let shouldUseCoercedItems = false const coercedItems: Record = {} @@ -185,7 +193,7 @@ export class JsonSchemaCoercer { satisfied = false } else { - const [subSatisfied, subCoerced] = this.#coerce(subSchema, value, options) + const [subSatisfied, subCoerced] = this.coerceInternal(rootSchema, subSchema, value) coercedItems[key] = subCoerced if (!subSatisfied) { @@ -219,7 +227,7 @@ export class JsonSchemaCoercer { switch (schema['x-native-type']) { case JsonSchemaXNativeType.Date: { if (typeof coerced === 'string') { - coerced = this.#stringToDate(coerced) + coerced = stringToDate(coerced) } if (!(coerced instanceof Date)) { @@ -231,10 +239,10 @@ export class JsonSchemaCoercer { case JsonSchemaXNativeType.BigInt: { switch (typeof coerced) { case 'string': - coerced = this.#stringToBigInt(coerced) + coerced = stringToBigInt(coerced) break case 'number': - coerced = this.#numberToBigInt(coerced) + coerced = numberToBigInt(coerced) break } @@ -246,7 +254,7 @@ export class JsonSchemaCoercer { } case JsonSchemaXNativeType.RegExp: { if (typeof coerced === 'string') { - coerced = this.#stringToRegExp(coerced) + coerced = stringToRegExp(coerced) } if (!(coerced instanceof RegExp)) { @@ -257,7 +265,7 @@ export class JsonSchemaCoercer { } case JsonSchemaXNativeType.Url: { if (typeof coerced === 'string') { - coerced = this.#stringToURL(coerced) + coerced = stringToURL(coerced) } if (!(coerced instanceof URL)) { @@ -268,7 +276,7 @@ export class JsonSchemaCoercer { } case JsonSchemaXNativeType.Set: { if (Array.isArray(coerced)) { - coerced = this.#arrayToSet(coerced) + coerced = arrayToSet(coerced) } if (!(coerced instanceof Set)) { @@ -279,7 +287,7 @@ export class JsonSchemaCoercer { } case JsonSchemaXNativeType.Map: { if (Array.isArray(coerced)) { - coerced = this.#arrayToMap(coerced) + coerced = arrayToMap(coerced) } if (!(coerced instanceof Map)) { @@ -293,7 +301,7 @@ export class JsonSchemaCoercer { if (schema.allOf) { for (const subSchema of schema.allOf) { - const [subSatisfied, subCoerced] = this.#coerce(subSchema, coerced, options) + const [subSatisfied, subCoerced] = this.coerceInternal(rootSchema, subSchema, coerced) coerced = subCoerced @@ -308,7 +316,7 @@ export class JsonSchemaCoercer { let bestOptions: { coerced: unknown, satisfied: boolean } | undefined for (const subSchema of schema[key]) { - const [subSatisfied, subCoerced] = this.#coerce(subSchema, coerced, options) + const [subSatisfied, subCoerced] = this.coerceInternal(rootSchema, subSchema, coerced) if (subSatisfied) { if (!bestOptions || subCoerced === coerced) { @@ -327,7 +335,7 @@ export class JsonSchemaCoercer { } if (typeof schema.not !== 'undefined') { - const [notSatisfied] = this.#coerce(schema.not, coerced, options) + const [notSatisfied] = this.coerceInternal(rootSchema, schema.not, coerced) if (notSatisfied) { satisfied = false @@ -336,95 +344,95 @@ export class JsonSchemaCoercer { return [satisfied, coerced] } +} - #stringToNumber(value: string): number | string { - const num = Number.parseFloat(value) - - if (Number.isNaN(num) || num !== Number(value)) { - return value - } +function stringToNumber(value: string): number | string { + const num = Number.parseFloat(value) - return num + if (Number.isNaN(num) || num !== Number(value)) { + return value } - #stringToInteger(value: string): number | string { - const num = Number.parseInt(value) + return num +} - if (Number.isNaN(num) || num !== Number(value)) { - return value - } +function stringToInteger(value: string): number | string { + const num = Number.parseInt(value) - return num + if (Number.isNaN(num) || num !== Number(value)) { + return value } - #stringToBoolean(value: string): boolean | string { - const lower = value.toLowerCase() - - if (lower === 'false' || lower === 'off') { - return false - } + return num +} - if (lower === 'true' || lower === 'on') { - return true - } +function stringToBoolean(value: string): boolean | string { + const lower = value.toLowerCase() - return value + if (lower === 'false' || lower === 'off') { + return false } - #stringToBigInt(value: string): bigint | string { - return guard(() => BigInt(value)) ?? value + if (lower === 'true' || lower === 'on') { + return true } - #numberToBigInt(value: number): bigint | number { - return guard(() => BigInt(value)) ?? value - } + return value +} - #stringToDate(value: string): Date | string { - const date = new Date(value) +function stringToBigInt(value: string): bigint | string { + return tryOrUndefined(() => BigInt(value)) ?? value +} - if (Number.isNaN(date.getTime()) || !FLEXIBLE_DATE_FORMAT_REGEX.test(value)) { - return value - } +function numberToBigInt(value: number): bigint | number { + return tryOrUndefined(() => BigInt(value)) ?? value +} + +function stringToDate(value: string): Date | string { + const date = new Date(value) - return date + if (Number.isNaN(date.getTime()) || !FLEXIBLE_DATE_FORMAT_REGEX.test(value)) { + return value } - #stringToRegExp(value: string): RegExp | string { - const match = value.match(/^\/(.*)\/([a-z]*)$/) + return date +} - if (match) { - const [, pattern, flags] = match - return guard(() => new RegExp(pattern!, flags)) ?? value - } +function stringToRegExp(value: string): RegExp | string { + const match = value.match(/^\/(.*)\/([a-z]*)$/) - return value + if (match) { + const [, pattern, flags] = match + return tryOrUndefined(() => new RegExp(pattern!, flags)) ?? value } - #stringToURL(value: string): URL | string { - return guard(() => new URL(value)) ?? value - } + return value +} - #arrayToSet(value: unknown[]): Set | unknown[] { - const set = new Set(value) +function stringToURL(value: string): URL | string { + return tryOrUndefined(() => new URL(value)) ?? value +} - if (set.size !== value.length) { - return value - } +function arrayToSet(value: unknown[]): Set | unknown[] { + const set = new Set(value) - return set + if (set.size !== value.length) { + return value } - #arrayToMap(value: unknown[]): Map | unknown[] { - if (value.some(item => !Array.isArray(item) || item.length !== 2)) { - return value - } + return set +} - const result = new Map(value as [unknown, unknown][]) +function arrayToMap(value: unknown[]): Map | unknown[] { + if (value.some(item => !Array.isArray(item) || item.length !== 2)) { + return value + } - if (result.size !== value.length) { - return value - } + const result = new Map(value as [unknown, unknown][]) - return result + if (result.size !== value.length) { + return value } + + return result } diff --git a/packages/json-schema/src/composition-utils.test.ts b/packages/json-schema/src/composition-utils.test.ts new file mode 100644 index 000000000..67dabe173 --- /dev/null +++ b/packages/json-schema/src/composition-utils.test.ts @@ -0,0 +1,1620 @@ +import type { JsonSchema } from './types' +import type { JsonObjectSchema } from './utils' +import { + combineJsonObjectSchemaEntries, + combineJsonSchemasWithComposition, + deduplicateJsonSchemas, + extractJsonObjectSchemaEntries, + flattenJsonUnionSchema, + isJsonPrimitiveSchema, + matchArrayableJsonSchema, +} from './composition-utils' + +it('isJsonPrimitiveSchema', () => { + expect(isJsonPrimitiveSchema({ type: 'string' })).toBe(true) + expect(isJsonPrimitiveSchema({ const: 'fixed' })).toBe(true) + expect(isJsonPrimitiveSchema({ enum: ['a', 'b'] })).toBe(true) + expect(isJsonPrimitiveSchema({ description: 'primitive union', anyOf: [{ type: 'number' }, { oneOf: [{ type: 'boolean' }, { const: 'x' }] }] })).toBe(true) + + expect(isJsonPrimitiveSchema(true)).toBe(false) + expect(isJsonPrimitiveSchema({ type: 'object', properties: { a: { type: 'string' } } })).toBe(false) + expect(isJsonPrimitiveSchema({ anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] })).toBe(false) +}) + +describe('extractJsonObjectSchemaEntries', () => { + it('returns undefined for non object-able schemas', () => { + expect(extractJsonObjectSchemaEntries(true)).toBeUndefined() + expect(extractJsonObjectSchemaEntries(false)).toBeUndefined() + expect(extractJsonObjectSchemaEntries({ type: 'string' })).toBeUndefined() + expect(extractJsonObjectSchemaEntries({ anyOf: [false] })).toBeUndefined() + expect(extractJsonObjectSchemaEntries({ anyOf: [{ type: 'string' }] })).toBeUndefined() + expect(extractJsonObjectSchemaEntries({ oneOf: [{ type: 'string' }] })).toBeUndefined() + expect(extractJsonObjectSchemaEntries({ allOf: [{ type: 'string' }] })).toBeUndefined() + expect(extractJsonObjectSchemaEntries({ + $ref: '#/$defs/Missing', + $defs: { + Present: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + }, + }, + })).toBeUndefined() + expect(extractJsonObjectSchemaEntries({ })).toBeUndefined() + }) + + it('return empty array for empty object like schemas', () => { + expect(extractJsonObjectSchemaEntries({ properties: {} })).toEqual([]) + expect(extractJsonObjectSchemaEntries({ anyOf: [{ additionalProperties: {} }] })).toEqual([]) + expect(extractJsonObjectSchemaEntries({ oneOf: [{ properties: {} }] })).toEqual([]) + expect(extractJsonObjectSchemaEntries({ allOf: [{ type: 'object' }] })).toEqual([]) + }) + + it('parses direct object properties and preserves root $defs on item schemas', () => { + const schema: JsonObjectSchema = { + type: 'object', + properties: { + requiredRef: { $ref: '#/$defs/Shared' }, + optionalNever: false, + }, + required: ['requiredRef'], + $defs: { + Shared: { type: 'string' }, + }, + } + + expect(extractJsonObjectSchemaEntries(schema)).toEqual([ + ['requiredRef', { $ref: '#/$defs/Shared', $defs: schema.$defs }, false], + ['optionalNever', false, true], + ]) + }) + + it('resolves local refs before parsing direct object entries', () => { + expect(extractJsonObjectSchemaEntries({ + $ref: '#/$defs/Node', + $defs: { + Node: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: ['value'], + }, + }, + })).toEqual([ + ['value', { + type: 'string', + $defs: { + Node: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: ['value'], + }, + }, + }, false], + ]) + }) + + it('merges composed object branches into property entries', () => { + const schema: JsonSchema = { + anyOf: [ + { + type: 'object', + properties: { + left: { type: 'string' }, + shared: { type: 'string' }, + shared2: { type: 'number' }, + }, + required: ['left'], + }, + { + type: 'object', + properties: { + right: { type: 'number' }, + shared: { maxLength: 10, type: 'string' }, + shared2: { type: 'boolean' }, + }, + }, + ], + allOf: [ + { + type: 'object', + properties: { + shared: { minLength: 1, type: 'string' }, + fixed: { const: 'x' }, + }, + required: ['shared'], + }, + ], + $defs: { + Shared: { type: 'boolean' }, + }, + } + + expect(extractJsonObjectSchemaEntries(schema)).toEqual([ + ['left', { type: 'string', $defs: schema.$defs }, true], + ['shared', { + allOf: [ + { minLength: 1, type: 'string' }, + { + anyOf: [ + { type: 'string' }, + { maxLength: 10, type: 'string' }, + ], + }, + ], + $defs: schema.$defs, + }, false], + ['shared2', { + $defs: { + Shared: { + type: 'boolean', + }, + }, + anyOf: [{ type: 'number' }, { type: 'boolean' }], + }, true], + ['right', { type: 'number', $defs: schema.$defs }, true], + ['fixed', { const: 'x', $defs: schema.$defs }, true], + ]) + }) + + it('includes same-level object properties when composition keywords are also present', () => { + expect(extractJsonObjectSchemaEntries({ + type: 'object', + oneOf: [ + { + type: 'object', + properties: { + branch: { type: 'string' }, + }, + }, + ], + })).toEqual([ + ['branch', { type: 'string' }, true], + ]) + }) + + it('wraps pure union property merges in anyOf', () => { + expect(extractJsonObjectSchemaEntries({ + anyOf: [ + { + type: 'object', + properties: { + value: { type: 'string' }, + }, + }, + { + type: 'object', + properties: { + value: { type: 'number' }, + }, + }, + ], + })).toEqual([ + ['value', { anyOf: [{ type: 'string' }, { type: 'number' }] }, true], + ]) + }) + + it('wraps pure intersection property merges in allOf', () => { + expect(extractJsonObjectSchemaEntries({ + allOf: [ + { + type: 'object', + properties: { + value: { type: 'string' }, + }, + }, + { + type: 'object', + properties: { + value: { minLength: 1 }, + }, + }, + ], + })).toEqual([ + ['value', { allOf: [{ type: 'string' }, { minLength: 1 }] }, true], + ]) + }) + + it('deduplicates identical union and intersection property schemas', () => { + expect(extractJsonObjectSchemaEntries({ + anyOf: [ + { + type: 'object', + properties: { + a: { type: 'string' }, + }, + required: ['a'], + }, + ], + allOf: [ + { + type: 'object', + properties: { + a: { type: 'string' }, + }, + }, + ], + })).toEqual([ + ['a', { type: 'string' }, false], + ]) + }) + + it('marks union properties optional unless every branch requires them', () => { + expect(extractJsonObjectSchemaEntries({ + anyOf: [ + { + type: 'object', + properties: { + p1: { type: 'boolean' }, + p2: { type: 'boolean' }, + p3: { type: 'boolean' }, + }, + required: ['p1', 'p2'], + }, + { + type: 'object', + properties: { + p1: { type: 'boolean' }, + p2: { type: 'boolean' }, + p3: { type: 'boolean' }, + }, + required: ['p1', 'p3'], + }, + { + type: 'object', + properties: { + p1: { type: 'boolean' }, + p2: { type: 'boolean' }, + p3: { type: 'boolean' }, + }, + required: ['p1', 'p2', 'p3'], + }, + ], + })).toEqual([ + ['p1', { type: 'boolean' }, false], + ['p2', { type: 'boolean' }, true], + ['p3', { type: 'boolean' }, true], + ]) + }) + + it('marks intersection properties required when any branch requires them', () => { + expect(extractJsonObjectSchemaEntries({ + allOf: [ + { + type: 'object', + properties: { + p1: { type: 'boolean' }, + p2: { type: 'boolean' }, + p3: { type: 'boolean' }, + }, + required: ['p2'], + }, + { + type: 'object', + properties: { + p1: { type: 'boolean' }, + p2: { type: 'boolean' }, + p3: { type: 'boolean' }, + }, + required: ['p3'], + }, + { + type: 'object', + properties: { + p1: { type: 'boolean' }, + p2: { type: 'boolean' }, + p3: { type: 'boolean' }, + }, + }, + ], + })).toEqual([ + ['p1', { type: 'boolean' }, true], + ['p2', { type: 'boolean' }, false], + ['p3', { type: 'boolean' }, false], + ]) + }) + + it('uses hoisted defs for recursive root refs', () => { + const schema: JsonSchema = { + type: 'object', + properties: { + self: { $ref: '#' }, + leaf: { $ref: '#/$defs/Leaf' }, + }, + required: ['self'], + $defs: { + Leaf: { type: 'string' }, + }, + } + + expect(extractJsonObjectSchemaEntries(schema)).toEqual([ + ['self', { + $ref: '#/$defs/__schema0', + $defs: { + Leaf: { type: 'string' }, + __schema0: { + type: 'object', + properties: { + self: { $ref: '#/$defs/__schema0' }, + leaf: { $ref: '#/$defs/Leaf' }, + }, + required: ['self'], + }, + }, + }, false], + ['leaf', { + $ref: '#/$defs/Leaf', + $defs: { + Leaf: { type: 'string' }, + __schema0: { + type: 'object', + properties: { + self: { $ref: '#/$defs/__schema0' }, + leaf: { $ref: '#/$defs/Leaf' }, + }, + required: ['self'], + }, + }, + }, true], + ]) + }) + + it('extracts properties from recursive $ref nested unions and intersections', () => { + const schema: JsonSchema = { + $ref: '#/$defs/Node', + $defs: { + Node: { + anyOf: [ + { + type: 'object', + properties: { + p1: { type: 'string' }, + }, + }, + { anyOf: [{ $ref: '#/$defs/Shared' }] }, + { anyOf: [{ $ref: '#/$defs/Node' }] }, + ], + allOf: [ + { + type: 'object', + properties: { + p1: { type: 'string' }, + p2: { type: 'string' }, + }, + required: ['p1'], + }, + { allOf: [{ $ref: '#/$defs/Node' }] }, + ], + }, + Shared: { + type: 'object', + properties: { + p3: { type: 'boolean' }, + }, + }, + }, + } + + expect(extractJsonObjectSchemaEntries(schema)).toEqual([ + ['p1', { $defs: schema.$defs, type: 'string' }, false], + ['p3', { $defs: schema.$defs, type: 'boolean' }, true], + ['p2', { $defs: schema.$defs, type: 'string' }, true], + ]) + }) + + it('keeps properties required when a single anyOf branch contains an allOf composition', () => { + const schema: JsonSchema = { + anyOf: [ + { + allOf: [ + { properties: { p1: { type: 'boolean' } }, required: ['p1'] }, + { properties: { p2: { type: 'string' } }, required: ['p2'] }, + ], + }, + ], + } + + expect(extractJsonObjectSchemaEntries(schema)).toEqual([ + ['p1', { $defs: schema.$defs, type: 'boolean' }, false], + ['p2', { $defs: schema.$defs, type: 'string' }, false], + ]) + }) + + it('marks nested anyOf properties optional unless every allOf branch requires them', () => { + expect(extractJsonObjectSchemaEntries({ + anyOf: [ + { + allOf: [ + { properties: { shared: { type: 'boolean' } }, required: ['shared'] }, + { properties: { left: { type: 'string' } }, required: ['left'] }, + ], + }, + { + allOf: [ + { properties: { shared: { type: 'boolean' } }, required: ['shared'] }, + { properties: { right: { type: 'number' } }, required: ['right'] }, + ], + }, + ], + })).toEqual([ + ['shared', { type: 'boolean' }, false], + ['left', { type: 'string' }, true], + ['right', { type: 'number' }, true], + ]) + }) + + it('marks nested oneOf properties optional unless every allOf branch requires them', () => { + expect(extractJsonObjectSchemaEntries({ + oneOf: [ + { + allOf: [ + { properties: { shared: { type: 'boolean' } }, required: ['shared'] }, + { properties: { left: { type: 'string' } }, required: ['left'] }, + ], + }, + { + allOf: [ + { properties: { shared: { type: 'boolean' } }, required: ['shared'] }, + { properties: { right: { type: 'number' } }, required: ['right'] }, + ], + }, + ], + })).toEqual([ + ['shared', { type: 'boolean' }, false], + ['left', { type: 'string' }, true], + ['right', { type: 'number' }, true], + ]) + }) + + it('preserves required properties from nested anyOf branches when merged through outer allOf', () => { + expect(extractJsonObjectSchemaEntries({ + allOf: [ + { + anyOf: [ + { + type: 'object', + properties: { + shared: { type: 'string' }, + left: { type: 'boolean' }, + }, + required: ['shared', 'left'], + }, + { + type: 'object', + properties: { + shared: { type: 'string' }, + right: { type: 'number' }, + }, + required: ['shared'], + }, + ], + }, + { + properties: { + extra: { type: 'null' }, + }, + required: ['extra'], + }, + ], + })).toEqual([ + ['shared', { type: 'string' }, false], + ['left', { type: 'boolean' }, true], + ['right', { type: 'number' }, true], + ['extra', { type: 'null' }, false], + ]) + }) + + it('includes top-level required for object schemas with properties and composition', () => { + expect(extractJsonObjectSchemaEntries({ + type: 'object', + properties: { + a: { type: 'string' }, + }, + required: ['a'], + allOf: [ + { + type: 'object', + properties: { + b: { type: 'number' }, + }, + }, + ], + })).toEqual([ + ['a', { type: 'string' }, false], + ['b', { type: 'number' }, true], + ]) + }) + + it('handles top-level object with properties but no required', () => { + expect(extractJsonObjectSchemaEntries({ + type: 'object', + properties: { + a: { type: 'string' }, + }, + allOf: [ + { + type: 'object', + properties: { + b: { type: 'number' }, + }, + }, + ], + })).toEqual([ + ['a', { type: 'string' }, true], + ['b', { type: 'number' }, true], + ]) + }) + + it('handles composition branches that are object schemas without properties', () => { + expect(extractJsonObjectSchemaEntries({ + anyOf: [ + { type: 'object' }, + { + type: 'object', + properties: { + a: { type: 'string' }, + }, + }, + ], + })).toEqual([ + ['a', { type: 'string' }, true], + ]) + }) + + it('sorts anyOf and oneOf groups after allOf groups', () => { + expect(extractJsonObjectSchemaEntries({ + anyOf: [ + { + type: 'object', + properties: { + shared: { type: 'string' }, + }, + }, + ], + oneOf: [ + { + type: 'object', + properties: { + shared: { type: 'number' }, + }, + }, + ], + })).toEqual([ + ['shared', { allOf: [{ type: 'string' }, { type: 'number' }] }, true], + ]) + }) +}) + +describe('combineJsonObjectSchemaEntries', () => { + it('combines entries into a single object schema', () => { + expect(combineJsonObjectSchemaEntries([ + ['requiredRef', { + $ref: '#/$defs/Shared', + $defs: { + Shared: { type: 'string' }, + }, + }, false], + ['optionalNever', false, true], + ])).toEqual({ + type: 'object', + properties: { + requiredRef: { + $ref: '#/$defs/Shared', + }, + optionalNever: false, + }, + required: ['requiredRef'], + $defs: { + Shared: { type: 'string' }, + }, + }) + }) + + it('rewrites absolute refs to the property path when embedding property schemas', () => { + expect(combineJsonObjectSchemaEntries([ + ['node', { + type: 'object', + properties: { + self: { $ref: '#' }, + brother: { $ref: '#/properties/self' }, + unchanged: { $ref: '0/unchanged' }, + }, + required: ['self'], + }, false], + ])).toEqual({ + type: 'object', + properties: { + node: { + type: 'object', + properties: { + self: { $ref: '#/properties/node' }, + brother: { $ref: '#/properties/node/properties/self' }, + unchanged: { $ref: '0/unchanged' }, + }, + required: ['self'], + }, + }, + required: ['node'], + }) + }) + + it('dedupe and renames conflicting defs while hoisting property schemas', () => { + expect(combineJsonObjectSchemaEntries([ + ['left', { + $ref: '#/$defs/Shared', + $defs: { + Node: { type: 'string' }, + Shared: { type: 'string' }, + }, + }, false], + ['right', { + allOf: [{ $ref: '#/$defs/Shared' }], + $defs: { + Node: { type: 'string' }, + Shared: { type: 'number' }, + }, + }, true], + ])).toEqual({ + type: 'object', + properties: { + left: { + $ref: '#/$defs/Shared', + }, + right: { + allOf: [{ $ref: '#/$defs/Shared2' }], + }, + }, + required: ['left'], + $defs: { + Node: { type: 'string' }, + Shared: { type: 'string' }, + Shared2: { type: 'number' }, + }, + }) + }) + + it('round-trips entries extracted from an object schema', () => { + const schema: JsonObjectSchema = { + type: 'object', + properties: { + requiredRef: { $ref: '#/$defs/Shared' }, + optionalNever: false, + }, + required: ['requiredRef'], + $defs: { + Shared: { type: 'string' }, + }, + } + + expect(combineJsonObjectSchemaEntries(extractJsonObjectSchemaEntries(schema)!)).toEqual(schema) + }) + + it('keeps unknown refs untouched while deduplicating and renaming promoted defs', () => { + expect(combineJsonObjectSchemaEntries([ + ['first', { + anyOf: [ + { $ref: '#/$defs/Shared' }, + { $ref: '#/$defs/External' }, + { $ref: '../External' }, + { $ref: '#/$defs/Shared/properties/value' }, + { $ref: '#/nonExists' }, + ], + $defs: { + Shared: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + }, + Shared2: { + type: 'boolean', + }, + Equal: { + type: 'null', + }, + Missing: undefined as any, + }, + }, true], + ['second', { + allOf: [ + { $ref: '#/$defs/Shared' }, + { $ref: '#/$defs/Shared/properties/value' }, + ], + $defs: { + Shared: { + type: 'object', + properties: { + value: { type: 'number' }, + }, + }, + Equal: { + type: 'null', + }, + }, + }, true], + ])).toEqual({ + type: 'object', + properties: { + first: { + anyOf: [ + { $ref: '#/$defs/Shared' }, + { $ref: '#/$defs/External' }, + { $ref: '../External' }, + { $ref: '#/$defs/Shared/properties/value' }, + { $ref: '#/nonExists' }, + ], + }, + second: { + allOf: [ + { $ref: '#/$defs/Shared3' }, + { $ref: '#/$defs/Shared3/properties/value' }, + ], + }, + }, + $defs: { + Shared: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + }, + Shared2: { + type: 'boolean', + }, + Equal: { + type: 'null', + }, + Shared3: { + type: 'object', + properties: { + value: { type: 'number' }, + }, + }, + }, + }) + }) +}) + +describe('flattenJsonUnionSchema', () => { + it('returns non-union schemas as a single branch', () => { + expect(flattenJsonUnionSchema(true)).toEqual([true]) + expect(flattenJsonUnionSchema({ type: 'string' })).toEqual([{ type: 'string' }]) + }) + + it('flattens direct anyOf and oneOf branches', () => { + expect(flattenJsonUnionSchema({ anyOf: [{ type: 'string' }, { type: 'number' }] })).toEqual([ + { type: 'string' }, + { type: 'number' }, + ]) + + expect(flattenJsonUnionSchema({ description: 'metadata', oneOf: [{ type: 'boolean' }, { type: 'null' }] })).toEqual([ + { description: 'metadata', type: 'boolean' }, + { description: 'metadata', type: 'null' }, + ]) + }) + + it('keeps unions with additional constraints in child', () => { + const constrainedUnion: JsonSchema = { + pattern: '.*', + anyOf: [{ type: 'string' }, { type: 'number' }], + } + + expect(flattenJsonUnionSchema(constrainedUnion)).toEqual([ + { pattern: '.*', type: 'string' }, + { pattern: '.*', type: 'number' }, + ]) + }) + + it('moves conflicting sibling constraints into allOf', () => { + const schema: JsonSchema = { + description: 'root metadata', + anyOf: [{ description: 'branch metadata', type: 'string', allOf: [{ maxLength: 4 }] }], + } + + expect(flattenJsonUnionSchema(schema)).toEqual([ + { + description: 'branch metadata', + type: 'string', + allOf: [{ maxLength: 4 }, { description: 'root metadata' }], + }, + ]) + }) + + it('keeps unresolved $ref branches intact while flattening sibling unions', () => { + const schema = { + $defs: { Shared: { type: 'string' } }, + anyOf: [{ $ref: '#/$defs/Shared' }, { oneOf: [{ type: 'number' }, { type: 'boolean' }] }], + } satisfies JsonSchema + + expect(flattenJsonUnionSchema(schema)).toEqual([ + { $defs: schema.$defs, $ref: '#/$defs/Shared' }, + { $defs: schema.$defs, type: 'number' }, + { $defs: schema.$defs, type: 'boolean' }, + ]) + }) + + it('flattening union $ref branches', () => { + const schema = { + description: 'metadata', + $defs: { Shared: { anyOf: [{ type: 'number' }, { type: 'boolean' }] } }, + anyOf: [{ $ref: '#/$defs/Shared' }, { type: 'string' }], + } satisfies JsonSchema + + expect(flattenJsonUnionSchema(schema)).toEqual([ + { $defs: schema.$defs, description: 'metadata', type: 'number' }, + { $defs: schema.$defs, description: 'metadata', type: 'boolean' }, + { $defs: schema.$defs, description: 'metadata', type: 'string' }, + ]) + }) + + it('flattens transitive local $ref union branches', () => { + const schema = { + $defs: { + Shared: { $ref: '#/$defs/Alias' }, + Alias: { oneOf: [{ type: 'number' }, { type: 'boolean' }] }, + }, + anyOf: [{ $ref: '#/$defs/Shared' }, { type: 'string' }], + } satisfies JsonSchema + + expect(flattenJsonUnionSchema(schema)).toEqual([ + { $defs: schema.$defs, type: 'number' }, + { $defs: schema.$defs, type: 'boolean' }, + { $defs: schema.$defs, type: 'string' }, + ]) + }) + + it('keeps missing local $defs refs intact', () => { + const schema = { + $defs: { + Present: { type: 'string' }, + }, + anyOf: [{ $ref: '#/$defs/Missing' }, { type: 'number' }], + } satisfies JsonSchema + + expect(flattenJsonUnionSchema(schema)).toEqual([ + { $defs: schema.$defs, $ref: '#/$defs/Missing' }, + { $defs: schema.$defs, type: 'number' }, + ]) + }) + + it('flattening recursive union $ref branches', () => { + const schema = { + $defs: { Shared: { anyOf: [{ type: 'number' }, { type: 'boolean' }, { $ref: '#/$defs/Shared' }] } }, + anyOf: [{ $ref: '#/$defs/Shared' }, { type: 'string' }], + } satisfies JsonSchema + + expect(flattenJsonUnionSchema(schema)).toEqual([ + { $defs: schema.$defs, type: 'number' }, + { $defs: schema.$defs, type: 'boolean' }, + { $defs: schema.$defs, type: 'string' }, + ]) + }) + + it('dedupe json schemas result', () => { + const schema = { + $defs: { Shared: { anyOf: [{ type: 'string' }, { type: 'boolean' }] } }, + anyOf: [{ $ref: '#/$defs/Shared' }, { type: 'string' }], + } satisfies JsonSchema + + expect(flattenJsonUnionSchema(schema)).toEqual([ + { $defs: schema.$defs, type: 'string' }, + { $defs: schema.$defs, type: 'boolean' }, + ]) + }) + + it('can flat mixed oneOf and anyOf', () => { + const schema = { + $defs: { Shared: { anyOf: [{ type: 'string' }, { type: 'boolean' }] } }, + anyOf: [{ $ref: '#/$defs/Shared' }], + oneOf: [{ type: 'string' }], + } satisfies JsonSchema + + expect(flattenJsonUnionSchema(schema)).toEqual([ + { $defs: schema.$defs, type: 'string' }, + { $defs: schema.$defs, type: 'boolean' }, + ]) + }) +}) + +it('matchArrayableJsonSchema', () => { + expect(matchArrayableJsonSchema({ type: 'string' })).toBeUndefined() + expect(matchArrayableJsonSchema({ anyOf: [{ type: 'string' }, { type: 'number' }] })).toBeUndefined() + expect(matchArrayableJsonSchema({ anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'number' }] })).toBeUndefined() + expect(matchArrayableJsonSchema({ anyOf: [{ type: 'array' }, { type: 'number' }] })).toBeUndefined() + + expect(matchArrayableJsonSchema({ anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, description: 'many strings' }] })).toEqual([ + { type: 'string' }, + { type: 'array', items: { type: 'string' }, description: 'many strings' }, + ]) + expect(matchArrayableJsonSchema({ anyOf: [{ type: 'array', items: { type: 'string' }, minItems: 1 }, { type: 'string' }] })).toEqual([ + { type: 'string' }, + { type: 'array', items: { type: 'string' }, minItems: 1 }, + ]) + + expect(matchArrayableJsonSchema({ + anyOf: [ + { type: 'string', description: 'ignore1' }, + { type: 'array', items: { type: 'string', description: 'ignore2' }, description: 'many strings' }, + ], + })).toEqual([ + { type: 'string', description: 'ignore1' }, + { type: 'array', items: { type: 'string', description: 'ignore2' }, description: 'many strings' }, + ]) + + expect(matchArrayableJsonSchema({ anyOf: [{ type: 'array' }, { }] })).toEqual([{}, { type: 'array' }]) +}) + +describe('combineJsonSchemasWithComposition', () => { + it('when schemas.length <= 1', () => { + expect(combineJsonSchemasWithComposition('anyOf', [])).toEqual(true) + expect(combineJsonSchemasWithComposition('anyOf', [{ type: 'string' }])).toEqual({ type: 'string' }) + }) + + it('returns a plain allOf wrapper when no branch defines $defs', () => { + expect(combineJsonSchemasWithComposition('allOf', [ + { type: 'string' }, + false, + ])).toEqual({ + allOf: [ + { type: 'string' }, + false, + ], + }) + }) + + it('promotes branch defs to the root', () => { + expect(combineJsonSchemasWithComposition('allOf', [ + { + type: 'object', + properties: { + left: { $ref: '#/$defs/Shared' }, + }, + required: ['left'], + $defs: { + Shared: { + type: 'object', + properties: { + left: { type: 'string' }, + }, + required: ['left'], + }, + }, + }, + { + type: 'object', + properties: { + right: { $ref: '#/$defs/SharedRight' }, + }, + required: ['right'], + $defs: { + SharedRight: { + type: 'object', + properties: { + right: { type: 'number' }, + }, + required: ['right'], + }, + }, + }, + { + type: 'object', + properties: { + third: { $ref: '#/$defs/SharedThird' }, + }, + required: ['third'], + $defs: { + SharedThird: { + type: 'object', + properties: { + third: { type: 'boolean' }, + }, + required: ['third'], + }, + }, + }, + ])).toEqual({ + allOf: [ + { + type: 'object', + properties: { + left: { $ref: '#/$defs/Shared' }, + }, + required: ['left'], + }, + { + type: 'object', + properties: { + right: { $ref: '#/$defs/SharedRight' }, + }, + required: ['right'], + }, + { + type: 'object', + properties: { + third: { $ref: '#/$defs/SharedThird' }, + }, + required: ['third'], + }, + ], + $defs: { + Shared: { + type: 'object', + properties: { + left: { type: 'string' }, + }, + required: ['left'], + }, + SharedRight: { + type: 'object', + properties: { + right: { type: 'number' }, + }, + required: ['right'], + }, + SharedThird: { + type: 'object', + properties: { + third: { type: 'boolean' }, + }, + required: ['third'], + }, + }, + }) + }) + + it('renames conflicting defs across branches and rewrites refs across supported schema keywords in the current scope', () => { + expect(combineJsonSchemasWithComposition('allOf', [ + { + type: 'object', + properties: { + left: { $ref: '#/$defs/Shared' }, + }, + required: ['left'], + $defs: { + Shared: { + type: 'object', + properties: { + left: { type: 'string' }, + }, + required: ['left'], + }, + }, + }, + { + type: 'object', + allOf: [{ $ref: '#/$defs/Shared' }], + anyOf: [{ $ref: '#/$defs/Shared' }], + oneOf: [{ $ref: '#/$defs/Shared' }], + items: { $ref: '#/$defs/Shared' }, + additionalProperties: { $ref: '#/$defs/Shared' }, + not: { $ref: '#/$defs/Shared' }, + if: { $ref: '#/$defs/Shared' }, + then: { $ref: '#/$defs/Shared' }, + else: { $ref: '#/$defs/Shared' }, + prefixItems: [{ $ref: '#/$defs/Shared' }], + properties: { + right: { $ref: '#/$defs/Shared' }, + rightDeep: { $ref: '#/$defs/Shared/properties/right' }, + }, + required: ['right'], + $defs: { + Shared: { + type: 'object', + properties: { + right: { type: 'number' }, + }, + required: ['right'], + }, + }, + examples: [ + { $ref: '#/$defs/Shared' }, + ], + }, + { + type: 'object', + properties: { + duplicate: { $ref: '#/$defs/Shared' }, + }, + required: ['duplicate'], + $defs: { + Shared: { + type: 'object', + properties: { + left: { type: 'string' }, + }, + required: ['left'], + }, + }, + }, + { + type: 'object', + properties: { + third: { $ref: '#/$defs/Shared' }, + }, + required: ['third'], + $defs: { + Shared: { + type: 'object', + properties: { + third: { type: 'boolean' }, + }, + required: ['third'], + }, + }, + }, + ])).toEqual({ + allOf: [ + { + type: 'object', + properties: { + left: { $ref: '#/$defs/Shared' }, + }, + required: ['left'], + }, + { + type: 'object', + allOf: [{ $ref: '#/$defs/Shared2' }], + anyOf: [{ $ref: '#/$defs/Shared2' }], + oneOf: [{ $ref: '#/$defs/Shared2' }], + items: { $ref: '#/$defs/Shared2' }, + additionalProperties: { $ref: '#/$defs/Shared2' }, + not: { $ref: '#/$defs/Shared2' }, + if: { $ref: '#/$defs/Shared2' }, + then: { $ref: '#/$defs/Shared2' }, + else: { $ref: '#/$defs/Shared2' }, + prefixItems: [{ $ref: '#/$defs/Shared2' }], + properties: { + right: { $ref: '#/$defs/Shared2' }, + rightDeep: { $ref: '#/$defs/Shared2/properties/right' }, + }, + required: ['right'], + examples: [ + { $ref: '#/$defs/Shared' }, + ], + }, + { + type: 'object', + properties: { + duplicate: { $ref: '#/$defs/Shared' }, + }, + required: ['duplicate'], + }, + { + type: 'object', + properties: { + third: { $ref: '#/$defs/Shared3' }, + }, + required: ['third'], + }, + ], + $defs: { + Shared: { + type: 'object', + properties: { + left: { type: 'string' }, + }, + required: ['left'], + }, + Shared2: { + type: 'object', + properties: { + right: { type: 'number' }, + }, + required: ['right'], + }, + Shared3: { + type: 'object', + properties: { + third: { type: 'boolean' }, + }, + required: ['third'], + }, + }, + }) + }) + + it('reuses the same def name when conflicting defs are equal', () => { + expect(combineJsonSchemasWithComposition('allOf', [ + { + type: 'object', + properties: { + left: { $ref: '#/$defs/Shared' }, + }, + required: ['left'], + $defs: { + Shared: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: ['value'], + }, + }, + }, + { + type: 'object', + properties: { + right: { $ref: '#/$defs/Shared' }, + }, + required: ['right'], + $defs: { + Shared: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: ['value'], + }, + }, + }, + ])).toEqual({ + allOf: [ + { + type: 'object', + properties: { + left: { $ref: '#/$defs/Shared' }, + }, + required: ['left'], + }, + { + type: 'object', + properties: { + right: { $ref: '#/$defs/Shared' }, + }, + required: ['right'], + }, + ], + $defs: { + Shared: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: ['value'], + }, + }, + }) + }) + + it('ignore relative refs unchanged', () => { + expect(combineJsonSchemasWithComposition('allOf', [ + { + type: 'object', + properties: { + direct: { $ref: '#/$defs/Shared' }, + unchanged: { $ref: '1/Unknown' }, + unchanged2: { $ref: '../Unknown' }, + childSchema: { + type: 'object', + properties: { + inner: { $ref: '#/properties/childSchema/$defs/Shared' }, + }, + required: ['inner'], + $defs: { + Shared: { type: 'integer' }, + }, + }, + }, + $defs: { + Shared: { + type: 'object', + properties: { + left: { type: 'string' }, + }, + required: ['left'], + }, + }, + }, + { type: 'object' }, + ])).toEqual({ + allOf: [ + { + type: 'object', + properties: { + direct: { $ref: '#/$defs/Shared' }, + unchanged: { $ref: '1/Unknown' }, + unchanged2: { $ref: '../Unknown' }, + childSchema: { + type: 'object', + properties: { + inner: { $ref: '#/allOf/0/properties/childSchema/$defs/Shared' }, + }, + required: ['inner'], + $defs: { + Shared: { type: 'integer' }, + }, + }, + }, + }, + { type: 'object' }, + ], + $defs: { + Shared: { + type: 'object', + properties: { + left: { type: 'string' }, + }, + required: ['left'], + }, + }, + }) + }) + + it('preserves encoded pointer refs', () => { + expect(combineJsonSchemasWithComposition('allOf', [ + { + type: 'object', + properties: { + encoded: { $ref: '#/$defs/path~1name' }, + toggle: { $ref: '#/$defs/Flag' }, + }, + required: ['encoded', 'toggle'], + $defs: { + 'path/name': { + type: 'string', + }, + 'Flag': true, + }, + }, + { type: 'object' }, + ])).toEqual({ + allOf: [ + { + type: 'object', + properties: { + encoded: { $ref: '#/$defs/path~1name' }, + toggle: { $ref: '#/$defs/Flag' }, + }, + required: ['encoded', 'toggle'], + }, + { type: 'object' }, + ], + $defs: { + 'path/name': { + type: 'string', + }, + 'Flag': true, + }, + }) + }) + + it('ignores undefined defs entries while promoting valid defs', () => { + expect(combineJsonSchemasWithComposition('allOf', [ + { + type: 'object', + properties: { + value: { $ref: '#/$defs/Real' }, + }, + $defs: { + Real: { type: 'string' }, + Missing: undefined as any, + }, + }, + { type: 'object' }, + ])).toEqual({ + allOf: [ + { + type: 'object', + properties: { + value: { $ref: '#/$defs/Real' }, + }, + }, + { type: 'object' }, + ], + $defs: { + Real: { type: 'string' }, + }, + }) + }) + + it('rewrites absolute refs to the exact allOf branch path', () => { + expect(combineJsonSchemasWithComposition('allOf', [ + { + type: 'object', + properties: { + child: { $ref: '#' }, + brother: { $ref: '#/properties/child' }, + nonExists: { $ref: '#/nonExists' }, + }, + required: ['child'], + }, + { type: 'object' }, + ])).toEqual({ + allOf: [ + { + type: 'object', + properties: { + child: { $ref: '#/allOf/0' }, + brother: { $ref: '#/allOf/0/properties/child' }, + nonExists: { $ref: '#/nonExists' }, + }, + required: ['child'], + }, + { type: 'object' }, + ], + }) + }) + + it('supports anyOf', () => { + expect(combineJsonSchemasWithComposition('anyOf', [ + { + type: 'object', + properties: { + left: { $ref: '#/$defs/Shared' }, + }, + required: ['left'], + $defs: { + Shared: { + type: 'string', + }, + }, + }, + { + type: 'object', + properties: { + right: { $ref: '#' }, + nested: { $ref: '#/properties/right' }, + }, + required: ['right', 'nested'], + }, + ])).toEqual({ + anyOf: [ + { + type: 'object', + properties: { + left: { $ref: '#/$defs/Shared' }, + }, + required: ['left'], + }, + { + type: 'object', + properties: { + right: { $ref: '#/anyOf/1' }, + nested: { $ref: '#/anyOf/1/properties/right' }, + }, + required: ['right', 'nested'], + }, + ], + $defs: { + Shared: { + type: 'string', + }, + }, + }) + }) + + it('supports oneOf', () => { + expect(combineJsonSchemasWithComposition('oneOf', [ + { + type: 'object', + properties: { + left: { $ref: '#/$defs/Shared' }, + }, + required: ['left'], + $defs: { + Shared: { + type: 'string', + }, + }, + }, + { + type: 'object', + properties: { + right: { $ref: '#/$defs/Shared' }, + childSchema: { + type: 'object', + properties: { + nested: { $ref: '#/properties/right' }, + }, + required: ['nested'], + }, + }, + required: ['right', 'childSchema'], + $defs: { + Shared: { + type: 'number', + }, + }, + }, + ])).toEqual({ + oneOf: [ + { + type: 'object', + properties: { + left: { $ref: '#/$defs/Shared' }, + }, + required: ['left'], + }, + { + type: 'object', + properties: { + right: { $ref: '#/$defs/Shared2' }, + childSchema: { + type: 'object', + properties: { + nested: { $ref: '#/oneOf/1/properties/right' }, + }, + required: ['nested'], + }, + }, + required: ['right', 'childSchema'], + }, + ], + $defs: { + Shared: { + type: 'string', + }, + Shared2: { + type: 'number', + }, + }, + }) + }) +}) + +describe('deduplicateJsonSchemas', () => { + it('removes structurally identical schemas while preserving the first occurrence order', () => { + const shared = { type: 'string', minLength: 1 } satisfies JsonSchema + + expect(deduplicateJsonSchemas([ + shared, + { type: 'number' }, + { type: 'string', minLength: 1 }, + { anyOf: [{ type: 'boolean' }, { type: 'null' }] }, + { anyOf: [{ type: 'boolean' }, { type: 'null' }] }, + ])).toEqual([ + shared, + { type: 'number' }, + { anyOf: [{ type: 'boolean' }, { type: 'null' }] }, + ]) + }) + + it('keeps distinct boolean and object schemas', () => { + expect(deduplicateJsonSchemas([ + true, + false, + true, + { const: 'x' }, + { const: 'x' }, + false, + ])).toEqual([ + true, + false, + { const: 'x' }, + ]) + }) + + it('treats schemas with different defs as distinct', () => { + expect(deduplicateJsonSchemas([ + { + $ref: '#/$defs/Shared', + $defs: { + Shared: { type: 'string' }, + }, + }, + { + $ref: '#/$defs/Shared', + $defs: { + Shared: { type: 'number' }, + }, + }, + ])).toEqual([ + { + $ref: '#/$defs/Shared', + $defs: { + Shared: { type: 'string' }, + }, + }, + { + $ref: '#/$defs/Shared', + $defs: { + Shared: { type: 'number' }, + }, + }, + ]) + }) +}) diff --git a/packages/json-schema/src/composition-utils.ts b/packages/json-schema/src/composition-utils.ts new file mode 100644 index 000000000..a20a45ea5 --- /dev/null +++ b/packages/json-schema/src/composition-utils.ts @@ -0,0 +1,496 @@ +/** + * These utilities assume the schema has only one root-level `$defs` object + * and exclusively use absolute JSON pointers for `$ref` values. + */ + +import type { JsonSchema } from './types' +import type { JsonArraySchema, JsonObjectSchema } from './utils' +import { get, isDeepEqual, omit, toArray } from '@orpc/shared' +import { JSON_SCHEMA_LOGIC_KEYWORDS, JSON_SCHEMA_PRIMITIVE_TYPES } from './constants' +import { decodeJsonPointerSegment, encodeJsonPointerSegment, hoistRecursiveRefToDef, mapJsonSchemaRefs, resolveJsonSchemaRootLocalRef } from './ref-utils' +import { ensureJsonSchemaObject, isJsonArraySchema } from './utils' + +/** + * Combines multiple schemas under the requested composition keyword, promoting branch `$defs` to the root. + */ +export function combineJsonSchemasWithComposition( + keyword: 'allOf' | 'anyOf' | 'oneOf', + schemas: JsonSchema[], +): JsonSchema { + if (schemas.length <= 1) { + return schemas[0] ?? true + } + + const mergedDefs: Record = {} + const compositionBranches: JsonSchema[] = [] + + for (let i = 0; i < schemas.length; i++) { + const schema = schemas[i]! + + if (typeof schema === 'boolean') { + compositionBranches.push(schema) + continue + } + + const { $defs, ...rest } = schema + const renameMap: Record = {} + const promotedNames = new Set() + + if ($defs) { + for (const [name, def] of Object.entries($defs)) { + if (def === undefined) + continue + promotedNames.add(name) + + if (name in mergedDefs) { + if (isDeepEqual(mergedDefs[name], def)) { + continue + } + + let counter = 2 + let newName = `${name}${counter}` + while (newName in mergedDefs) { + counter++ + newName = `${name}${counter}` + } + mergedDefs[newName] = def + renameMap[name] = newName + } + else { + mergedDefs[name] = def + } + } + } + + compositionBranches.push(mapJsonSchemaRefs( + rest, + (ref) => { + if (ref === '#') { + return `#/${keyword}/${i}` + } + + if (ref.startsWith('#/$defs/')) { + const afterPrefix = ref.slice('#/$defs/'.length) + const slashIdx = afterPrefix.indexOf('/') + const encodedSegment = slashIdx === -1 ? afterPrefix : afterPrefix.slice(0, slashIdx) + const rest = slashIdx === -1 ? '' : afterPrefix.slice(slashIdx) + const defName = decodeJsonPointerSegment(encodedSegment) + + if (!promotedNames.has(defName)) { + return ref + } + + if (defName in renameMap) { + return `#/$defs/${encodeJsonPointerSegment(renameMap[defName]!)}${rest}` + } + + return ref + } + + if (ref.startsWith('#/') && get(rest, ref.slice(2).split('/').map(decodeJsonPointerSegment)) !== undefined) { + return `#/${keyword}/${i}/${ref.slice(2)}` + } + + return ref + }, + )) + } + + const result: Exclude = { [keyword]: compositionBranches } + if (Object.keys(mergedDefs).length > 0) { + result.$defs = mergedDefs + } + + return result +} + +/** + * Returns true when every branch in the schema describes a primitive value. + */ +export function isJsonPrimitiveSchema(schema: JsonSchema): boolean { + return flattenJsonUnionSchema(schema).every((s) => { + if (typeof s === 'boolean') { + return false + } + + if (typeof s.type === 'string' && JSON_SCHEMA_PRIMITIVE_TYPES.has(s.type)) { + return true + } + + if (s.const !== undefined) { + return true + } + + if (s.enum !== undefined) { + return true + } + + return false + }) +} + +export type JsonObjectSchemaEntry = [name: string, schema: JsonSchema, optional: boolean] + +/** + * Combines object property entries back into a single object schema. + */ +export function combineJsonObjectSchemaEntries(entries: JsonObjectSchemaEntry[]): JsonObjectSchema { + const properties: Record = {} + const required: string[] = [] + const mergedDefs: Record = {} + + for (const [name, propertySchema, optional] of entries) { + if (!optional) { + required.push(name) + } + + if (typeof propertySchema === 'boolean') { + properties[name] = propertySchema + continue + } + + const { $defs, ...rest } = propertySchema + const renameMap: Record = {} + const promotedNames = new Set() + + if ($defs) { + for (const [defName, def] of Object.entries($defs)) { + if (def === undefined) { + continue + } + + promotedNames.add(defName) + + if (defName in mergedDefs) { + if (isDeepEqual(mergedDefs[defName], def)) { + continue + } + + let counter = 2 + let newName = `${defName}${counter}` + while (newName in mergedDefs) { + counter++ + newName = `${defName}${counter}` + } + + mergedDefs[newName] = def + renameMap[defName] = newName + } + else { + mergedDefs[defName] = def + } + } + } + + const propertyPathPrefix = `#/properties/${encodeJsonPointerSegment(name)}` + properties[name] = mapJsonSchemaRefs(rest, (ref) => { + if (ref.startsWith('#/$defs/')) { + const afterPrefix = ref.slice('#/$defs/'.length) + const slashIdx = afterPrefix.indexOf('/') + const encodedSegment = slashIdx === -1 ? afterPrefix : afterPrefix.slice(0, slashIdx) + const refRest = slashIdx === -1 ? '' : afterPrefix.slice(slashIdx) + const defName = decodeJsonPointerSegment(encodedSegment) + + if (!promotedNames.has(defName)) { + return ref + } + + if (defName in renameMap) { + return `#/$defs/${encodeJsonPointerSegment(renameMap[defName]!)}${refRest}` + } + + return ref + } + + if (ref === '#') { + return propertyPathPrefix + } + + if (ref.startsWith('#/') && get(rest, ref.slice(2).split('/').map(decodeJsonPointerSegment)) !== undefined) { + return `${propertyPathPrefix}/${ref.slice(2)}` + } + + return ref + }) + } + + const schema: JsonObjectSchema = { + type: 'object', + properties, + } + + if (required.length > 0) { + schema.required = required + } + + if (Object.keys(mergedDefs).length > 0) { + schema.$defs = mergedDefs + } + + return schema +} + +/** + * Parses an object schema, or a composition of object schemas, into property entries. + */ +export function extractJsonObjectSchemaEntries(schema: JsonSchema): JsonObjectSchemaEntry[] | undefined { + schema = hoistRecursiveRefToDef(schema) + if (typeof schema !== 'object') { + return undefined + } + + const result = extractJsonObjectSchemaEntriesInternal(omit(schema, ['$defs']), schema.$defs, new Set()) + + if (!result.objectLike) { + return undefined + } + + return result.entries.map(([n, s, ...r]) => [n, withRootDefs(s, schema.$defs), ...r]) +} + +type JsonObjectSchemaEntrySource = 'direct' | 'allOf' | 'anyOf' | 'oneOf' +type ExtractJsonObjectSchemaEntriesResult = { + entries: JsonObjectSchemaEntry[] + objectLike: boolean +} +function extractJsonObjectSchemaEntriesInternal( + schema: JsonSchema, + $defs: Exclude['$defs'], + resolvingRefs: Set, +): ExtractJsonObjectSchemaEntriesResult { + if (typeof schema !== 'object') { + return { entries: [], objectLike: false } + } + + if (typeof schema.$ref === 'string') { + if (resolvingRefs.has(schema.$ref)) { + return { entries: [], objectLike: true } + } + + const resolved = resolveJsonSchemaRootLocalRef(schema, $defs) + + if (resolved !== schema) { + return extractJsonObjectSchemaEntriesInternal(resolved, $defs, new Set(resolvingRefs).add(schema.$ref)) + } + } + + const sources: Array<{ entries: JsonObjectSchemaEntry[], source: JsonObjectSchemaEntrySource }> = [] + + if (schema.properties) { + sources.push({ + entries: Object.entries(schema.properties).map(([name, propertySchema]) => { + return [ + name, + propertySchema, + !schema.required?.includes(name), + ] satisfies JsonObjectSchemaEntry + }), + source: 'direct', + }) + } + + let objectLike = schema.type === 'object' + || schema.properties !== undefined + || schema.required !== undefined + || schema.additionalProperties !== undefined + + for (const keyword of ['anyOf', 'oneOf', 'allOf'] as const) { + const branches = schema[keyword] + + if (branches === undefined) { + continue + } + + const branchResults = branches.map(branch => extractJsonObjectSchemaEntriesInternal(branch, $defs, resolvingRefs)) + + if (branchResults.some(result => !result.objectLike)) { + return { entries: [], objectLike: false } + } + + const entriesByName = new Map() + + for (const result of branchResults) { + for (const entry of result.entries) { + const entries = entriesByName.get(entry[0]) + + if (entries) { + entries.push(entry) + } + else { + entriesByName.set(entry[0], [entry]) + } + } + } + + objectLike = true + sources.push({ + entries: Array.from(entriesByName.entries()).map(([name, entries]) => { + const schemas = deduplicateJsonSchemas(entries.map(entry => entry[1])) + const required = keyword === 'allOf' + ? entries.some(entry => !entry[2]) + : branchResults.every((result) => { + const entry = result.entries.find(item => item[0] === name) + return entry !== undefined && !entry[2] + }) + + return [ + name, + schemas.length === 1 + ? schemas[0]! + : keyword === 'allOf' + ? { allOf: schemas } + : { anyOf: schemas }, + !required, + ] satisfies JsonObjectSchemaEntry + }), + source: keyword, + }) + } + + const sourceEntries = new Map>>() + + for (const { entries, source } of sources) { + for (const entry of entries) { + const existing = sourceEntries.get(entry[0]) + + if (existing) { + existing[source] = entry + } + else { + sourceEntries.set(entry[0], { [source]: entry }) + } + } + } + + return { + entries: Array.from(sourceEntries.entries()).map(([name, entries]) => { + const schemas = deduplicateJsonSchemas([ + entries.direct?.[1], + entries.allOf?.[1], + entries.anyOf?.[1], + entries.oneOf?.[1], + ].filter(schema => schema !== undefined)) + + return [ + name, + schemas.length === 1 ? schemas[0]! : { allOf: schemas }, + [entries.direct, entries.allOf, entries.anyOf, entries.oneOf] + .every(entry => entry === undefined || entry[2]), + ] satisfies JsonObjectSchemaEntry + }), + objectLike, + } +} + +/** + * Flattens `anyOf` and `oneOf` unions when they are the only active constraints. + */ +export function flattenJsonUnionSchema(schema: JsonSchema): JsonSchema[] { + return deduplicateJsonSchemas(flattenJsonUnionSchemaInternal(schema, new Set())) +} + +function flattenJsonUnionSchemaInternal( + schema: JsonSchema, + resolvingRefs: Set, +): JsonSchema[] { + if (typeof schema !== 'object') { + return [schema] + } + + if (typeof schema.$ref === 'string') { + if (resolvingRefs.has(schema.$ref)) { + return [] + } + + const resolved = resolveJsonSchemaRootLocalRef(schema) + + if (resolved !== schema) { + const result = flattenJsonUnionSchemaInternal(resolved, resolvingRefs.add(schema.$ref)) + if (result.length > 1) { + return result + } + } + } + const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schema + const entries = Object.entries(rest).filter(([, val]) => val !== undefined) + + for (const keyword of ['anyOf', 'oneOf'] as const) { + if (schema[keyword]) { + return schema[keyword].flatMap((s) => { + s = ensureJsonSchemaObject(s) + + const mergedSchema: JsonSchema = { + ...s, + ...Object.fromEntries(entries.filter(([key]) => s[key as keyof typeof s] === undefined)), + $defs: schema.$defs, + } + + const conflicts = entries.filter(([key]) => s[key as keyof typeof s] !== undefined) + if (conflicts.length) { + mergedSchema.allOf = [...toArray(mergedSchema.allOf), Object.fromEntries(conflicts)] + } + + return flattenJsonUnionSchemaInternal(mergedSchema, resolvingRefs) + }) + } + } + + return [schema] +} + +/** + * Matches a union made of a single item schema and its array form. + */ +export function matchArrayableJsonSchema(schema: JsonSchema): undefined | [itemSchema: JsonSchema, arraySchema: JsonArraySchema] { + const schemas = flattenJsonUnionSchema(schema) + + if (schemas.length !== 2) { + return undefined + } + + const arraySchema = schemas.find(isJsonArraySchema) + if (arraySchema === undefined) { + return undefined + } + + const items1 = arraySchema.items ?? true + const items2 = schemas.find(s => s !== arraySchema) as JsonSchema + + const logicItem1: JsonSchema = Object.fromEntries( + Object.entries(ensureJsonSchemaObject(items1)) + .filter(([key]) => JSON_SCHEMA_LOGIC_KEYWORDS.has(key)), + ) + + const logicItem2: JsonSchema = Object.fromEntries( + Object.entries(ensureJsonSchemaObject(items2)) + .filter(([key]) => JSON_SCHEMA_LOGIC_KEYWORDS.has(key)), + ) + + if (!isDeepEqual(logicItem1, logicItem2)) { + return undefined + } + + return [items2, arraySchema] +} + +export function deduplicateJsonSchemas(schemas: JsonSchema[]): JsonSchema[] { + const result: JsonSchema[] = [] + + for (const schema of schemas) { + if (result.some(i => isDeepEqual(i, schema))) { + continue + } + + result.push(schema) + } + + return result +} + +function withRootDefs(schema: JsonSchema, $defs: Record | undefined): JsonSchema { + if (typeof schema === 'boolean' || !$defs) { + return schema + } + + return { ...schema, $defs } +} diff --git a/packages/json-schema/src/constants.ts b/packages/json-schema/src/constants.ts new file mode 100644 index 000000000..5fa845079 --- /dev/null +++ b/packages/json-schema/src/constants.ts @@ -0,0 +1,53 @@ +import type { JsonSchemaKeywords } from './types' + +export const JSON_SCHEMA_LOGIC_KEYWORDS = new Set([ + '$dynamicRef', + '$ref', + 'additionalItems', + 'additionalProperties', + 'allOf', + 'anyOf', + 'const', + 'contains', + 'contentEncoding', + 'contentMediaType', + 'contentSchema', + 'dependencies', + 'dependentRequired', + 'dependentSchemas', + 'else', + 'enum', + 'exclusiveMaximum', + 'exclusiveMinimum', + 'format', + 'if', + 'items', + 'maxContains', + 'maximum', + 'maxItems', + 'maxLength', + 'maxProperties', + 'minContains', + 'minimum', + 'minItems', + 'minLength', + 'minProperties', + 'multipleOf', + 'not', + 'oneOf', + 'pattern', + 'patternProperties', + 'prefixItems', + 'properties', + 'propertyNames', + 'required', + 'then', + 'type', + 'unevaluatedItems', + 'unevaluatedProperties', + 'uniqueItems', +]) + +export const JSON_SCHEMA_PRIMITIVE_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null']) + +export const JSON_SCHEMA_RECORD_KEYWORDS = new Set(['properties', 'patternProperties', 'dependentSchemas', 'dependencies', '$defs']) diff --git a/packages/json-schema/src/convert.test.ts b/packages/json-schema/src/convert.test.ts new file mode 100644 index 000000000..4ab27ebfd --- /dev/null +++ b/packages/json-schema/src/convert.test.ts @@ -0,0 +1,67 @@ +import type { AnySchema } from '@orpc/contract' +import type { JsonSchemaConverter } from './convert' +import * as v from 'valibot' +import z from 'zod' +import { DelegatingJsonSchemaConverter } from './convert' + +describe('delegatingJsonSchemaConverter', () => { + it('uses the first matching custom converter', async () => { + const schema = z.object({ value: z.string() }) + + const firstConverter: JsonSchemaConverter = { + condition: vi.fn().mockResolvedValue(true), + convert: vi.fn().mockResolvedValue([{ type: 'string' }, false]), + } + + const secondConverter: JsonSchemaConverter = { + condition: vi.fn().mockResolvedValue(true), + convert: vi.fn().mockResolvedValue([{ type: 'number' }, true]), + } + + const converter = new DelegatingJsonSchemaConverter([firstConverter, secondConverter]) + + await expect(converter.convert(schema, 'input')).resolves.toEqual([{ type: 'string' }, false]) + expect(firstConverter.condition).toHaveBeenCalledWith(schema, 'input') + expect(firstConverter.convert).toHaveBeenCalledWith(schema, 'input') + expect(secondConverter.condition).not.toHaveBeenCalled() + expect(secondConverter.convert).not.toHaveBeenCalled() + }) + + it('converts schemas using the standard json schema fallback behavior', async () => { + const converter = new DelegatingJsonSchemaConverter([]) + + const schema = z.number().transform(String).pipe(z.string()) + await expect(converter.convert(schema, 'input')).resolves.toEqual([ + expect.objectContaining({ type: 'number' }), + false, + ]) + await expect(converter.convert(schema, 'output')).resolves.toEqual([ + expect.objectContaining({ type: 'string' }), + false, + ]) + + const optionalSchema = v.optional(v.string()) + await expect(converter.convert(optionalSchema, 'input')).resolves.toEqual([ + expect.objectContaining({ }), + true, + ]) + await expect(converter.convert(optionalSchema, 'output')).resolves.toEqual([ + expect.objectContaining({ }), + true, + ]) + }) + + it('returns an empty schema when ~standard does not expose jsonSchema', async () => { + const schema: AnySchema = { + '~standard': { + vendor: 'custom', + version: 1, + validate: vi.fn().mockResolvedValue({}), + }, + } + + const converter = new DelegatingJsonSchemaConverter([]) + + await expect(converter.convert(schema, 'input')).resolves.toEqual([{}, true]) + }) +}) diff --git a/packages/json-schema/src/convert.ts b/packages/json-schema/src/convert.ts new file mode 100644 index 000000000..71ffa30aa --- /dev/null +++ b/packages/json-schema/src/convert.ts @@ -0,0 +1,45 @@ +import type { AnySchema } from '@orpc/contract' +import type { Promisable } from '@orpc/shared' +import type { JsonSchema } from './types' + +export type JsonSchemaConverterDirection = 'input' | 'output' + +export interface JsonSchemaConverter { + /** + * Determines whether this converter can handle the given schema. + */ + condition(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): Promisable + + /** + * Converts an ORPC schema to a JSON Schema representation. + */ + convert(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): Promisable<[jsonSchema: JsonSchema, optional: boolean]> +} + +export class DelegatingJsonSchemaConverter implements Pick { + constructor( + private readonly converters: JsonSchemaConverter[] = [], + ) {} + + async convert(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): Promise<[jsonSchema: JsonSchema, optional: boolean]> { + for (const converter of this.converters) { + if (await converter.condition(schema, direction)) { + return converter.convert(schema, direction) + } + } + + const optional = !(await schema?.['~standard'].validate(undefined))?.issues?.length + + if (schema && 'jsonSchema' in schema['~standard'] && schema['~standard'].jsonSchema) { + try { + return [ + (schema['~standard'].jsonSchema as any)[direction](), + optional, + ] + } + catch { } + } + + return [{}, optional] + } +} diff --git a/packages/json-schema/src/index.test.ts b/packages/json-schema/src/index.test.ts new file mode 100644 index 000000000..0350e1a87 --- /dev/null +++ b/packages/json-schema/src/index.test.ts @@ -0,0 +1,12 @@ +it('exports utils, DelegatingJsonSchemaConverter, StandardJsonSchemaConverter, JsonSchemaCoercer, SmartCoercionHandlerPlugin, SmartCoercionLinkPlugin', async () => { + await expect(import('.')).resolves.toMatchObject({ + isJsonObjectSchema: expect.any(Function), + combineJsonSchemasWithComposition: expect.any(Function), + ensureJsonSchemaObject: expect.any(Function), + DelegatingJsonSchemaConverter: expect.any(Function), + StandardJsonSchemaConverter: expect.any(Function), + JsonSchemaCoercer: expect.any(Function), + SmartCoercionHandlerPlugin: expect.any(Function), + SmartCoercionLinkPlugin: expect.any(Function), + }) +}) diff --git a/packages/json-schema/src/index.ts b/packages/json-schema/src/index.ts index 2444d2c65..7e040b804 100644 --- a/packages/json-schema/src/index.ts +++ b/packages/json-schema/src/index.ts @@ -1,3 +1,11 @@ export * from './coercer' -export * from './smart-coercion-plugin' +export * from './composition-utils' +export * from './convert' +export * from './ref-utils' +export * from './smart-coercion-handler-plugin' +export * from './smart-coercion-link-plugin' +export * from './standard-json-schema-converter' export * from './types' +export * from './utils' + +export type { AnySchema, Schema } from '@orpc/contract' diff --git a/packages/json-schema/src/ref-utils.test.ts b/packages/json-schema/src/ref-utils.test.ts new file mode 100644 index 000000000..5f71a9c0d --- /dev/null +++ b/packages/json-schema/src/ref-utils.test.ts @@ -0,0 +1,396 @@ +import type { JsonSchema } from './types' +import { decodeJsonPointerSegment, encodeJsonPointerSegment, hoistRecursiveRefToDef, mapJsonSchemaRefs, resolveJsonSchemaRootLocalRef } from './ref-utils' + +describe('json pointer utils', () => { + it('encodes and decodes JSON pointer segments', () => { + expect(encodeJsonPointerSegment('a~/b')).toBe('a~0~1b') + expect(decodeJsonPointerSegment('a~0~1b')).toBe('a~/b') + }) +}) + +describe('mapJsonSchemaRefs', () => { + it('passes the structural path to the mapper', () => { + const visited: Array<[ref: string, path: Array]> = [] + + const schema = mapJsonSchemaRefs({ + type: 'object', + properties: { + child: { $ref: '#/$defs/Node' }, + }, + anyOf: [ + { $ref: '#/$defs/Leaf' }, + ], + examples: [ + { $ref: '#/$defs/Ignored' }, + ], + }, (ref, path) => { + visited.push([ref, path]) + return ref + }) + + expect(schema).toEqual({ + type: 'object', + properties: { + child: { $ref: '#/$defs/Node' }, + }, + anyOf: [ + { $ref: '#/$defs/Leaf' }, + ], + examples: [ + { $ref: '#/$defs/Ignored' }, + ], + }) + + expect(visited).toEqual([ + ['#/$defs/Node', ['properties', 'child', '$ref']], + ['#/$defs/Leaf', ['anyOf', 0, '$ref']], + ]) + }) + + it('rewrites refs across schema arrays and schema record keywords', () => { + const visited: Array<[ref: string, path: Array]> = [] + + const rewritten = mapJsonSchemaRefs({ + anyOf: [ + { $ref: '#/$defs/Node' }, + { + properties: { + child: { $ref: '#/$defs/Child' }, + }, + }, + ], + $defs: { + Node: { + items: { $ref: '#/$defs/Leaf' }, + }, + }, + dependentSchemas: { + feature: { $ref: '#/$defs/Feature' }, + }, + }, (ref, path) => { + visited.push([ref, path]) + return `${ref}?visited=${path.join('.')}` + }) + + expect(rewritten).toEqual({ + anyOf: [ + { $ref: '#/$defs/Node?visited=anyOf.0.$ref' }, + { + properties: { + child: { $ref: '#/$defs/Child?visited=anyOf.1.properties.child.$ref' }, + }, + }, + ], + $defs: { + Node: { + items: { $ref: '#/$defs/Leaf?visited=$defs.Node.items.$ref' }, + }, + }, + dependentSchemas: { + feature: { $ref: '#/$defs/Feature?visited=dependentSchemas.feature.$ref' }, + }, + }) + + expect(visited).toEqual([ + ['#/$defs/Node', ['anyOf', 0, '$ref']], + ['#/$defs/Child', ['anyOf', 1, 'properties', 'child', '$ref']], + ['#/$defs/Leaf', ['$defs', 'Node', 'items', '$ref']], + ['#/$defs/Feature', ['dependentSchemas', 'feature', '$ref']], + ]) + }) + + it('ignores non-schema metadata while still traversing nested schemas', () => { + const visited: Array<[ref: string, path: Array]> = [] + + const rewritten = mapJsonSchemaRefs({ + type: 'object', + title: 'Example', + examples: [ + { $ref: '#/$defs/Ignored' }, + ], + default: { + $ref: '#/$defs/AlsoIgnored', + }, + properties: { + nested: { + allOf: [ + { $ref: '#/$defs/Tracked' }, + ], + }, + }, + }, (ref, path) => { + visited.push([ref, path]) + return ref.replace('#/$defs/', '#/$defs/rewritten-') + }) + + expect(rewritten).toEqual({ + type: 'object', + title: 'Example', + examples: [ + { $ref: '#/$defs/Ignored' }, + ], + default: { + $ref: '#/$defs/AlsoIgnored', + }, + properties: { + nested: { + allOf: [ + { $ref: '#/$defs/rewritten-Tracked' }, + ], + }, + }, + }) + + expect(visited).toEqual([ + ['#/$defs/Tracked', ['properties', 'nested', 'allOf', 0, '$ref']], + ]) + }) +}) + +describe('resolveJsonSchemaRootLocalRef', () => { + it('returns unsupported refs unchanged', () => { + const schemaWithoutRef: JsonSchema = { type: 'string' } + const externalRef: JsonSchema = { $ref: '#/components/schemas/User', $defs: { User: { type: 'string' } } } + const missingDefs: JsonSchema = { $ref: '#/$defs/User' } as JsonSchema + const nullDefs: JsonSchema = { $ref: '#/$defs/User', $defs: null as never } + + expect(resolveJsonSchemaRootLocalRef(true)).toBe(true) + expect(resolveJsonSchemaRootLocalRef(schemaWithoutRef)).toBe(schemaWithoutRef) + expect(resolveJsonSchemaRootLocalRef(externalRef)).toBe(externalRef) + expect(resolveJsonSchemaRootLocalRef(missingDefs)).toBe(missingDefs) + expect(resolveJsonSchemaRootLocalRef(nullDefs)).toBe(nullDefs) + }) + + it('returns the original schema when the ref path cannot be resolved', () => { + const walksIntoNonObject: JsonSchema = { + $ref: '#/$defs/branch/leaf', + $defs: { + branch: true, + }, + } + + const missingTarget: JsonSchema = { + $ref: '#/$defs/missing', + $defs: {}, + } + + expect(resolveJsonSchemaRootLocalRef(walksIntoNonObject)).toBe(walksIntoNonObject) + expect(resolveJsonSchemaRootLocalRef(missingTarget)).toBe(missingTarget) + }) + + it('resolves boolean and nested object refs from $defs', () => { + const escapedKey = 'a~/b' + const encodedKey = encodeJsonPointerSegment(escapedKey) + + const booleanRef: JsonSchema = { + $ref: '#/$defs/flag', + $defs: { + flag: false, + }, + } + + const recursiveRef: JsonSchema = { + $ref: '#/$defs/outer', + description: 'top level description', + examples: ['example'], + $defs: { + outer: { + $ref: `#/$defs/${encodedKey}`, + title: 'outer title', + }, + [escapedKey]: { + type: 'string', + minLength: 1, + }, + }, + } + + const truthyBooleanRef: JsonSchema = { + $ref: '#/$defs/flag', + title: 'kept', + $defs: { + flag: true, + }, + } + + expect(resolveJsonSchemaRootLocalRef(booleanRef)).toEqual(false) + expect(resolveJsonSchemaRootLocalRef(truthyBooleanRef)).toEqual(true) + expect(resolveJsonSchemaRootLocalRef(recursiveRef)).toEqual({ + type: 'string', + minLength: 1, + title: 'outer title', + description: 'top level description', + examples: ['example'], + $defs: { + outer: { + $ref: `#/$defs/${encodedKey}`, + title: 'outer title', + }, + [escapedKey]: { + type: 'string', + minLength: 1, + }, + }, + }) + }) + + it('prefer $defs arg over schema.$defs even undefined', () => { + const schema: JsonSchema = { + $ref: '#/$defs/branch', + $defs: { + branch: true, + }, + } + + expect(resolveJsonSchemaRootLocalRef(schema, { + branch: false, + })).toEqual(false) + expect(resolveJsonSchemaRootLocalRef(schema, undefined)).toBe(schema) + }) +}) + +describe('hoistRecursiveRefToDef', () => { + it('returns schemas without recursive refs unchanged', () => { + const schema: JsonSchema = { + type: 'object', + properties: { + child: { $ref: '#/$defs/Node' }, + lib: { $ref: '#/nonExists' }, + }, + $defs: { + Node: { type: 'string' }, + }, + } + + expect(hoistRecursiveRefToDef(true)).toBe(true) + expect(hoistRecursiveRefToDef(schema)).toBe(schema) + }) + + it('moves # refs into a generated $defs entry', () => { + const schema: JsonSchema = { + type: 'object', + properties: { + value: { type: 'string' }, + child: { $ref: '#' }, + leaf: { $ref: '#/$defs/Leaf' }, + nested: { type: 'object', properties: { parent: { $ref: '#' } } }, + }, + required: ['value', 'child'], + $defs: { + Leaf: { + anyOf: [ + { type: 'null' }, + { $ref: '#' }, + ], + }, + }, + } + + expect(hoistRecursiveRefToDef(schema)).toEqual({ + $ref: '#/$defs/__schema0', + $defs: { + Leaf: { + anyOf: [ + { type: 'null' }, + { $ref: '#/$defs/__schema0' }, + ], + }, + __schema0: { + type: 'object', + properties: { + value: { type: 'string' }, + child: { $ref: '#/$defs/__schema0' }, + leaf: { $ref: '#/$defs/Leaf' }, + nested: { type: 'object', properties: { parent: { $ref: '#/$defs/__schema0' } } }, + }, + required: ['value', 'child'], + }, + }, + }) + }) + + it('uses the next available generated name when $defs already contains one', () => { + const schema: JsonSchema = { + type: 'array', + items: { $ref: '#' }, + $defs: { + __schema0: { type: 'string' }, + }, + } + + expect(hoistRecursiveRefToDef(schema)).toEqual({ + $ref: '#/$defs/__schema1', + $defs: { + __schema0: { type: 'string' }, + __schema1: { + type: 'array', + items: { $ref: '#/$defs/__schema1' }, + }, + }, + }) + }) + + it('hoists partial recursive local refs into the generated $defs schema', () => { + const schema: JsonSchema = { + type: 'object', + properties: { + one: { type: 'string' }, + twp: { $ref: '#/properties/one' }, + non_exists: { $ref: '#/nonExists' }, + }, + required: ['value', 'child'], + } + + expect(hoistRecursiveRefToDef(schema)).toEqual({ + $ref: '#/$defs/__schema0', + $defs: { + __schema0: { + type: 'object', + properties: { + one: { type: 'string' }, + twp: { $ref: '#/$defs/__schema0/properties/one' }, + non_exists: { $ref: '#/nonExists' }, + }, + required: ['value', 'child'], + }, + }, + }) + }) + + it('preserves escaped JSON pointer segments when hoisting partial recursive local refs', () => { + const slashKey = 'a/b' + const tildeKey = 'a~b' + const encodedSlashKey = encodeJsonPointerSegment(slashKey) + + const schema: JsonSchema = { + type: 'object', + properties: { + [slashKey]: { type: 'string' }, + nested: { + type: 'object', + properties: { + [tildeKey]: { $ref: `#/properties/${encodedSlashKey}` }, + }, + }, + }, + } + + expect(hoistRecursiveRefToDef(schema)).toEqual({ + $ref: '#/$defs/__schema0', + $defs: { + __schema0: { + type: 'object', + properties: { + [slashKey]: { type: 'string' }, + nested: { + type: 'object', + properties: { + [tildeKey]: { $ref: `#/$defs/__schema0/properties/${encodedSlashKey}` }, + }, + }, + }, + }, + }, + }) + }) +}) diff --git a/packages/json-schema/src/ref-utils.ts b/packages/json-schema/src/ref-utils.ts new file mode 100644 index 000000000..92cb56ad5 --- /dev/null +++ b/packages/json-schema/src/ref-utils.ts @@ -0,0 +1,155 @@ +/** + * These utilities assume the schema has only one root-level `$defs` object + * and exclusively use absolute JSON pointers for `$ref` values. + */ + +import type { JsonSchema } from './types' +import { get } from '@orpc/shared' +import { JSON_SCHEMA_LOGIC_KEYWORDS, JSON_SCHEMA_RECORD_KEYWORDS } from './constants' + +/** + * Encodes a JSON Pointer segment according to RFC 6901. + * + * https://datatracker.ietf.org/doc/html/rfc6901 + */ +export function encodeJsonPointerSegment(segment: string): string { + return segment.replaceAll('~', '~0').replaceAll('/', '~1') +} + +/** + * Decodes a JSON Pointer segment according to RFC 6901. + * + * https://datatracker.ietf.org/doc/html/rfc6901 + */ +export function decodeJsonPointerSegment(segment: string): string { + return segment.replaceAll('~1', '/').replaceAll('~0', '~') +} + +export function mapJsonSchemaRefs( + value: JsonSchema, + map: (ref: string, path: Array) => string, + schemaLevel = true, + path: Array = [], +): JsonSchema { + if (!value || typeof value !== 'object') { + return value + } + + if (Array.isArray(value)) { + return value.map((item, index) => mapJsonSchemaRefs(item, map, schemaLevel, [...path, index])) as any + } + + const result: Record = {} + for (const [key, val] of Object.entries(value)) { + if (key === '$ref' && typeof val === 'string') { + result[key] = map(val, [...path, key]) + } + else if (!schemaLevel) { + result[key] = mapJsonSchemaRefs(val as JsonSchema, map, true, [...path, key]) + } + else if (JSON_SCHEMA_LOGIC_KEYWORDS.has(key) || JSON_SCHEMA_RECORD_KEYWORDS.has(key)) { + result[key] = mapJsonSchemaRefs(val as JsonSchema, map, !JSON_SCHEMA_RECORD_KEYWORDS.has(key), [...path, key]) + } + else { + result[key] = val + } + } + + return result as JsonSchema +} + +/** + * Rewrites recursive root `#` refs by moving the schema body into `$defs`. + */ +export function hoistRecursiveRefToDef(schema: JsonSchema): JsonSchema { + if (typeof schema !== 'object') { + return schema + } + + let defName: string | undefined + + const rewritten = mapJsonSchemaRefs(schema, (ref) => { + if (ref === '#' || (ref.startsWith('#/') && !ref.startsWith('#/$defs/') && get(schema, ref.slice(2).split('/').map(decodeJsonPointerSegment)) !== undefined)) { + defName ??= findRecursiveJsonSchemaDefName(schema.$defs) + return `#/$defs/${encodeJsonPointerSegment(defName)}${ref.slice(1)}` + } + + return ref + }) + + if (defName === undefined) { + return schema + } + + const { $defs, ...rest } = rewritten as Exclude + + return { + $ref: `#/$defs/${encodeJsonPointerSegment(defName)}`, + $defs: { + ...$defs, + [defName]: rest, + }, + } +} + +/** + * Resolves a local `$ref` at the **root level** of the given schema, if present. + * + * Only handles refs of the form `#/$defs/` pointing into the provided + * (or schema-embedded) `$defs` map. Nested `$ref`s inside sub-schemas are + * intentionally left untouched. + * + * If the ref cannot be resolved (missing `$defs`, unknown key, etc.) the + * schema is returned as-is. + * + * @param schema - The schema whose root-level `$ref` should be resolved. + * @param $defs - Definition map to resolve against. If omitted, falls back to + * `schema.$defs`. When provided, takes precedence over any `$defs` embedded + * in the schema. + */ +export function resolveJsonSchemaRootLocalRef( + schema: JsonSchema, + $defs?: Exclude['$defs'], +): JsonSchema { + if (typeof schema === 'boolean') { + return schema + } + + if (arguments.length === 1) { + $defs = schema.$defs + } + + if (!$defs) { + return schema + } + + if (typeof schema.$ref !== 'string' || !schema.$ref.startsWith('#/$defs/')) { + return schema + } + + const resolved = get($defs, schema.$ref.slice('#/$defs/'.length).split('/').map(decodeJsonPointerSegment)) as JsonSchema | undefined + + if (resolved === undefined) { + return schema + } + + if (typeof resolved !== 'object') { + return resolved + } + + const { $ref: _ref, ...rest } = schema + return resolveJsonSchemaRootLocalRef({ + ...rest, + ...resolved, + }) +} + +function findRecursiveJsonSchemaDefName(defs: Exclude['$defs'] | undefined): string { + let index = 0 + + while (defs?.[`__schema${index}`] !== undefined) { + index++ + } + + return `__schema${index}` +} diff --git a/packages/json-schema/src/smart-coercion-handler-plugin.test.ts b/packages/json-schema/src/smart-coercion-handler-plugin.test.ts new file mode 100644 index 000000000..a33ee48c4 --- /dev/null +++ b/packages/json-schema/src/smart-coercion-handler-plugin.test.ts @@ -0,0 +1,53 @@ +import { oc } from '@orpc/contract' +import z from 'zod' +import { SmartCoercionHandlerPlugin } from './smart-coercion-handler-plugin' + +describe('smartCoercionHandlerPlugin', () => { + it('prepends its interceptor and skips coercion when input schemas are missing', async () => { + const existingInterceptor = vi.fn() + const plugin = new SmartCoercionHandlerPlugin() + + const options = plugin.init({ clientInterceptors: [existingInterceptor] } as any) + + expect(options.clientInterceptors).toHaveLength(2) + expect(options.clientInterceptors?.[1]).toBe(existingInterceptor) + + const next = vi.fn().mockResolvedValue('handled') + + await expect(options.clientInterceptors?.[0]?.({ + procedure: oc, + input: { value: '1' }, + next, + } as any)).resolves.toBe('handled') + + expect(next).toHaveBeenCalledOnce() + expect(next).toHaveBeenCalledWith() + }) + + it('coerces input schemas and reuses converted schemas from the cache', async () => { + const plugin = new SmartCoercionHandlerPlugin() + + const procedure = oc + .input(z.looseObject({ number: z.number() })) + .input(z.looseObject({ boolean: z.boolean() })) + + const options = plugin.init({} as any) + const interceptor = options.clientInterceptors?.[0] + const next = vi.fn().mockResolvedValue('handled') + + await expect(interceptor?.({ + procedure, + input: { number: '123', boolean: 'true' }, + next, + } as any)).resolves.toBe('handled') + + await expect(interceptor?.({ + procedure, + input: { number: '456', boolean: 'off' }, + next, + } as any)).resolves.toBe('handled') + + expect(next).toHaveBeenNthCalledWith(1, { procedure, input: { number: 123, boolean: true } }) + expect(next).toHaveBeenNthCalledWith(2, { procedure, input: { number: 456, boolean: false } }) + }) +}) diff --git a/packages/json-schema/src/smart-coercion-handler-plugin.ts b/packages/json-schema/src/smart-coercion-handler-plugin.ts new file mode 100644 index 000000000..ff700e073 --- /dev/null +++ b/packages/json-schema/src/smart-coercion-handler-plugin.ts @@ -0,0 +1,63 @@ +import type { AnySchema } from '@orpc/contract' +import type { Context } from '@orpc/server' +import type { StandardHandlerOptions, StandardHandlerPlugin } from '@orpc/server/standard' +import type { JsonSchemaConverter } from './convert' +import type { JsonSchema } from './types' +import { toArray } from '@orpc/shared' +import { JsonSchemaCoercer } from './coercer' +import { DelegatingJsonSchemaConverter } from './convert' +import { StandardJsonSchemaConverter } from './standard-json-schema-converter' + +export interface SmartCoercionHandlerPluginOptions { + converters?: undefined | JsonSchemaConverter[] +} + +export class SmartCoercionHandlerPlugin implements StandardHandlerPlugin { + name = '~smart-coercion' + + private readonly converter: DelegatingJsonSchemaConverter + private readonly coercer: JsonSchemaCoercer + private readonly cache: WeakMap = new WeakMap() + + constructor(options: SmartCoercionHandlerPluginOptions = {}) { + this.converter = new DelegatingJsonSchemaConverter([ + ...toArray(options.converters), + new StandardJsonSchemaConverter(), + ]) + this.coercer = new JsonSchemaCoercer() + } + + init(options: StandardHandlerOptions): StandardHandlerOptions { + return { + ...options, + clientInterceptors: [ + async ({ next, input, ...interceptorOptions }) => { + const inputSchemas = interceptorOptions.procedure['~orpc'].inputSchemas + + if (!inputSchemas) { + return next() + } + + const coercedInput = await this.coerceValue(inputSchemas, input) + return next({ ...interceptorOptions, input: coercedInput }) + }, + ...toArray(options.clientInterceptors), + ], + } + } + + private async coerceValue(schemas: AnySchema[], value: unknown): Promise { + for (const schema of schemas) { + let converted = this.cache.get(schema) + + if (!converted) { + converted = await this.converter.convert(schema, 'input') + this.cache.set(schema, converted) + } + + value = this.coercer.coerce(converted, value) + } + + return value + } +} diff --git a/packages/json-schema/src/smart-coercion-link-plugin.test.ts b/packages/json-schema/src/smart-coercion-link-plugin.test.ts new file mode 100644 index 000000000..05fa19d5b --- /dev/null +++ b/packages/json-schema/src/smart-coercion-link-plugin.test.ts @@ -0,0 +1,148 @@ +import type { AnyORPCError } from '@orpc/client' +import { ORPCError } from '@orpc/client' +import { oc } from '@orpc/contract' +import z from 'zod' +import { SmartCoercionLinkPlugin } from './smart-coercion-link-plugin' + +describe('smartCoercionLinkPlugin', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('appends its interceptor and throws when the resolved contract is not a procedure', async () => { + const contract = oc.router({ + users: { + list: oc, + }, + }) + const existingInterceptor = vi.fn() + const plugin = new SmartCoercionLinkPlugin(contract) + + const options = plugin.init({ interceptors: [existingInterceptor] } as any) + + expect(options.interceptors).toHaveLength(2) + expect(options.interceptors?.[0]).toBe(existingInterceptor) + + await expect(options.interceptors?.[1]?.({ + path: ['users', 'list', 'extra'], + next: vi.fn(), + } as any)).rejects.toThrow( + 'No valid procedure found at path "users.list.extra"', + ) + }) + + it('returns the original output when the procedure has no output schemas', async () => { + const contract = oc.router({ + users: { + get: oc, + }, + }) + + const plugin = new SmartCoercionLinkPlugin(contract) + + const output = { value: '1' } + const next = vi.fn().mockResolvedValue(output) + const interceptor = plugin.init({} as any).interceptors?.[0] + + await expect(interceptor?.({ + path: ['users', 'get'], + next, + } as any)).resolves.toBe(output) + + expect(next).toHaveBeenCalledOnce() + }) + + it('coerces output schemas and reuses converted schemas from the cache', async () => { + const contract = oc.router({ + users: { + get: oc.output(z.looseObject({ number: z.number() })).output(z.looseObject({ boolean: z.boolean() })), + }, + }) + + const plugin = new SmartCoercionLinkPlugin(contract) + + const interceptor = plugin.init({} as any).interceptors?.[0] + const next = vi.fn() + .mockResolvedValueOnce({ number: '123', boolean: 'true' }) + .mockResolvedValueOnce({ number: '456', boolean: 'off' }) + + await expect(interceptor?.({ + path: ['users', 'get'], + next, + } as any)).resolves.toEqual({ number: 123, boolean: true }) + + await expect(interceptor?.({ + path: ['users', 'get'], + next, + } as any)).resolves.toEqual({ number: 456, boolean: false }) + }) + + it('throw the original error when it not defined ORPCError or has no data schema', async () => { + const contract = oc.router({ + users: { + get: oc, + }, + }) + + const plugin = new SmartCoercionLinkPlugin(contract) + + const error = new Error('Message') + const orpcError = new ORPCError('FORBIDDEN') + const definedORPCError = new ORPCError('FORBIDDEN') + ;(definedORPCError as any).defined = true + + const interceptor = plugin.init({} as any).interceptors?.[0] + const next = vi.fn() + .mockThrowOnce(error) + .mockThrowOnce(orpcError) + .mockThrowOnce(definedORPCError) + + await expect(interceptor?.({ + path: ['users', 'get'], + next, + } as any)).rejects.toBe(error) + + await expect(interceptor?.({ + path: ['users', 'get'], + next, + } as any)).rejects.toBe(orpcError) + + await expect(interceptor?.({ + path: ['users', 'get'], + next, + } as any)).rejects.toBe(definedORPCError) + }) + + it('coerces error data', async () => { + const contract = oc.router({ + users: { + get: oc.errors({ + FORBIDDEN: { + data: z.object({ number: z.number() }), + }, + }), + }, + }) + + const plugin = new SmartCoercionLinkPlugin(contract) + + const definedORPCError = new ORPCError('FORBIDDEN', { data: { number: '123' } }) + ;(definedORPCError as any).defined = true + + const interceptor = plugin.init({} as any).interceptors?.[0] + const next = vi.fn().mockThrowOnce(definedORPCError) + + await expect(interceptor?.({ + path: ['users', 'get'], + next, + } as any)).rejects.toSatisfy((error: AnyORPCError) => { + expect(error).instanceOf(ORPCError) + expect(error).not.toBe(definedORPCError) + expect(error.defined).toEqual(true) + expect(error.stack).toEqual(definedORPCError.stack) + expect(error.data).toEqual({ number: 123 }) + + return true + }) + }) +}) diff --git a/packages/json-schema/src/smart-coercion-link-plugin.ts b/packages/json-schema/src/smart-coercion-link-plugin.ts new file mode 100644 index 000000000..4773ecbac --- /dev/null +++ b/packages/json-schema/src/smart-coercion-link-plugin.ts @@ -0,0 +1,94 @@ +import type { ClientContext } from '@orpc/client' +import type { StandardLinkOptions, StandardLinkPlugin } from '@orpc/client/standard' +import type { AnySchema, ErrorMap, RouterContract } from '@orpc/contract' +import type { JsonSchemaConverter } from './convert' +import type { JsonSchema } from './types' +import { cloneORPCError, ORPCError } from '@orpc/client' +import { getProcedureContractOrThrow } from '@orpc/contract' +import { toArray } from '@orpc/shared' +import { JsonSchemaCoercer } from './coercer' +import { DelegatingJsonSchemaConverter } from './convert' +import { StandardJsonSchemaConverter } from './standard-json-schema-converter' + +export interface SmartCoercionLinkPluginOptions { + converters?: undefined | JsonSchemaConverter[] +} + +export class SmartCoercionLinkPlugin implements StandardLinkPlugin { + name = '~smart-coercion' + + /** + * Output and error values should be coerced before validation. + */ + after = ['~response-validation'] + + private readonly converter: DelegatingJsonSchemaConverter + private readonly coercer: JsonSchemaCoercer + private readonly cache: WeakMap = new WeakMap() + + constructor( + private readonly contract: RouterContract, + options: SmartCoercionLinkPluginOptions = {}, + ) { + this.converter = new DelegatingJsonSchemaConverter([ + ...toArray(options.converters), + new StandardJsonSchemaConverter(), + ]) + this.coercer = new JsonSchemaCoercer() + } + + init(options: StandardLinkOptions): StandardLinkOptions { + return { + ...options, + interceptors: [ + ...toArray(options.interceptors), + async ({ next, path }) => { + const procedure = getProcedureContractOrThrow(this.contract, path) + + try { + const output = await next() + const outputSchemas = procedure['~orpc'].outputSchemas + + if (!outputSchemas) { + return output + } + + const coercedOutput = await this.coerceValue(outputSchemas, output) + return coercedOutput + } + catch (error) { + if (!(error instanceof ORPCError) || !error.defined) { + throw error + } + + const errorMap: ErrorMap = procedure['~orpc'].errorMap + const dataSchema = errorMap[error.code]?.data + + if (!dataSchema) { + throw error + } + + const cloned = cloneORPCError(error) + cloned.data = await this.coerceValue([dataSchema], cloned.data) + throw cloned + } + }, + ], + } + } + + private async coerceValue(schemas: AnySchema[], value: unknown): Promise { + for (const schema of schemas) { + let converted = this.cache.get(schema) + + if (!converted) { + converted = await this.converter.convert(schema, 'output') + this.cache.set(schema, converted) + } + + value = this.coercer.coerce(converted, value) + } + + return value + } +} diff --git a/packages/json-schema/src/smart-coercion-plugin.test.ts b/packages/json-schema/src/smart-coercion-plugin.test.ts deleted file mode 100644 index a68e2ba7e..000000000 --- a/packages/json-schema/src/smart-coercion-plugin.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import * as z from 'zod' -import { ZodToJsonSchemaConverter } from '../../zod/src/zod4' -import { SmartCoercionPlugin } from './smart-coercion-plugin' - -describe('smartCoercionPlugin', () => { - it('should coerce input based on schema', async () => { - const plugin = new SmartCoercionPlugin({ - schemaConverters: [ - new ZodToJsonSchemaConverter(), - ], - }) - const options = {} as any - plugin.init(options) - - const coerce = async (schema: any, originalInput: unknown) => { - let coerced: unknown - - await options.clientInterceptors[0]({ - procedure: { - '~orpc': { - inputSchema: schema, - }, - }, - input: originalInput, - next: ({ input } = { input: originalInput }) => { - coerced = input - }, - }) - - return coerced - } - - expect(await coerce(undefined, { a: '123' })).toEqual({ a: '123' }) - expect(await coerce(z.object({ a: z.number() }), { a: '123' })).toEqual({ a: 123 }) - expect(await coerce(z.object({ a: z.boolean() }), { a: 'on' })).toEqual({ a: true }) - }) -}) diff --git a/packages/json-schema/src/smart-coercion-plugin.ts b/packages/json-schema/src/smart-coercion-plugin.ts deleted file mode 100644 index a48caa174..000000000 --- a/packages/json-schema/src/smart-coercion-plugin.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { AnySchema } from '@orpc/contract' -import type { ConditionalSchemaConverter, SchemaConverter } from '@orpc/openapi' -import type { Context } from '@orpc/server' -import type { StandardHandlerOptions, StandardHandlerPlugin } from '@orpc/server/standard' -import type { JsonSchema } from './types' -import { CompositeSchemaConverter } from '@orpc/openapi' -import { toArray } from '@orpc/shared' -import { - JsonSchemaCoercer, -} from './coercer' - -export interface SmartCoercionPluginOptions { - schemaConverters?: readonly ConditionalSchemaConverter[] -} - -export class SmartCoercionPlugin implements StandardHandlerPlugin { - private readonly converter: SchemaConverter - private readonly coercer: JsonSchemaCoercer - private readonly cache: WeakMap = new WeakMap() - - constructor(options: SmartCoercionPluginOptions = {}) { - this.converter = new CompositeSchemaConverter(toArray(options.schemaConverters)) - this.coercer = new JsonSchemaCoercer() - } - - init(options: StandardHandlerOptions): void { - options.clientInterceptors ??= [] - - options.clientInterceptors.unshift(async (options) => { - const inputSchema = options.procedure['~orpc'].inputSchema - - if (!inputSchema) { - return options.next() - } - - const coercedInput = await this.#coerce(inputSchema, options.input) - - return options.next({ ...options, input: coercedInput }) - }) - } - - async #coerce(schema: AnySchema, value: unknown): Promise { - let jsonSchema = this.cache.get(schema) - - if (!jsonSchema) { - jsonSchema = (await this.converter.convert(schema, { strategy: 'input' }))[1] - this.cache.set(schema, jsonSchema) - } - - return this.coercer.coerce(jsonSchema, value) - } -} diff --git a/packages/json-schema/src/standard-json-schema-converter.test.ts b/packages/json-schema/src/standard-json-schema-converter.test.ts new file mode 100644 index 000000000..4193cd8c5 --- /dev/null +++ b/packages/json-schema/src/standard-json-schema-converter.test.ts @@ -0,0 +1,94 @@ +import type { AnySchema } from '@orpc/contract' +import * as arktype from 'arktype' +import z from 'zod' +import { StandardJsonSchemaConverter } from './standard-json-schema-converter' + +function withStandardOverrides(schema: TSchema, overrides: Record): TSchema { + Object.defineProperty(schema, '~standard', { + value: { + ...schema['~standard'], + ...overrides, + }, + }) + + return schema +} + +describe('standardJsonSchemaConverter', () => { + const converter = new StandardJsonSchemaConverter() + + describe('.condition', () => { + it.each([ + ['zod', z.string()], + ['arktype', arktype.type('string')], + ] as const)('accepts %s schemas', (_, schema) => { + expect(converter.condition(schema, 'input')).toBe(true) + }) + }) + + it.each([ + ['zod', z.number().transform(String).pipe(z.string()), 'number', 'string'], + ['arktype', arktype.type('number'), 'number', 'number'], + ] as const)('uses %s standard json schema input and output generators', (_, schema, inputType, outputType) => { + expect(converter.convert(schema, 'input')).toEqual([ + expect.objectContaining({ type: inputType }), + false, + ]) + + expect(converter.convert(schema, 'output')).toEqual([ + expect.objectContaining({ type: outputType }), + false, + ]) + }) + + it('infers optionality from zod and arktype', () => { + expect(converter.convert(z.string().default('fallback'), 'input')).toEqual([ + expect.objectContaining({ type: 'string' }), + true, + ]) + + expect(converter.convert(z.string().default('fallback'), 'output')).toEqual([ + expect.objectContaining({ type: 'string' }), + false, + ]) + + expect(converter.convert(arktype.type('string | undefined'), 'input')).toEqual([{}, true]) + + expect(converter.convert(arktype.type('string | undefined'), 'output')).toEqual([{}, true]) + }) + + it('keeps converting when standard validation is async or throws', () => { + const asyncSchema = withStandardOverrides(z.string(), { + validate: () => Promise.resolve({ value: undefined }), + }) + + expect(converter.convert(asyncSchema, 'output')).toEqual([ + expect.objectContaining({ type: 'string' }), + false, + ]) + + const throwingSchema = withStandardOverrides(arktype.type('string'), { + validate: () => { + throw new Error('validate failed') + }, + }) + + expect(converter.convert(throwingSchema, 'input')).toEqual([ + expect.objectContaining({ type: 'string' }), + false, + ]) + }) + + it('falls back to an empty optional schema when json schema generation throws', () => { + const schema = withStandardOverrides(z.string(), { + jsonSchema: { + input: () => { + throw new Error('unsupported') + }, + output: () => ({ type: 'string' }), + }, + }) + + expect(converter.convert(schema, 'input')).toEqual([{}, true]) + }) +}) diff --git a/packages/json-schema/src/standard-json-schema-converter.ts b/packages/json-schema/src/standard-json-schema-converter.ts new file mode 100644 index 000000000..d9f03c19e --- /dev/null +++ b/packages/json-schema/src/standard-json-schema-converter.ts @@ -0,0 +1,46 @@ +import type { AnySchema } from '@orpc/contract' +// eslint-disable-next-line no-restricted-imports +import type { StandardJSONSchemaV1 } from '@standard-schema/spec' +import type { JsonSchemaConverter, JsonSchemaConverterDirection } from './convert' +import type { JsonSchema } from './types' +import { isTypescriptObject } from '@orpc/shared' + +export class StandardJsonSchemaConverter implements JsonSchemaConverter { + condition(schema: AnySchema | undefined, _direction: JsonSchemaConverterDirection): boolean { + return Boolean( + schema + && 'jsonSchema' in schema['~standard'] + && isTypescriptObject(schema['~standard'].jsonSchema) + && 'input' in schema['~standard'].jsonSchema + && typeof schema['~standard'].jsonSchema.input === 'function' + && 'output' in schema['~standard'].jsonSchema + && typeof schema['~standard'].jsonSchema.output === 'function', + ) + } + + convert(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): [jsonSchema: JsonSchema, optional: boolean] { + return this.convertInternal(schema as any, direction) + } + + convertInternal(schema: StandardJSONSchemaV1 & AnySchema, direction: JsonSchemaConverterDirection): [jsonSchema: JsonSchema, optional: boolean] { + try { + const jsonSchema = direction === 'input' + ? schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) + : schema['~standard'].jsonSchema.output({ target: 'draft-2020-12' }) + + let optional = false + try { + const result = schema['~standard'].validate(undefined) + if (!(result instanceof Promise) && !result.issues) { + optional = direction === 'input' ? true : result.value === undefined + } + } + catch {} + + return [jsonSchema, optional] + } + catch { + return [{}, true] + } + } +} diff --git a/packages/json-schema/src/types.ts b/packages/json-schema/src/types.ts index 9a62daea9..7d5c28a72 100644 --- a/packages/json-schema/src/types.ts +++ b/packages/json-schema/src/types.ts @@ -1,14 +1,8 @@ // eslint-disable-next-line no-restricted-imports -import type * as Draft07 from 'json-schema-typed/draft-07' -// eslint-disable-next-line no-restricted-imports -import type * as Draft2019 from 'json-schema-typed/draft-2019-09' -// eslint-disable-next-line no-restricted-imports import type * as Draft2020 from 'json-schema-typed/draft-2020-12' -export type JsonSchema - = | Draft2020.JSONSchema - | Draft2019.JSONSchema - | Draft07.JSONSchema +export type JsonSchema = Draft2020.JSONSchema +export type JsonSchemaKeywords = typeof Draft2020.keywords[number] export enum JsonSchemaXNativeType { BigInt = 'bigint', @@ -18,3 +12,6 @@ export enum JsonSchemaXNativeType { Set = 'set', Map = 'map', } + +// eslint-disable-next-line no-restricted-imports +export { Format as JsonSchemaFormat, TypeName as JsonSchemaType } from 'json-schema-typed/draft-2020-12' diff --git a/packages/json-schema/src/utils.test.ts b/packages/json-schema/src/utils.test.ts new file mode 100644 index 000000000..8f177295f --- /dev/null +++ b/packages/json-schema/src/utils.test.ts @@ -0,0 +1,49 @@ +import type { JsonSchema } from './types' +import { ensureJsonSchemaObject, isJsonArraySchema, isJsonFileSchema, isJsonObjectSchema, isUnconstrainedSchema } from './utils' + +it('isJsonFileSchema', () => { + expect(isJsonFileSchema({ type: 'string', contentMediaType: 'image/png' })).toBe(true) + expect(isJsonFileSchema({ type: 'string', format: 'binary' })).toBe(true) + expect(isJsonFileSchema({ type: 'string', format: 'binary' })).toBe(true) + expect(isJsonFileSchema({ type: 'string', contentEncoding: 'binary' })).toBe(true) + + expect(isJsonFileSchema({ type: 'string' })).toBe(false) + expect(isJsonFileSchema({ type: 'object', contentMediaType: 'image/png' })).toBe(false) + expect(isJsonFileSchema({ type: 'object', format: 'binary' })).toBe(false) + expect(isJsonFileSchema({ type: 'object', contentEncoding: 'binary' })).toBe(false) + expect(isJsonFileSchema(true)).toBe(false) +}) + +it('isJsonObjectSchema', () => { + expect(isJsonObjectSchema({ type: 'object' })).toBe(true) + + expect(isJsonObjectSchema({ type: 'array' })).toBe(false) + expect(isJsonObjectSchema(false)).toBe(false) +}) + +it('isJsonArraySchema', () => { + expect(isJsonArraySchema({ type: 'array' })).toBe(true) + + expect(isJsonArraySchema({ type: 'object' })).toBe(false) + expect(isJsonArraySchema(false)).toBe(false) +}) + +it('isUnconstrainedSchema', () => { + expect(isUnconstrainedSchema(true)).toBe(true) + expect(isUnconstrainedSchema(false)).toBe(false) + expect(isUnconstrainedSchema({})).toBe(true) + expect(isUnconstrainedSchema({ description: 'metadata only' })).toBe(true) + + expect(isUnconstrainedSchema({ type: 'string' })).toBe(false) + expect(isUnconstrainedSchema({ properties: { a: { type: 'string' } } })).toBe(false) +}) + +describe('ensureJsonSchemaObject', () => { + it('normalizes booleans and preserves object schemas', () => { + const objectSchema: JsonSchema = { type: 'string' } + + expect(ensureJsonSchemaObject(true)).toEqual({}) + expect(ensureJsonSchemaObject(false)).toEqual({ not: {} }) + expect(ensureJsonSchemaObject(objectSchema)).toBe(objectSchema) + }) +}) diff --git a/packages/json-schema/src/utils.ts b/packages/json-schema/src/utils.ts new file mode 100644 index 000000000..9bfe72256 --- /dev/null +++ b/packages/json-schema/src/utils.ts @@ -0,0 +1,60 @@ +/** + * These utilities assume the schema has only one root-level `$defs` object + * and exclusively use absolute JSON pointers for `$ref` values. + */ + +import type { JsonSchema } from './types' +import { JSON_SCHEMA_LOGIC_KEYWORDS } from './constants' + +export type JsonFileSchema = JsonSchema & object & { type: 'string', contentMediaType?: string } + +/** + * Returns true when the schema is a file-like string schema. + */ +export function isJsonFileSchema(schema: JsonSchema): schema is JsonFileSchema { + return typeof schema !== 'boolean' && schema.type === 'string' && (typeof schema.contentMediaType === 'string' || schema.format === 'binary' || schema.contentEncoding === 'binary') +} + +export type JsonObjectSchema = JsonSchema & object & { type: 'object' } + +/** + * Returns true when the schema is an object schema. + */ +export function isJsonObjectSchema(schema: JsonSchema): schema is JsonObjectSchema { + return typeof schema !== 'boolean' && schema.type === 'object' +} + +export type JsonArraySchema = JsonSchema & object & { type: 'array' } + +/** + * Returns true when the schema is an array schema. + */ +export function isJsonArraySchema(schema: JsonSchema): schema is JsonArraySchema { + return typeof schema !== 'boolean' && schema.type === 'array' +} + +/** + * Returns true when the schema does not apply any recognized constraints. + */ +export function isUnconstrainedSchema(schema: JsonSchema): boolean { + if (typeof schema === 'boolean') { + return schema + } + + if (Object.keys(schema).every(k => !JSON_SCHEMA_LOGIC_KEYWORDS.has(k))) { + return true + } + + return false +} + +/** + * Ensures a JSON Schema is represented as an object schema document. + */ +export function ensureJsonSchemaObject(schema: JsonSchema): Exclude { + if (typeof schema === 'boolean') { + return schema ? {} : { not: {} } + } + + return schema +} diff --git a/packages/json-schema/tsconfig.json b/packages/json-schema/tsconfig.json index 963ffbd54..f190fca21 100644 --- a/packages/json-schema/tsconfig.json +++ b/packages/json-schema/tsconfig.json @@ -3,11 +3,10 @@ "references": [ { "path": "../shared" }, { "path": "../server" }, - { "path": "../openapi" }, - { "path": "../contract" }, - { "path": "../interop" } + { "path": "../client" }, + { "path": "../contract" } ], - "include": ["src"], + "include": ["package.json", "src"], "exclude": [ "**/*.test.*", "**/*.test-d.ts", diff --git a/packages/nest/.gitignore b/packages/nest/.gitignore deleted file mode 100644 index f3620b55e..000000000 --- a/packages/nest/.gitignore +++ /dev/null @@ -1,26 +0,0 @@ -# Hidden folders and files -.* -!.gitignore -!.*.example - -# Common generated folders -logs/ -node_modules/ -out/ -dist/ -dist-ssr/ -build/ -coverage/ -temp/ - -# Common generated files -*.log -*.log.* -*.tsbuildinfo -*.vitest-temp.json -vite.config.ts.timestamp-* -vitest.config.ts.timestamp-* - -# Common manual ignore files -*.local -*.pem \ No newline at end of file diff --git a/packages/nest/README.md b/packages/nest/README.md deleted file mode 100644 index 4aa25fa06..000000000 --- a/packages/nest/README.md +++ /dev/null @@ -1,248 +0,0 @@ -
- oRPC logo -
- -

- - - -

Typesafe APIs Made Simple 🪄

- -**oRPC is a powerful combination of RPC and OpenAPI**, makes it easy to build APIs that are end-to-end type-safe and adhere to OpenAPI standards - ---- - -## Highlights - -- **🔗 End-to-End Type Safety**: Ensure type-safe inputs, outputs, and errors from client to server. -- **📘 First-Class OpenAPI**: Built-in support that fully adheres to the OpenAPI standard. -- **📝 Contract-First Development**: Optionally define your API contract before implementation. -- **🔍 First-Class OpenTelemetry**: Seamlessly integrate with OpenTelemetry for observability. -- **⚙️ Framework Integrations**: Seamlessly integrate with TanStack Query (React, Vue, Solid, Svelte, Angular), SWR, Pinia Colada, and more. -- **🚀 Server Actions**: Fully compatible with React Server Actions on Next.js, TanStack Start, and other platforms. -- **🔠 Standard Schema Support**: Works out of the box with Zod, Valibot, ArkType, and other schema validators. -- **🗃️ Native Types**: Supports native types like Date, File, Blob, BigInt, URL, and more. -- **⏱️ Lazy Router**: Enhance cold start times with our lazy routing feature. -- **📡 SSE & Streaming**: Enjoy full type-safe support for SSE and streaming. -- **🌍 Multi-Runtime Support**: Fast and lightweight on Cloudflare, Deno, Bun, Node.js, and beyond. -- **🔌 Extendability**: Easily extend functionality with plugins, middleware, and interceptors. - -## Documentation - -You can find the full documentation [here](https://orpc.dev). - -## Packages - -- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Build your API contract. -- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build your API or implement API contract. -- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API on the client with type-safety. -- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Generate OpenAPI specs and handle OpenAPI requests. -- [@orpc/otel](https://www.npmjs.com/package/@orpc/otel): [OpenTelemetry](https://opentelemetry.io/) integration for observability. -- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Deeply integrate oRPC with [NestJS](https://nestjs.com/). -- [@orpc/react](https://www.npmjs.com/package/@orpc/react): Utilities for integrating oRPC with React and React Server Actions. -- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): [TanStack Query](https://tanstack.com/query/latest) integration. -- [@orpc/experimental-react-swr](https://www.npmjs.com/package/@orpc/experimental-react-swr): [SWR](https://swr.vercel.app/) integration. -- [@orpc/vue-colada](https://www.npmjs.com/package/@orpc/vue-colada): Integration with [Pinia Colada](https://pinia-colada.esm.dev/). -- [@orpc/hey-api](https://www.npmjs.com/package/@orpc/hey-api): [Hey API](https://heyapi.dev/) integration. -- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): More schemas that [Zod](https://zod.dev/) doesn't support yet. -- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): OpenAPI spec generation from [Valibot](https://valibot.dev/). -- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): OpenAPI spec generation from [ArkType](https://arktype.io/). - -## `@orpc/nest` - -Deeply integrate oRPC with [NestJS](https://nestjs.com/). Read the [documentation](https://orpc.dev/docs/openapi/integrations/implement-contract-in-nest) for more information. - -### Implement Contract - -An overview of how to implement an [oRPC contract](https://orpc.dev/docs/contract-first/define-contract) in NestJS. - -```ts -import { Implement, implement, ORPCError } from '@orpc/nest' - -@Controller() -export class PlanetController { - /** - * Implement a standalone procedure - */ - @Implement(contract.planet.list) - list() { - return implement(contract.planet.list).handler(({ input }) => { - // Implement logic here - - return [] - }) - } - - /** - * Implement entire contract - */ - @Implement(contract.planet) - planet() { - return { - list: implement(contract.planet.list).handler(({ input }) => { - // Implement logic here - return [] - }), - find: implement(contract.planet.find).handler(({ input }) => { - // Implement logic here - return { - id: 1, - name: 'Earth', - description: 'The planet Earth', - } - }), - create: implement(contract.planet.create).handler(({ input }) => { - // Implement logic here - return { - id: 1, - name: 'Earth', - description: 'The planet Earth', - } - }), - } - } - - // other handlers... -} -``` - -## Sponsors - -If you find oRPC valuable and would like to support its development, you can do so here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). - -### 🏆 Platinum Sponsor - - - - - -
ScreenshotOne.com
ScreenshotOne.com
- -### 🥈 Silver Sponsor - - - - - -
村上さん
村上さん
- -### Generous Sponsors - - - - - -
LN Markets
LN Markets
- -### Sponsors - - - - - - - - - - - - - - - - - - - - - - - - -
Reece McDonald
Reece McDonald
nk
nk
supastarter
supastarter
Dexter Miguel
Dexter Miguel
herrfugbaum
herrfugbaum
Ryota Murakami
Ryota Murakami
David Cramer
David Cramer
Valerii Petryniak
Valerii Petryniak
Valerii Strilets
Valerii Strilets
Kyle Mistele
Kyle Mistele
Andrew Peters
Andrew Peters
Ryan Vogel
Ryan Vogel
christ12938
christ12938
Peter Adam
Peter Adam
Ryan Soderberg
Ryan Soderberg
shota
shota
- -### Backers - - - - - - - - - - - - - - - - - - - - - - - - - -
David Walsh
David Walsh
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
Alex
Alex
nattstack
nattstack
Andrey Gubanov
Andrey Gubanov
- -### Past Sponsors - -

- Maxie - Stijn Timmer - あわわわとーにゅ - Zuplo - motopods - Francisco Hermida - Théo LUDWIG - Abhay Ramesh - shr.ink oü - 0x4e32 - Ryuz - happyboy - yicchi - Saksham - Roman Hrynevych - rokitg - Omar Khatib - Yu-Sabo - Bapusaheb Patil - grim - Nelson Lai - Lê Cao Nguyên - Robert Soriano - SKostyukovich - Fabworks - Novak Antonijevic - Laduni Estu Syalwa - Chen, Zhi-Yuan - Illarion Koperski - Anees Iqbal - Sefa Eyeoglu - Adam Tkaczyk - plancraft -

- -## License - -Distributed under the MIT License. See [LICENSE](https://github.com/middleapi/orpc/blob/main/LICENSE) for more information. diff --git a/packages/nest/build.config.ts b/packages/nest/build.config.ts deleted file mode 100644 index 00041941d..000000000 --- a/packages/nest/build.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineBuildConfig } from 'unbuild' - -export default defineBuildConfig({ - rollup: { - esbuild: { - tsconfigRaw: { - compilerOptions: { - experimentalDecorators: true, - }, - }, - }, - }, -}) diff --git a/packages/nest/package.json b/packages/nest/package.json deleted file mode 100644 index 9a5ae4ab2..000000000 --- a/packages/nest/package.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "name": "@orpc/nest", - "type": "module", - "version": "1.14.6", - "license": "MIT", - "homepage": "https://orpc.dev", - "repository": { - "type": "git", - "url": "git+https://github.com/middleapi/orpc.git", - "directory": "packages/nest" - }, - "keywords": [ - "orpc" - ], - "sideEffects": false, - "publishConfig": { - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" - } - } - }, - "exports": { - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], - "scripts": { - "build": "unbuild", - "build:watch": "pnpm run build --watch", - "type:check": "tsc -b", - "type:check:test": "tsc -p tsconfig.test.json --noEmit" - }, - "peerDependencies": { - "@nestjs/common": ">=11.0.0", - "@nestjs/core": ">=11.0.0", - "express": ">=5.0.0", - "fastify": ">=5.0.0", - "rxjs": ">=7.0.0" - }, - "peerDependenciesMeta": { - "express": { - "optional": true - }, - "fastify": { - "optional": true - } - }, - "dependencies": { - "@orpc/client": "workspace:*", - "@orpc/contract": "workspace:*", - "@orpc/openapi": "workspace:*", - "@orpc/openapi-client": "workspace:*", - "@orpc/server": "workspace:*", - "@orpc/shared": "workspace:*", - "@orpc/standard-server": "workspace:*", - "@orpc/standard-server-fastify": "workspace:*", - "@orpc/standard-server-fetch": "workspace:*", - "@orpc/standard-server-node": "workspace:*" - }, - "devDependencies": { - "@fastify/cookie": "^11.0.2", - "@hono/node-server": "^1.19.11", - "@mnigos/platform-hono": "^0.1.3", - "@nestjs/common": "^11.1.16", - "@nestjs/core": "^11.1.16", - "@nestjs/platform-express": "^11.1.16", - "@nestjs/platform-fastify": "^11.1.16", - "@nestjs/testing": "^11.1.16", - "@ts-rest/core": "^3.52.1", - "@types/express": "^5.0.6", - "express": "^5.2.1", - "fastify": "^5.8.3", - "hono": "^4.10.7", - "rxjs": "^7.8.2", - "supertest": "^7.1.4", - "zod": "^4.3.6" - } -} diff --git a/packages/nest/src/implement.test-d.ts b/packages/nest/src/implement.test-d.ts deleted file mode 100644 index 0cc9b335f..000000000 --- a/packages/nest/src/implement.test-d.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { oc } from '@orpc/contract' -import { implement, lazy } from '@orpc/server' -import { inputSchema, outputSchema } from '../../contract/tests/shared' -import { Implement } from './implement' - -describe('@Implement', () => { - it('require return an implemented procedure and without initial context', () => { - const contract = oc.input(inputSchema).output(outputSchema) - - class _ImplProcedureController { - @Implement(contract) - ping() { - return implement(contract).handler(() => ({}) as any) - } - - @Implement(contract) - ping_with_middleware_context() { - return implement(contract).use(({ next }) => next({ context: { extra: 'value' } })).handler(() => ({}) as any) - } - - // @ts-expect-error --- return invalid - @Implement(contract) - ping_invalid() { - return 'invalid' - } - - // @ts-expect-error --- initial context is not allowed - @Implement(contract) - ping_invalid_initial_context() { - return implement(contract).$context<{ a: string }>().handler(() => ({}) as any) - } - - // @ts-expect-error --- implement wrong contract - @Implement(contract) - ping_wrong_implement() { - return implement(oc.input(inputSchema)).handler(() => ({}) as any) - } - } - }) - - it('require return an implemented router and without initial context', () => { - const contract = { - ping: oc.input(inputSchema).output(outputSchema), - } - - class _ImplProcedureController { - @Implement(contract) - ping() { - return { - ping: implement(contract.ping).handler(() => ({}) as any), - } - } - - @Implement(contract) - ping_with_middleware_context() { - return { - ping: implement(contract.ping).use(({ next }) => next({ context: { extra: 'value' } })).handler(() => ({}) as any), - } - } - - @Implement(contract) - ping_with_lazy() { - return { - ping: lazy(() => Promise.resolve({ default: implement(contract.ping).handler(() => ({}) as any) })), - } - } - - // @ts-expect-error --- return invalid - @Implement(contract) - ping_invalid() { - return 'invalid' - } - - // @ts-expect-error --- initial context is not allowed - @Implement(contract) - ping_invalid_initial_context() { - return { - ping: implement(contract.ping).$context<{ a: string }>().handler(() => ({}) as any), - } - } - - // @ts-expect-error --- initial context is not allowed - @Implement(contract) - ping_invalid_initial_context_lazy() { - return { - ping: lazy(() => Promise.resolve({ default: implement(contract.ping).$context<{ a: string }>().handler(() => ({}) as any) })), - } - } - - // @ts-expect-error --- implement wrong contract - @Implement(contract) - ping_wrong_implement() { - return { - ping: implement(oc.input(inputSchema)).handler(() => ({}) as any), - } - } - - // @ts-expect-error --- implement wrong contract - @Implement(contract) - ping_wrong_implement_lazy() { - return { - ping: lazy(() => Promise.resolve({ default: implement(oc.input(inputSchema)).handler(() => ({}) as any) })), - } - } - } - }) -}) diff --git a/packages/nest/src/implement.test.ts b/packages/nest/src/implement.test.ts deleted file mode 100644 index 317e04f07..000000000 --- a/packages/nest/src/implement.test.ts +++ /dev/null @@ -1,794 +0,0 @@ -import type { NodeHttpRequest } from '@orpc/standard-server-node' -import type { Request } from 'express' -import type { FastifyReply } from 'fastify' -import FastifyCookie from '@fastify/cookie' -import { HonoAdapter } from '@mnigos/platform-hono' -import { Controller, Req, Res } from '@nestjs/common' -import { REQUEST } from '@nestjs/core' -import { FastifyAdapter } from '@nestjs/platform-fastify' -import { Test } from '@nestjs/testing' -import { oc, ORPCError } from '@orpc/contract' -import { implement, lazy } from '@orpc/server' -import * as StandardServerNode from '@orpc/standard-server-node' -import supertest from 'supertest' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import * as z from 'zod' -import { Implement } from './implement' -import { ORPCModule } from './module' - -const sendStandardResponseSpy = vi.spyOn(StandardServerNode, 'sendStandardResponse') - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('@Implement', async () => { - const ping_handler = vi.fn(() => ({ body: 'pong', headers: { 'x-ping': 'pong' } })) - const pong_handler = vi.fn(({ input }) => { - throw new ORPCError('TEST', { - data: `pong ${input.name}`, - status: 408, - }) - }) - const peng_handler = vi.fn(({ input }) => `peng ${input.path}`) - - const contract = { - ping: oc.route({ - path: '/ping', - inputStructure: 'detailed', - outputStructure: 'detailed', - method: 'POST', - }), - pong: oc.route({ - path: '/pong/{name}', - method: 'GET', - }).input(z.object({ - name: z.string(), - })), - nested: { - peng: oc.route({ - path: '/{+path}', - method: 'DELETE', - successStatus: 202, - }).input(z.object({ - path: z.string(), - })), - }, - } - - let req: NodeHttpRequest | undefined - - beforeEach(() => { - req = undefined - }) - - @Controller() - class ImplProcedureController { - @Implement(contract.ping) - ping(@Req() _req: NodeHttpRequest) { - req = _req - - return implement(contract.ping).handler(ping_handler) - } - - @Implement(contract.pong) - pong(@Req() _req: NodeHttpRequest) { - req = _req - - return implement(contract.pong).handler(pong_handler) - } - - @Implement(contract.nested.peng) - peng(@Req() _req: NodeHttpRequest) { - req = _req - - return implement(contract.nested.peng).handler(peng_handler) - } - } - - const AdvanceMeta: MethodDecorator = (target, propertyKey, descriptor) => { - Reflect.defineMetadata('orpc:meta', { path: '/advanced' }, target, propertyKey) - } - - @Controller() - class ImplRouterController { - @Implement(contract) - @AdvanceMeta - router(@Req() _req: NodeHttpRequest) { - req = _req - - return { - ping: implement(contract.ping).handler(ping_handler), - pong: lazy(() => Promise.resolve({ default: implement(contract.pong).handler(pong_handler) })), - nested: lazy(() => Promise.resolve({ - default: { - peng: implement(contract.nested.peng).handler(peng_handler), - }, - })), - } - } - - /** - * Make sure the @Implement can prevent conflict method name - */ - router_ping() { - return 'router_ping' - } - - /** - * Make sure the @Implement can prevent conflict method name - */ - router_ping_0() { - return 'router_ping_0' - } - - /** - * Make sure the @Implement can prevent conflict method name - */ - router_nested_peng() { - return 'router_nested_peng' - } - } - - describe.each([ - [ImplProcedureController, 'implement each standalone procedure'], - [ImplRouterController, 'implement entire contract'], - ] as const)('type: $1', async (Controller, _) => { - const moduleRef = await Test.createTestingModule({ - controllers: [Controller], - }).compile() - - const app = moduleRef.createNestApplication() - await app.init() - - const httpServer = app.getHttpServer() - - it('case: call ping', async () => { - const res = await supertest(httpServer) - .post('/ping?param=value¶m2[]=value2¶m2[]=value3') - .set('x-custom', 'value') - .send({ hello: 'world' }) - - expect(res.statusCode).toEqual(200) - expect(res.body).toEqual('pong') - expect(res.headers).toEqual(expect.objectContaining({ 'x-ping': 'pong' })) - - expect(ping_handler).toHaveBeenCalledTimes(1) - expect(ping_handler).toHaveBeenCalledWith(expect.objectContaining({ - input: { - headers: expect.objectContaining({ - 'x-custom': 'value', - }), - body: { hello: 'world' }, - params: {}, - query: { - param: 'value', - param2: ['value2', 'value3'], - }, - }, - })) - - expect(req).toBeDefined() - expect(req!.method).toEqual('POST') - expect(req!.url).toEqual('/ping?param=value¶m2[]=value2¶m2[]=value3') - }) - - it('case: call pong', async () => { - const res = await supertest(httpServer).get('/pong/world') - - expect(res.statusCode).toEqual(408) - expect(res.body).toEqual(expect.objectContaining({ - code: 'TEST', - data: 'pong world', - })) - - expect(pong_handler).toHaveBeenCalledTimes(1) - expect(pong_handler).toHaveBeenCalledWith(expect.objectContaining({ - input: { - name: 'world', - }, - })) - - expect(req).toBeDefined() - expect(req!.method).toEqual('GET') - expect(req!.url).toEqual('/pong/world') - }) - - /** - * parameter match slash is not supported on fastify - */ - it('case: call peng', async () => { - const res = await supertest(httpServer).delete('/world/who%3F') - - expect(res.statusCode).toEqual(202) - expect(res.body).toEqual('peng world/who?') - - expect(peng_handler).toHaveBeenCalledTimes(1) - expect(peng_handler).toHaveBeenCalledWith(expect.objectContaining({ - input: { - path: 'world/who?', - }, - })) - - expect(req).toBeDefined() - expect(req!.method).toEqual('DELETE') - expect(req!.url).toEqual('/world/who%3F') - }) - - it('support dynamic success status', async () => { - ping_handler.mockResolvedValueOnce({ body: 'pong', headers: { 'x-ping': 'pong' }, status: 203 } as any) - - const res = await supertest(httpServer) - .post('/ping?param=value¶m2[]=value2¶m2[]=value3') - .set('x-custom', 'value') - .send({ hello: 'world' }) - - expect(res.statusCode).toEqual(203) - }) - }) - - it('can avoid conflict method name', async () => { - const controller = new ImplRouterController() - - expect(controller.router_ping()).toEqual('router_ping') - expect(controller.router_ping_0()).toEqual('router_ping_0') - expect(controller.router_nested_peng()).toEqual('router_nested_peng') - }) - - it('reflect metadata on new method', async () => { - const controller = new ImplRouterController() - - expect(Reflect.getMetadata('orpc:meta', controller, 'router_ping_1')).toEqual({ path: '/advanced' }) - expect(Reflect.getMetadata('orpc:meta', controller, 'router_pong')).toEqual({ path: '/advanced' }) - expect(Reflect.getMetadata('orpc:meta', controller, 'router_nested')).toEqual({ path: '/advanced' }) - expect(Reflect.getMetadata('orpc:meta', controller, 'router_nested_peng_0')).toEqual({ path: '/advanced' }) - }) - - it('on body parsing error', async () => { - const moduleRef = await Test.createTestingModule({ - controllers: [ImplProcedureController], - }).compile() - - const app = moduleRef.createNestApplication() - await app.init() - - const httpServer = app.getHttpServer() - - const res = await supertest(httpServer) - .post('/ping') - .set('content-type', 'multipart/form-data') - .send('invalid') - - expect(res.statusCode).toEqual(400) - expect(res.body).toEqual(expect.objectContaining({ - code: 'BAD_REQUEST', - message: 'Malformed request. Ensure the request body is properly formatted and the \'Content-Type\' header is set correctly.', - })) - }) - - it('can handle wrong implementation on runtime', async () => { - @Controller() - class WrongImplProcedureController { - @Implement(contract.ping) - ping() { - return 'wrong' as any - } - } - - const moduleRef = await Test.createTestingModule({ - controllers: [WrongImplProcedureController], - }).compile() - - const app = moduleRef.createNestApplication({ logger: false }) - await app.init() - - const httpServer = app.getHttpServer() - - const res = await supertest(httpServer) - .post('/ping?param=value¶m2[]=value2¶m2[]=value3') - .set('x-custom', 'value') - .send({ hello: 'world' }) - - expect(res.statusCode).toEqual(500) - expect(res.body).toEqual({ - statusCode: 500, - message: 'Internal server error', - }) - }) - - it('throw on build if contract is not has a path', async () => { - const invalidContract = oc.route({}) - - expect(() => { - @Controller() - class WrongImplProcedureController { - @Implement(invalidContract) - peng() { - return implement(invalidContract).handler(() => {}) - } - } - }).toThrow('Please define one using \'path\' property on the \'.route\' method.') - }) - - it('partial working on fastify', async () => { - @Controller() - class FastifyController { - @Implement(contract.ping) - pong(@Req() _req: any) { - req = _req - return implement(contract.ping).handler(ping_handler) - } - } - - const moduleRef = await Test.createTestingModule({ - controllers: [FastifyController], - }).compile() - - const app = moduleRef.createNestApplication(new FastifyAdapter()) - await app.init() - await app.getHttpAdapter().getInstance().ready() - - const httpServer = app.getHttpServer() - - const res = await supertest(httpServer) - .post('/ping?param=value¶m2[]=value2¶m2[]=value3') - .set('x-custom', 'value') - .send({ hello: 'world' }) - - expect(res.statusCode).toEqual(200) - expect(res.body).toEqual('pong') - expect(res.headers).toEqual(expect.objectContaining({ 'x-ping': 'pong' })) - - expect(ping_handler).toHaveBeenCalledTimes(1) - expect(ping_handler).toHaveBeenCalledWith(expect.objectContaining({ - input: { - headers: expect.objectContaining({ - 'x-custom': 'value', - }), - body: { hello: 'world' }, - params: {}, - query: { - param: 'value', - param2: ['value2', 'value3'], - }, - }, - })) - - expect(req).toBeDefined() - expect(req!.method).toEqual('POST') - expect(req!.url).toEqual('/ping?param=value¶m2[]=value2¶m2[]=value3') - }) - - it('should pass correct signal and lastEventId', async () => { - const states: any[] = [] - - @Controller() - class ImplProcedureController { - @Implement(contract.pong) - ping() { - return implement(contract.pong).handler(({ signal, lastEventId }) => { - states.push(lastEventId) - - states.push(signal!.aborted) - signal?.addEventListener('abort', () => { - states.push(true) - }) - }) - } - } - - const moduleRef = await Test.createTestingModule({ - controllers: [ImplProcedureController], - }).compile() - - const app = moduleRef.createNestApplication() - await app.init() - - const httpServer = app.getHttpServer() - - const res = await supertest(httpServer) - .get('/pong/world') - .set('last-event-id', '123') - - expect(res.statusCode).toEqual(200) - - expect(states).toEqual([ - '123', - false, - ]) - }) - - describe('module configuration', () => { - it('works with ORPCModule.forRoot', async () => { - const interceptor = vi.fn(({ next }) => next()) - const moduleRef = await Test.createTestingModule({ - imports: [ - ORPCModule.forRoot({ - interceptors: [interceptor], - eventIteratorKeepAliveComment: '__TEST__', - customJsonSerializers: [ - { - condition: data => data === 'pong', - serialize: () => '__PONG__', - }, - ], - }), - ], - controllers: [ImplProcedureController], - }).compile() - - const app = moduleRef.createNestApplication() - await app.init() - - const httpServer = app.getHttpServer() - - const res = await supertest(httpServer) - .post('/ping?param=value¶m2[]=value2¶m2[]=value3') - .set('x-custom', 'value') - .send({ hello: 'world' }) - - expect(res.statusCode).toEqual(200) - expect(res.body).toEqual('__PONG__') - - expect(interceptor).toHaveBeenCalledTimes(1) - expect(sendStandardResponseSpy).toHaveBeenCalledTimes(1) - expect(sendStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ - eventIteratorKeepAliveComment: '__TEST__', - })) - }) - - it('works with ORPCModule.forRootAsync', async () => { - const interceptor = vi.fn(({ next }) => next()) - const moduleRef = await Test.createTestingModule({ - imports: [ - ORPCModule.forRootAsync({ - useFactory: async (request: Request) => ({ - interceptors: [interceptor], - eventIteratorKeepAliveComment: '__TEST__', - context: { - request, - }, - customJsonSerializers: [ - { - condition: data => data === 'pong', - serialize: () => '__PONG__', - }, - ], - }), - inject: [REQUEST], - }), - ], - controllers: [ImplProcedureController], - }).compile() - - const app = moduleRef.createNestApplication() - await app.init() - - const httpServer = app.getHttpServer() - - const res = await supertest(httpServer) - .post('/ping?param=value¶m2[]=value2¶m2[]=value3') - .set('x-custom', 'value') - .send({ hello: 'world' }) - - expect(res.statusCode).toEqual(200) - expect(res.body).toEqual('__PONG__') - - expect(interceptor).toHaveBeenCalledTimes(1) - expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ - context: expect.objectContaining({ - request: expect.objectContaining({ - url: '/ping?param=value¶m2[]=value2¶m2[]=value3', - headers: expect.objectContaining({ - 'x-custom': 'value', - }), - }), - }), - })) - expect(sendStandardResponseSpy).toHaveBeenCalledTimes(1) - expect(sendStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ - eventIteratorKeepAliveComment: '__TEST__', - })) - }) - - describe('sendResponseInterceptors', () => { - it('can override response with default status', async () => { - const moduleRef = await Test.createTestingModule({ - imports: [ - ORPCModule.forRoot({ - sendResponseInterceptors: [ - async ({ standardResponse }) => { - expect(standardResponse.status).toBe(202) - - return { custom: true } - }, - ], - }), - ], - controllers: [ImplProcedureController], - }).compile() - - const app = moduleRef.createNestApplication() - await app.init() - - const httpServer = app.getHttpServer() - - const res = await supertest(httpServer).delete('/world/who%3F') - - expect(res.statusCode).toEqual(202) - expect(res.text).toEqual('{"custom":true}') - }) - - it('can override response with any status', async () => { - const moduleRef = await Test.createTestingModule({ - imports: [ - ORPCModule.forRoot({ - sendResponseInterceptors: [ - async ({ standardResponse, response }) => { - expect(standardResponse.status).toBe(200) - expect(standardResponse.body).toBe('pong') - - response.status(202) - return { custom: true } - }, - ], - }), - ], - controllers: [ImplProcedureController], - }).compile() - - const app = moduleRef.createNestApplication() - await app.init() - - const httpServer = app.getHttpServer() - - const res = await supertest(httpServer) - .post('/ping?param=value¶m2[]=value2¶m2[]=value3') - .set('x-custom', 'value') - .send({ hello: 'world' }) - - expect(res.statusCode).toEqual(202) - expect(res.text).toEqual('{"custom":true}') - }) - }) - - it('plugins', async () => { - const clientInterceptor = vi.fn(({ next }) => next()) - const clientInterceptors = [clientInterceptor] - const interceptor = vi.fn(({ next }) => next()) - const plugins = [{ - init(options: any) { - options.clientInterceptors ??= [] - options.clientInterceptors.push(interceptor) - }, - }] - - // Use this clientInterceptors, plugins arrays - // to verify that handler options is cloned before applying plugins. - // Without proper cloning, interceptors would run multiple times and affect subsequent requests. - const moduleRef = await Test.createTestingModule({ - imports: [ - ORPCModule.forRoot({ - context: { thisIsContext: true }, - interceptors: clientInterceptors, - path: ['__PATH__'], - plugins, - }), - ], - controllers: [ImplProcedureController], - }).compile() - - const app = moduleRef.createNestApplication() - await app.init() - const httpServer = app.getHttpServer() - - const res1 = await supertest(httpServer).delete('/world/who%3F') - expect(res1.statusCode).toEqual(202) - expect(interceptor).toHaveBeenCalledTimes(1) - expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ - context: expect.objectContaining({ - thisIsContext: true, - }), - path: ['__PATH__'], - })) - expect(clientInterceptor).toHaveBeenCalledTimes(1) - expect(clientInterceptor).toHaveBeenCalledWith(expect.objectContaining({ - context: expect.objectContaining({ - thisIsContext: true, - }), - path: ['__PATH__'], - })) - - interceptor.mockClear() - clientInterceptor.mockClear() - const res2 = await supertest(httpServer) - .post('/ping?param=value¶m2[]=value2¶m2[]=value3') - .set('x-custom', 'value') - .send({ hello: 'world' }) - expect(res2.statusCode).toEqual(200) - expect(interceptor).toHaveBeenCalledTimes(1) - expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ - context: expect.objectContaining({ - thisIsContext: true, - }), - path: ['__PATH__'], - })) - expect(clientInterceptor).toHaveBeenCalledTimes(1) - expect(clientInterceptor).toHaveBeenCalledWith(expect.objectContaining({ - context: expect.objectContaining({ - thisIsContext: true, - }), - path: ['__PATH__'], - })) - - interceptor.mockClear() - clientInterceptor.mockClear() - const res3 = await supertest(httpServer).delete('/world/who%3F') - expect(res3.statusCode).toEqual(202) - expect(interceptor).toHaveBeenCalledTimes(1) - expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ - context: expect.objectContaining({ - thisIsContext: true, - }), - path: ['__PATH__'], - })) - expect(clientInterceptor).toHaveBeenCalledTimes(1) - expect(clientInterceptor).toHaveBeenCalledWith(expect.objectContaining({ - context: expect.objectContaining({ - thisIsContext: true, - }), - path: ['__PATH__'], - })) - }) - - it('custom error response body encoder', async () => { - const moduleRef = await Test.createTestingModule({ - imports: [ - ORPCModule.forRoot({ - customErrorResponseBodyEncoder: error => ({ - custom: true, - code: error.code, - data: error.data, - }), - }), - ], - controllers: [ImplProcedureController], - }).compile() - - const app = moduleRef.createNestApplication() - await app.init() - - const httpServer = app.getHttpServer() - - const res = await supertest(httpServer).get('/pong/world') - - expect(res.statusCode).toEqual(408) - expect(res.body).toEqual({ - custom: true, - code: 'TEST', - data: 'pong world', - }) - }) - }) - - describe('compatibility', () => { - it('works with @mnigos/platform-hono', async () => { - @Controller() - class HonoController { - @Implement(contract.ping) - ping() { - return implement(contract.ping).handler(ping_handler) - } - - @Implement(contract.pong) - pong() { - return implement(contract.pong).handler(pong_handler) - } - - @Implement(contract.nested.peng) - peng() { - return implement(contract.nested.peng).handler(peng_handler) - } - } - - const moduleRef = await Test.createTestingModule({ - controllers: [HonoController], - }).compile() - - const adapter = new HonoAdapter() - const app = moduleRef.createNestApplication(adapter, { bodyParser: false }) - await app.init() - await app.listen(0) - - const httpServer = app.getHttpServer() - - try { - const pingRes = await supertest(httpServer) - .post('/ping?param=value¶m2[]=value2¶m2[]=value3') - .set('x-custom', 'value') - .send({ hello: 'world' }) - - expect(pingRes.statusCode).toEqual(200) - expect(pingRes.body).toEqual('pong') - expect(pingRes.headers).toEqual(expect.objectContaining({ 'x-ping': 'pong' })) - - expect(ping_handler).toHaveBeenCalledWith(expect.objectContaining({ - input: { - headers: expect.objectContaining({ - 'x-custom': 'value', - }), - body: { hello: 'world' }, - params: {}, - query: { - param: 'value', - param2: ['value2', 'value3'], - }, - }, - })) - - const pongRes = await supertest(httpServer).get('/pong/world') - - expect(pongRes.statusCode).toEqual(408) - expect(pongRes.body).toEqual(expect.objectContaining({ - code: 'TEST', - data: 'pong world', - })) - expect(pong_handler).toHaveBeenCalledWith(expect.objectContaining({ - input: { - name: 'world', - }, - })) - - const pengRes = await supertest(httpServer).delete('/world/who%3F') - - expect(pengRes.statusCode).toEqual(202) - expect(pengRes.body).toEqual('peng world/who?') - expect(peng_handler).toHaveBeenCalledWith(expect.objectContaining({ - input: { - path: 'world/who?', - }, - })) - } - finally { - await app.close() - } - }) - - it('work with fastify/cookie', async () => { - @Controller() - class FastifyController { - @Implement(contract.ping) - pong(@Res({ passthrough: true }) reply: FastifyReply) { - reply.cookie('foo', 'bar') - return implement(contract.ping).handler(ping_handler) - } - } - - const moduleRef = await Test.createTestingModule({ - controllers: [FastifyController], - }).compile() - - const adapter = new FastifyAdapter() - await adapter.register(FastifyCookie as any) - const app = moduleRef.createNestApplication(adapter) - await app.init() - await app.getHttpAdapter().getInstance().ready() - - const httpServer = app.getHttpServer() - - const res = await supertest(httpServer) - .post('/ping?param=value¶m2[]=value2¶m2[]=value3') - .set('x-custom', 'value') - .send({ hello: 'world' }) - - expect(res.statusCode).toEqual(200) - expect(res.body).toEqual('pong') - expect(res.headers).toEqual(expect.objectContaining({ - 'x-ping': 'pong', - 'set-cookie': [ - expect.stringContaining('foo=bar'), - ], - })) - }) - }) -}) diff --git a/packages/nest/src/implement.ts b/packages/nest/src/implement.ts deleted file mode 100644 index 55ed6f2a9..000000000 --- a/packages/nest/src/implement.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common' -import type { ContractRouter } from '@orpc/contract' -import type { Router } from '@orpc/server' -import type { StandardParams } from '@orpc/server/standard' -import type { Promisable } from '@orpc/shared' -import type { Request, Response } from 'express' -import type { FastifyReply, FastifyRequest } from 'fastify' -import type { Observable } from 'rxjs' -import type { ORPCGlobalContext, ORPCModuleConfig } from './module' -import { applyDecorators, Delete, Get, Head, HttpCode, Inject, Injectable, Optional, Options, Patch, Post, Put, UseInterceptors } from '@nestjs/common' -import { fallbackContractConfig, isContractProcedure } from '@orpc/contract' -import { StandardBracketNotationSerializer, StandardOpenAPIJsonSerializer, StandardOpenAPISerializer } from '@orpc/openapi-client/standard' -import { StandardOpenAPICodec } from '@orpc/openapi/standard' -import { getRouter, isProcedure, unlazy } from '@orpc/server' -import { StandardHandler } from '@orpc/server/standard' -import { get, intercept, toArray } from '@orpc/shared' -import * as StandardServerFastify from '@orpc/standard-server-fastify' -import * as StandardServerFetch from '@orpc/standard-server-fetch' -import * as StandardServerNode from '@orpc/standard-server-node' -import { mergeMap } from 'rxjs' -import { ORPC_MODULE_CONFIG_SYMBOL } from './module' -import { toNestPattern } from './utils' - -interface HonoContext { - req: { raw: globalThis.Request, params?: NestParams } - res?: globalThis.Response - finalized: boolean - newResponse: (...args: any[]) => globalThis.Response -} - -const MethodDecoratorMap = { - HEAD: Head, - GET: Get, - POST: Post, - PUT: Put, - PATCH: Patch, - DELETE: Delete, - OPTIONS: Options, -} - -/** - * Decorator in controller handler to implement a oRPC contract. - * - * @see {@link https://orpc.dev/docs/openapi/integrations/implement-contract-in-nest#implement-your-contract NestJS Implement Contract Docs} - */ -export function Implement>( - contract: T, -): >>( - target: Record, - propertyKey: string, - descriptor: TypedPropertyDescriptor<(...args: any[]) => U>, -) => void { - if (isContractProcedure(contract)) { - const method = fallbackContractConfig('defaultMethod', contract['~orpc'].route.method) - const path = contract['~orpc'].route.path - - if (path === undefined) { - throw new Error(` - @Implement decorator requires contract to have a 'path'. - Please define one using 'path' property on the '.route' method. - Or use "populateContractRouterPaths" from "@orpc/contract" utility to automatically fill in any missing paths. - `) - } - - return (target, propertyKey, descriptor) => { - applyDecorators( - MethodDecoratorMap[method](toNestPattern(path)), - HttpCode(fallbackContractConfig('defaultSuccessStatus', contract['~orpc'].route.successStatus)), - UseInterceptors(ImplementInterceptor), - )(target, propertyKey, descriptor) - } - } - - return (target, propertyKey, descriptor) => { - for (const key in contract) { - let methodName = `${propertyKey}_${key}` - - let i = 0 - while (methodName in target) { - methodName = `${propertyKey}_${key}_${i++}` - } - - target[methodName] = async function (...args: any[]) { - const router = await descriptor.value!.apply(this, args) - return getRouter(router, [key]) - } - - for (const p of Reflect.getOwnMetadataKeys(target, propertyKey)) { - Reflect.defineMetadata(p, Reflect.getOwnMetadata(p, target, propertyKey), target, methodName) - } - - for (const p of Reflect.getOwnMetadataKeys(target.constructor, propertyKey)) { - Reflect.defineMetadata(p, Reflect.getOwnMetadata(p, target.constructor, propertyKey), target.constructor, methodName) - } - - Implement(get(contract, [key]) as any)(target, methodName, Object.getOwnPropertyDescriptor(target, methodName)!) - } - } -} - -type NestParams = Record - -@Injectable() -export class ImplementInterceptor implements NestInterceptor { - private readonly config: Partial - private readonly codec: StandardOpenAPICodec - - constructor( - @Inject(ORPC_MODULE_CONFIG_SYMBOL) @Optional() config: ORPCModuleConfig | undefined, - ) { - // @Optional() does not allow set default value so we need to do it here - this.config = config ?? {} - - this.codec = new StandardOpenAPICodec( - new StandardOpenAPISerializer( - new StandardOpenAPIJsonSerializer(this.config), - new StandardBracketNotationSerializer(this.config), - ), - this.config, - ) - } - - intercept(ctx: ExecutionContext, next: CallHandler): Observable { - return next.handle().pipe( - mergeMap(async (impl: unknown) => { - const { default: procedure } = await unlazy(impl) - - if (!isProcedure(procedure)) { - throw new Error(` - The return value of the @Implement controller handler must be a corresponding implemented router or procedure. - `) - } - - const req: Request | FastifyRequest | HonoContext['req'] = ctx.switchToHttp().getRequest() - const res: Response | FastifyReply | HonoContext = ctx.switchToHttp().getResponse() - - // Detect a Hono adapter response context by its Fetch response factory. - const isHono = 'finalized' in res && typeof (res as HonoContext).newResponse === 'function' - const isFastify = 'raw' in req && !isHono - - const standardRequest = (() => { - if (isHono) { - return StandardServerFetch.toStandardLazyRequest((req as HonoContext['req']).raw) - } - if (isFastify) { - return StandardServerFastify.toStandardLazyRequest(req as FastifyRequest, res as FastifyReply) - } - return StandardServerNode.toStandardLazyRequest(req as Request, res as Response) - })() - - const handler = new StandardHandler(procedure, { - init: () => {}, - match: () => Promise.resolve({ path: toArray(this.config.path), procedure, params: flattenParams(req.params as NestParams | undefined) }), - }, this.codec, { - // Since plugins can modify options directly, so we need to clone to avoid affecting other handlers/requests - // TODO: improve plugins system to avoid this cloning - clientInterceptors: [...toArray(this.config.interceptors)], - plugins: [...toArray(this.config.plugins)], - }) - - const result = await handler.handle(standardRequest, { - context: this.config.context, - }) - - if (result.matched) { - return intercept( - toArray(this.config.sendResponseInterceptors), - { request: req, response: res, standardResponse: result.response }, - async ({ response, standardResponse }) => { - if (isHono) { - const fetchResponse = StandardServerFetch.toFetchResponse(standardResponse, this.config) - return (response as HonoContext).newResponse(fetchResponse.body, fetchResponse) - } - else if (isFastify) { - await StandardServerFastify.sendStandardResponse(response as FastifyReply, standardResponse, this.config) - } - else { - await StandardServerNode.sendStandardResponse(response as Response, standardResponse, this.config) - } - }, - ) - } - }), - ) - } -} - -function flattenParams(params: NestParams | undefined): StandardParams { - const flatten: StandardParams = {} - - for (const [key, value] of Object.entries(params ?? {})) { - if (Array.isArray(value)) { - flatten[key] = value.join('/') - } - else { - flatten[key] = value - } - } - - return flatten -} diff --git a/packages/nest/src/index.test.ts b/packages/nest/src/index.test.ts deleted file mode 100644 index 8f0a9e309..000000000 --- a/packages/nest/src/index.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as ServerModule from '@orpc/server' -import { expect, it, vi } from 'vitest' -import { implement } from './index' - -vi.mock('@orpc/server', async (importOriginal) => { - const original = await importOriginal() - return { - ...original, - implement: vi.fn(original.implement), - } -}) - -it('implement is aliased', () => { - const contract = { nested: {} } - const options = { dedupeLeadingMiddlewares: false } - const impl = implement(contract, options) - - expect(ServerModule.implement).toHaveBeenCalledTimes(1) - expect(ServerModule.implement).toHaveBeenCalledWith(contract, options) - expect(impl).toBe(vi.mocked(ServerModule.implement).mock.results[0]!.value) -}) diff --git a/packages/nest/src/index.ts b/packages/nest/src/index.ts deleted file mode 100644 index 49bde505a..000000000 --- a/packages/nest/src/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { AnyContractRouter } from '@orpc/contract' -import type { BuilderConfig, Context, Implementer } from '@orpc/server' -import type { ORPCGlobalContext } from './module' -import { implement as baseImplement } from '@orpc/server' - -export * from './implement' -export { Implement as Impl } from './implement' -export * from './module' -export * from './utils' - -export { - /** - * @deprecated Import from `@orpc/contract` instead for better compatibility. - */ - populateContractRouterPaths, -} from '@orpc/contract' -export type { - /** - * @deprecated Import from `@orpc/contract` instead for better compatibility. - */ - PopulateContractRouterPathsOptions, - /** - * @deprecated Import from `@orpc/contract` instead for better compatibility. - */ - PopulatedContractRouterPaths, -} from '@orpc/contract' - -export { onError, onFinish, onStart, onSuccess, ORPCError } from '@orpc/server' -export type { - ImplementedProcedure, - Implementer, - ImplementerInternal, - ImplementerInternalWithMiddlewares, - ProcedureImplementer, - RouterImplementer, - RouterImplementerWithMiddlewares, -} from '@orpc/server' - -/** - * Alias for `implement` from `@orpc/server` with default context set to `ORPCGlobalContext` - */ -export function implement( - contract: T, - config: BuilderConfig = {}, -): Implementer { - return baseImplement(contract, config) -} diff --git a/packages/nest/src/module.ts b/packages/nest/src/module.ts deleted file mode 100644 index fc913b7ca..000000000 --- a/packages/nest/src/module.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { DynamicModule } from '@nestjs/common' -import type { AnySchema } from '@orpc/contract' -import type { StandardBracketNotationSerializerOptions, StandardOpenAPIJsonSerializerOptions } from '@orpc/openapi-client/standard' -import type { StandardOpenAPICodecOptions } from '@orpc/openapi/standard' -import type { CreateProcedureClientOptions } from '@orpc/server' -import type { StandardHandlerOptions } from '@orpc/server/standard' -import type { Interceptor } from '@orpc/shared' -import type { StandardResponse } from '@orpc/standard-server' -import type { SendStandardResponseOptions } from '@orpc/standard-server-node' -import { Module } from '@nestjs/common' -import { ImplementInterceptor } from './implement' - -export const ORPC_MODULE_CONFIG_SYMBOL = Symbol('ORPC_MODULE_CONFIG') - -/** - * You can extend this interface to add global context properties. - * @example - * ```ts - * declare module '@orpc/nest' { - * interface ORPCGlobalContext { - * user: { id: string; name: string } - * } - * } - * ``` - */ -export interface ORPCGlobalContext { - -} -// TODO: replace CreateProcedureClientOptions with StandardHandlerOptions -export interface ORPCModuleConfig extends - CreateProcedureClientOptions, - SendStandardResponseOptions, - StandardOpenAPIJsonSerializerOptions, - StandardBracketNotationSerializerOptions, - StandardOpenAPICodecOptions { - plugins?: StandardHandlerOptions['plugins'] - - sendResponseInterceptors?: Interceptor< - { request: any, response: any, standardResponse: StandardResponse }, - unknown - >[] -} - -@Module({}) -export class ORPCModule { - static forRoot(config: ORPCModuleConfig): DynamicModule { - return { - module: ORPCModule, - providers: [ - { - provide: ORPC_MODULE_CONFIG_SYMBOL, - useValue: config, - }, - ImplementInterceptor, - ], - exports: [ORPC_MODULE_CONFIG_SYMBOL, ImplementInterceptor], - global: true, - } - } - - static forRootAsync(options: { - imports?: any[] - useFactory: (...args: any[]) => Promise | ORPCModuleConfig - inject?: any[] - }): DynamicModule { - return { - module: ORPCModule, - imports: options.imports, - providers: [ - { - provide: ORPC_MODULE_CONFIG_SYMBOL, - useFactory: options.useFactory, - inject: options.inject, - }, - ImplementInterceptor, - ], - exports: [ORPC_MODULE_CONFIG_SYMBOL, ImplementInterceptor], - global: true, - } - } -} diff --git a/packages/nest/src/utils.test.ts b/packages/nest/src/utils.test.ts deleted file mode 100644 index 9cbfbb172..000000000 --- a/packages/nest/src/utils.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { toNestPattern } from './utils' - -it('toNestPattern', () => { - expect(toNestPattern('/ping')).toBe('/ping') - expect(toNestPattern('/ping')).toBe('/ping') - expect(toNestPattern('/{id}')).toBe('/:id') - expect(toNestPattern('/{id}/{+path}')).toBe('/:id/*path') - - expect(toNestPattern('/{id}/name{name}')).toBe('/:id/name{name}') -}) diff --git a/packages/nest/src/utils.ts b/packages/nest/src/utils.ts deleted file mode 100644 index fb0a90e2d..000000000 --- a/packages/nest/src/utils.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { HTTPPath } from '@orpc/contract' -import { standardizeHTTPPath } from '@orpc/openapi-client/standard' - -export function toNestPattern(path: HTTPPath): string { - return standardizeHTTPPath(path) - .replace(/\/\{\+([^}]+)\}/g, '/*$1') - .replace(/\/\{([^}]+)\}/g, '/:$1') -} diff --git a/packages/nest/tsconfig.json b/packages/nest/tsconfig.json deleted file mode 100644 index 259ffc3bf..000000000 --- a/packages/nest/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "extends": "../../tsconfig.lib.json", - "compilerOptions": { - "emitDecoratorMetadata": true, - "experimentalDecorators": true - }, - "references": [ - { "path": "../client" }, - { "path": "../contract" }, - { "path": "../openapi" }, - { "path": "../openapi-client" }, - { "path": "../server" }, - { "path": "../shared" }, - { "path": "../standard-server" }, - { "path": "../standard-server-node" } - ], - "include": ["src"], - "exclude": [ - "**/*.test.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] -} diff --git a/packages/nest/tsconfig.test.json b/packages/nest/tsconfig.test.json deleted file mode 100644 index 224a7244b..000000000 --- a/packages/nest/tsconfig.test.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "experimentalDecorators": true, - "types": ["node", "vitest/globals"] - }, - "references": [ - { "path": "./tsconfig.json" } - ], - "include": [ - "tests", - "src/**/*.test.*", - "src/**/*.test-d.ts" - ] -} diff --git a/packages/next/.gitignore b/packages/next/.gitignore new file mode 100644 index 000000000..97192859c --- /dev/null +++ b/packages/next/.gitignore @@ -0,0 +1,29 @@ +# Hidden folders and files +.* +!.gitignore +!.*.example + +# Common generated folders +logs/ +node_modules/ +out/ +dist/ +dist-ssr/ +build/ +coverage/ +temp/ + +# Common generated files +*.log +*.log.* +*.tsbuildinfo +*.vitest-temp.json +vite.config.ts.timestamp-* +vitest.config.ts.timestamp-* + +# Common manual ignore files +*.local +*.pem + +## Hey API Code gen +/tests/client/ \ No newline at end of file diff --git a/packages/next/README.md b/packages/next/README.md new file mode 100644 index 000000000..ebf800f09 --- /dev/null +++ b/packages/next/README.md @@ -0,0 +1,188 @@ +

oRPC - Typesafe APIs Made Simple 🪄

+ + + +## Documentation + +You can read the documentation [here](https://orpc.dev). + +## Packages + +**Core** + +- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Define type-safe contracts for your API. +- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Implement your API or contract on the server. +- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API from the client with full type safety. +- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Bring OpenAPI compatibility to your APIs. + +**Schema validation** + +- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): Integrate with [Zod](https://zod.dev/). +- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): Integrate with [Valibot](https://valibot.dev/). +- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): Integrate with [ArkType](https://arktype.io/). + +**Framework & ecosystem integrations** + +- [@orpc/next](https://www.npmjs.com/package/@orpc/react): Use oRPC with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). +- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). +- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). + +**Built-in features** + +- [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub. +- [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting. +- [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. + +**Observability** + +- [@orpc/opentelemetry](https://www.npmjs.com/package/@orpc/opentelemetry): Instrument your API with [OpenTelemetry](https://opentelemetry.io/). +- [@orpc/pino](https://www.npmjs.com/package/@orpc/pino): Log with [Pino](https://getpino.io/). +- [@orpc/evlog](https://www.npmjs.com/package/@orpc/evlog): Log with [Evlog](https://evlog.dev/). + +## Sponsors + +Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). Every bit helps! 🚀 + +### 🏆 Platinum Sponsor + + + + + +
ScreenshotOne.com
ScreenshotOne.com
+ +### 🥈 Silver Sponsor + + + + + +
村上さん
村上さん
+ +### Generous Sponsors + + + + + +
LN Markets
LN Markets
+ +### Sponsors + + + + + + + + + + + + + + + + + + + + + + + + +
Reece McDonald
Reece McDonald
nk
nk
supastarter
supastarter
Dexter Miguel
Dexter Miguel
herrfugbaum
herrfugbaum
Ryota Murakami
Ryota Murakami
David Cramer
David Cramer
Valerii Petryniak
Valerii Petryniak
Valerii Strilets
Valerii Strilets
Kyle Mistele
Kyle Mistele
Andrew Peters
Andrew Peters
Ryan Vogel
Ryan Vogel
christ12938
christ12938
Peter Adam
Peter Adam
Ryan Soderberg
Ryan Soderberg
shota
shota
+ +### Backers + + + + + + + + + + + + + + + + + + + + + + + + + +
David Walsh
David Walsh
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
Alex
Alex
nattstack
nattstack
Andrey Gubanov
Andrey Gubanov
+ +### Past Sponsors + +

+ Maxie + Stijn Timmer + あわわわとーにゅ + Zuplo + motopods + Francisco Hermida + Théo LUDWIG + Abhay Ramesh + shr.ink oü + 0x4e32 + Ryuz + happyboy + yicchi + Saksham + Roman Hrynevych + rokitg + Omar Khatib + Yu-Sabo + Bapusaheb Patil + grim + Nelson Lai + Lê Cao Nguyên + Robert Soriano + SKostyukovich + Fabworks + Novak Antonijevic + Laduni Estu Syalwa + Chen, Zhi-Yuan + Illarion Koperski + Anees Iqbal + Sefa Eyeoglu + Adam Tkaczyk + plancraft +

+ +## 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/next/package.json b/packages/next/package.json new file mode 100644 index 000000000..bbb25d634 --- /dev/null +++ b/packages/next/package.json @@ -0,0 +1,66 @@ +{ + "name": "@orpc/next", + "type": "module", + "version": "1.13.4", + "license": "MIT", + "homepage": "https://orpc.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/middleapi/orpc.git", + "directory": "packages/next" + }, + "keywords": [ + "orpc", + "next" + ], + "sideEffects": [ + "./dist/extensions/actionable.mjs" + ], + "publishConfig": { + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + }, + "./hooks": { + "types": "./dist/hooks/index.d.mts", + "import": "./dist/hooks/index.mjs", + "default": "./dist/hooks/index.mjs" + }, + "./extensions/actionable": { + "types": "./dist/extensions/actionable.d.mts", + "import": "./dist/extensions/actionable.mjs", + "default": "./dist/extensions/actionable.mjs" + } + } + }, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./hooks": "./src/hooks/index.ts", + "./extensions/actionable": "./src/extensions/actionable.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "unbuild", + "type:check": "tsc -b" + }, + "peerDependencies": { + "next": ">=16.2.7", + "react": ">=19.2.7" + }, + "dependencies": { + "@orpc/client": "workspace:*", + "@orpc/openapi": "workspace:*", + "@orpc/server": "workspace:*", + "@orpc/shared": "workspace:*" + }, + "devDependencies": { + "next": "^16.2.7", + "react": "^19.2.7" + } +} diff --git a/packages/react/src/deferred-interceptors.test.ts b/packages/next/src/deferred-interceptors.test.ts similarity index 100% rename from packages/react/src/deferred-interceptors.test.ts rename to packages/next/src/deferred-interceptors.test.ts diff --git a/packages/react/src/deferred-interceptors.ts b/packages/next/src/deferred-interceptors.ts similarity index 100% rename from packages/react/src/deferred-interceptors.ts rename to packages/next/src/deferred-interceptors.ts diff --git a/packages/next/src/extensions/actionable.test-d.ts b/packages/next/src/extensions/actionable.test-d.ts new file mode 100644 index 000000000..80d7f9dea --- /dev/null +++ b/packages/next/src/extensions/actionable.test-d.ts @@ -0,0 +1,45 @@ +import type { DecoratedProcedure, ORPCError, Procedure } from '@orpc/server' +import type { ProcedureServerFunction } from '../server-function' +import { z } from 'zod' +import './actionable' + +const errorMap = { + BASE: { + data: z.object({ id: z.string() }), + message: 'base', + }, +} + +const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) +const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) + +it('adds .actionable method to DecoratedProcedure', async () => { + const procedure = {} as DecoratedProcedure< + { auth: boolean }, + { extra: string }, + typeof schema1, + typeof schema2, + typeof errorMap, + ORPCError<'CODE', string> + > + + expectTypeOf(procedure.actionable({ context: { auth: true } })).toEqualTypeOf< + & ProcedureServerFunction< + typeof schema1, + typeof schema2, + typeof errorMap, + ORPCError<'CODE', string> + > + & Procedure< + { auth: boolean }, + { extra: string }, + typeof schema1, + typeof schema2, + typeof errorMap, + ORPCError<'CODE', string> + > + >() + + // @ts-expect-error - invalid initial context + procedure.actionable({ context: { auth: 'invalid' } }) +}) diff --git a/packages/next/src/extensions/actionable.test.ts b/packages/next/src/extensions/actionable.test.ts new file mode 100644 index 000000000..473ce0a94 --- /dev/null +++ b/packages/next/src/extensions/actionable.test.ts @@ -0,0 +1,19 @@ +import { os, Procedure } from '@orpc/server' +import { z } from 'zod' +import * as ServerFunctionModule from '../server-function' +import './actionable' + +const createServerFunctionSpy = vi.spyOn(ServerFunctionModule, 'createServerFunction') + +it('adds .actionable method to DecoratedProcedure', async () => { + const procedure = os.input(z.string()).handler(({ input }) => `Hello, ${input}!`) + + const actionable = procedure.actionable({ context: { auth: true } }) + expect(createServerFunctionSpy).toHaveBeenCalledTimes(1) + expect(createServerFunctionSpy).toHaveBeenCalledWith(procedure, { context: { auth: true } }) + expect(actionable).toBe(createServerFunctionSpy.mock.results[0]?.value) + expect(actionable).toBeInstanceOf(Procedure) + expect(actionable['~orpc']).toBe(procedure['~orpc']) + + await expect(actionable('Jack')).resolves.toEqual([null, 'Hello, Jack!']) +}) diff --git a/packages/next/src/extensions/actionable.ts b/packages/next/src/extensions/actionable.ts new file mode 100644 index 000000000..ec951ead9 --- /dev/null +++ b/packages/next/src/extensions/actionable.ts @@ -0,0 +1,36 @@ +import type { AnyORPCError, AnySchema, Context, ErrorMap, Procedure, ProcedureClientOptions } from '@orpc/server' +import type { MaybeOptionalOptions } from '@orpc/shared' +import type { ProcedureServerFunction } from '../server-function' +import { DecoratedProcedure } from '@orpc/server' +import { createServerFunction } from '../server-function' + +declare module '@orpc/server' { + interface DecoratedProcedure< + TInitialContext extends Context, + TInjectedContext extends Context, + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + TReturnedError extends AnyORPCError, + > { + actionable( + ...rest: MaybeOptionalOptions< + ProcedureClientOptions< + TInitialContext, + TOutputSchema, + TErrorMap, + TReturnedError, + object + > + > + ): + & ProcedureServerFunction + & Procedure + } +} + +DecoratedProcedure.prototype.actionable = function callable(...rest) { + const actionable = createServerFunction(this, ...rest) as any + actionable['~orpc'] = this['~orpc'] + return actionable +} diff --git a/packages/next/src/hooks/index.test.ts b/packages/next/src/hooks/index.test.ts new file mode 100644 index 000000000..c7ba7ae9b --- /dev/null +++ b/packages/next/src/hooks/index.test.ts @@ -0,0 +1,6 @@ +it('exports useServerFunction and useOptimisticServerFunction', async () => { + await expect(import('./index')).resolves.toMatchObject({ + useServerFunction: expect.any(Function), + useOptimisticServerFunction: expect.any(Function), + }) +}) diff --git a/packages/next/src/hooks/index.ts b/packages/next/src/hooks/index.ts new file mode 100644 index 000000000..42f200104 --- /dev/null +++ b/packages/next/src/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './optimistic-server-function' +export * from './server-function' diff --git a/packages/next/src/hooks/optimistic-server-function.test-d.ts b/packages/next/src/hooks/optimistic-server-function.test-d.ts new file mode 100644 index 000000000..cbd339379 --- /dev/null +++ b/packages/next/src/hooks/optimistic-server-function.test-d.ts @@ -0,0 +1,29 @@ +import { os } from '@orpc/server' +import { z } from 'zod' +import { createServerFunction } from '../server-function' +import { useOptimisticServerFunction } from './optimistic-server-function' + +const inputSchema = z.object({ input: z.number().transform(v => v.toString()) }) + +describe('useOptimisticServerFunction', () => { + const fn = createServerFunction( + os + .input(inputSchema.optional()) + .handler(async ({ input }) => { + return { output: Number(input) } + }), + ) + + it('can infer optimistic state', () => { + const state = useOptimisticServerFunction(fn, { + optimisticPassthrough: [{ output: 0 }], + optimisticReducer(state, input) { + expectTypeOf(state).toEqualTypeOf<{ output: number }[]>() + expectTypeOf(input).toEqualTypeOf<{ input: number } | undefined>() + return [...state, { output: Number(input?.input) }] + }, + }) + + expectTypeOf(state.optimisticState).toEqualTypeOf<{ output: number }[]>() + }) +}) diff --git a/packages/next/src/hooks/optimistic-server-function.test.tsx b/packages/next/src/hooks/optimistic-server-function.test.tsx new file mode 100644 index 000000000..b89770c42 --- /dev/null +++ b/packages/next/src/hooks/optimistic-server-function.test.tsx @@ -0,0 +1,58 @@ +import { os } from '@orpc/server' +import { act, renderHook, waitFor } from '@testing-library/react' +import { useState } from 'react' +import { z } from 'zod' +import { createServerFunction } from '../server-function' +import { useOptimisticServerFunction } from './optimistic-server-function' + +export const inputSchema = z.object({ input: z.number().transform(n => `${n}`) }) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('useOptimisticServerFunction', () => { + const handler = vi.fn(async ({ input }) => { + return { output: Number(input?.input ?? 0) } + }) + + const fn = createServerFunction( + os + .input(inputSchema) + .handler(handler), + ) + + it.each(['success', 'error'])('on %s', async (scenario) => { + if (scenario === 'error') { + handler.mockRejectedValueOnce(new Error('Test error')) + } + + const { result } = renderHook(() => { + const [outputs, setOutputs] = useState(() => [{ output: 0 }]) + const state = useOptimisticServerFunction(fn, { + optimisticPassthrough: outputs, + optimisticReducer(state, input) { + return [...state, { output: Number(input?.input ?? 0) }] + }, + }) + + return { state, setOutputs } + }) + + act(() => { + result.current.state.execute({ input: 123 }) + }) + + expect(result.current.state.optimisticState).toEqual([{ output: 0 }, { output: 123 }]) + + await waitFor(() => expect(result.current.state.status).toBe(scenario)) + + expect(result.current.state.optimisticState).toEqual([{ output: 0 }]) + + act(() => { + result.current.setOutputs(prev => [...prev, { output: 123 }]) + }) + + expect(result.current.state.optimisticState).toEqual([{ output: 0 }, { output: 123 }]) + }) +}) diff --git a/packages/next/src/hooks/optimistic-server-function.ts b/packages/next/src/hooks/optimistic-server-function.ts new file mode 100644 index 000000000..fc8a49fc2 --- /dev/null +++ b/packages/next/src/hooks/optimistic-server-function.ts @@ -0,0 +1,35 @@ +import type { AnyORPCErrorJSON } from '@orpc/client' +import type { ServerFunction, ServerFunctionError } from '../server-function' +import type { UserSeverFunctionOptions, UseServerFunctionResult } from './server-function' +import { onStart, toArray } from '@orpc/shared' +import { useCallback, useMemo, useOptimistic } from 'react' +import { useServerFunction } from './server-function' + +export interface UseOptimisticServerFunctionOptions extends + UserSeverFunctionOptions { + optimisticPassthrough: TOptimisticState + optimisticReducer: (state: TOptimisticState, input: TInput) => TOptimisticState +} + +export type UseOptimisticServerFunctionResult = UseServerFunctionResult & { + optimisticState: TOptimisticState +} + +export function useOptimisticServerFunction( + fn: ServerFunction, + options: UseOptimisticServerFunctionOptions, TOptimisticState>, +): UseOptimisticServerFunctionResult, TOptimisticState> { + const [optimisticState, addOptimistic] = useOptimistic(options.optimisticPassthrough, options.optimisticReducer) + + const state = useServerFunction(fn, { + ...options, + interceptors: [ + useCallback(onStart(({ input }) => { + addOptimistic(input) + }), [addOptimistic]), + ...toArray(options.interceptors), + ], + }) + + return useMemo(() => ({ ...state, optimisticState }), [state, optimisticState]) as any +} diff --git a/packages/next/src/hooks/server-function.test-d.ts b/packages/next/src/hooks/server-function.test-d.ts new file mode 100644 index 000000000..a9eb8245c --- /dev/null +++ b/packages/next/src/hooks/server-function.test-d.ts @@ -0,0 +1,130 @@ +import type { ORPCError } from '@orpc/server' +import { os, safe } from '@orpc/server' +import * as z from 'zod' +import { createServerFunction } from '../server-function' +import { useServerFunction } from './server-function' + +export const inputSchema = z.object({ input: z.number().transform(n => `${n}`) }) + +export const outputSchema = z.object({ output: z.number().transform(n => `${n}`) }) + +export const baseErrorMap = { + BASE: { + data: outputSchema, + }, + OVERRIDE: {}, +} + +describe('useServerFunction', () => { + const fn = createServerFunction( + os + .input(inputSchema.optional()) + .errors(baseErrorMap) + .output(outputSchema) + .handler(async ({ input }) => { + return { output: Number(input) } + }), + ) + + const state = useServerFunction(fn) + + it('infer correct input', () => { + state.execute({ input: 123 }) + state.execute(undefined) + state.execute() + // @ts-expect-error --- input is invalid + state.execute({ input: 'invalid' }) + + expectTypeOf(state.input).toEqualTypeOf() + }) + + it('require non-undefindable input ', () => { + const action = os.input(z.string()).handler(() => 123).actionable() + + const state = useServerFunction(action) + + state.execute('123') + // @ts-expect-error --- missing input + state.execute() + // @ts-expect-error --- invalid input + state.execute(123) + + if (!state.isIdle || state.status !== 'idle') { + expectTypeOf(state.input).toEqualTypeOf() + } + }) + + it('interceptors', async () => { + const state = useServerFunction(fn, { + interceptors: [ + async ({ input, next }) => { + expectTypeOf(input).toEqualTypeOf<{ input: number } | undefined>() + + const [error, data, inferableError] = await safe(next()) + + if (inferableError) { + expectTypeOf(error).toEqualTypeOf | ORPCError<'OVERRIDE', unknown>>() + } + + if (!error) { + expectTypeOf(data).toEqualTypeOf<{ output: string }>() + + return data + } + + return next() + }, + ], + }) + + state.execute({ input: 123 }, { + interceptors: [ + async ({ input, next }) => { + expectTypeOf(input).toEqualTypeOf<{ input: number } | undefined>() + + const [error, data, inferableError] = await safe(next()) + + if (inferableError) { + expectTypeOf(error).toEqualTypeOf | ORPCError<'OVERRIDE', unknown>>() + } + + if (!error) { + expectTypeOf(data).toEqualTypeOf<{ output: string }>() + + return data + } + + return next() + }, + ], + }) + }) + + it('output & error', async () => { + const [error, data, inferableError] = await state.execute({ input: 123 }) + + if (inferableError) { + expectTypeOf(error).toEqualTypeOf | ORPCError<'OVERRIDE', unknown>>() + } + + if (!error) { + expectTypeOf(data).toEqualTypeOf<{ output: string }>() + } + + if (state.isIdle || state.isPending || state.isError) { + expectTypeOf(state.data).toEqualTypeOf() + } + + if (state.status === 'idle' || state.status === 'pending' || state.status === 'error') { + expectTypeOf(state.data).toEqualTypeOf() + } + + if (state.isSuccess) { + expectTypeOf(state.data).toEqualTypeOf<{ output: string }>() + } + + if (state.status === 'success') { + expectTypeOf(state.data).toEqualTypeOf<{ output: string }>() + } + }) +}) diff --git a/packages/next/src/hooks/server-function.test.tsx b/packages/next/src/hooks/server-function.test.tsx new file mode 100644 index 000000000..02e9831bb --- /dev/null +++ b/packages/next/src/hooks/server-function.test.tsx @@ -0,0 +1,326 @@ +import { ORPCError, os } from '@orpc/server' +import { act, renderHook, waitFor } from '@testing-library/react' +import { z } from 'zod' +import { createServerFunction } from '../server-function' +import { useServerFunction } from './server-function' + +export const inputSchema = z.object({ input: z.number().transform(n => `${n}`) }) + +export const outputSchema = z.object({ output: z.number().transform(n => `${n}`) }) + +export const baseErrorMap = { + BASE: { + data: outputSchema, + }, + OVERRIDE: {}, +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('useServerFunction', () => { + const handler = vi.fn(async ({ input }) => { + return { output: Number(input?.input ?? 0) } + }) + + const fn = createServerFunction( + os + .input(inputSchema.optional()) + .errors(baseErrorMap) + .output(outputSchema) + .handler(handler), + ) + + it('on success', async () => { + const { result } = renderHook(() => useServerFunction(fn)) + + expect(result.current.status).toBe('idle') + expect(result.current.isIdle).toBe(true) + expect(result.current.isPending).toBe(false) + expect(result.current.isSuccess).toBe(false) + expect(result.current.isError).toBe(false) + expect(result.current.input).toBe(undefined) + expect(result.current.data).toBe(undefined) + expect(result.current.error).toBe(null) + + act(() => { + result.current.execute({ input: 123 }) + }) + + expect(result.current.status).toBe('pending') + expect(result.current.isIdle).toBe(false) + expect(result.current.isPending).toBe(true) + expect(result.current.isSuccess).toBe(false) + expect(result.current.isError).toBe(false) + expect(result.current.input).toEqual({ input: 123 }) + expect(result.current.data).toBe(undefined) + expect(result.current.error).toBe(null) + + await waitFor(() => expect(result.current.status).toBe('success')) + expect(result.current.isIdle).toBe(false) + expect(result.current.isPending).toBe(false) + expect(result.current.isSuccess).toBe(true) + expect(result.current.isError).toBe(false) + expect(result.current.input).toEqual({ input: 123 }) + expect(result.current.data).toEqual({ output: '123' }) + expect(result.current.error).toBe(null) + + act(() => { + result.current.reset() + }) + + expect(result.current.status).toBe('idle') + expect(result.current.isIdle).toBe(true) + expect(result.current.isPending).toBe(false) + expect(result.current.isSuccess).toBe(false) + expect(result.current.isError).toBe(false) + expect(result.current.input).toBe(undefined) + expect(result.current.data).toBe(undefined) + expect(result.current.error).toBe(null) + }) + + it('on error', async () => { + const { result } = renderHook(() => useServerFunction(fn)) + + expect(result.current.status).toBe('idle') + expect(result.current.isIdle).toBe(true) + expect(result.current.isPending).toBe(false) + expect(result.current.isSuccess).toBe(false) + expect(result.current.isError).toBe(false) + expect(result.current.input).toBe(undefined) + expect(result.current.data).toBe(undefined) + expect(result.current.error).toBe(null) + + act(() => { + // @ts-expect-error --- invalid input + result.current.execute({ input: 'invalid' }) + }) + + expect(result.current.status).toBe('pending') + expect(result.current.isIdle).toBe(false) + expect(result.current.isPending).toBe(true) + expect(result.current.isSuccess).toBe(false) + expect(result.current.isError).toBe(false) + expect(result.current.input).toEqual({ input: 'invalid' }) + expect(result.current.data).toBe(undefined) + expect(result.current.error).toBe(null) + + await waitFor(() => expect(result.current.status).toBe('error')) + expect(result.current.isIdle).toBe(false) + expect(result.current.isPending).toBe(false) + expect(result.current.isSuccess).toBe(false) + expect(result.current.isError).toBe(true) + expect(result.current.input).toEqual({ input: 'invalid' }) + expect(result.current.data).toBe(undefined) + expect(result.current.error).toBeInstanceOf(ORPCError) + + act(() => { + result.current.reset() + }) + + expect(result.current.status).toBe('idle') + expect(result.current.isIdle).toBe(true) + expect(result.current.isPending).toBe(false) + expect(result.current.isSuccess).toBe(false) + expect(result.current.isError).toBe(false) + expect(result.current.input).toBe(undefined) + expect(result.current.data).toBe(undefined) + expect(result.current.error).toBe(null) + }) + + it('on action calling error', async () => { + const { result } = renderHook(() => useServerFunction(() => { + throw new Error('failed to call') + })) + + expect(result.current.status).toBe('idle') + expect(result.current.isIdle).toBe(true) + expect(result.current.isPending).toBe(false) + expect(result.current.isSuccess).toBe(false) + expect(result.current.isError).toBe(false) + expect(result.current.input).toBe(undefined) + expect(result.current.data).toBe(undefined) + expect(result.current.error).toBe(null) + + act(() => { + result.current.execute({ input: 123 }) + }) + + expect(result.current.status).toBe('pending') + expect(result.current.isIdle).toBe(false) + expect(result.current.isPending).toBe(true) + expect(result.current.isSuccess).toBe(false) + expect(result.current.isError).toBe(false) + expect(result.current.input).toEqual({ input: 123 }) + expect(result.current.data).toBe(undefined) + expect(result.current.error).toBe(null) + + await waitFor(() => expect(result.current.status).toBe('error')) + expect(result.current.isIdle).toBe(false) + expect(result.current.isPending).toBe(false) + expect(result.current.isSuccess).toBe(false) + expect(result.current.isError).toBe(true) + expect(result.current.input).toEqual({ input: 123 }) + expect(result.current.data).toBe(undefined) + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error!.message).toBe('failed to call') + + act(() => { + result.current.reset() + }) + + expect(result.current.status).toBe('idle') + expect(result.current.isIdle).toBe(true) + expect(result.current.isPending).toBe(false) + expect(result.current.isSuccess).toBe(false) + expect(result.current.isError).toBe(false) + expect(result.current.input).toBe(undefined) + expect(result.current.data).toBe(undefined) + expect(result.current.error).toBe(null) + }) + + it('interceptors', async () => { + const interceptor = vi.fn(({ next }) => next()) + const executeInterceptor = vi.fn(({ next }) => next()) + + const { result } = renderHook(() => useServerFunction(fn, { + interceptors: [ + interceptor, + ], + })) + + expect(interceptor).toHaveBeenCalledTimes(0) + expect(executeInterceptor).toHaveBeenCalledTimes(0) + + act(() => { + result.current.execute({ input: 123 }, { + interceptors: [ + executeInterceptor, + ], + }) + }) + + expect(interceptor).toHaveBeenCalledTimes(1) + expect(executeInterceptor).toHaveBeenCalledTimes(1) + + expect(interceptor).toHaveBeenCalledWith({ + input: { input: 123 }, + next: expect.any(Function), + }) + + expect(executeInterceptor).toHaveBeenCalledWith({ + input: { input: 123 }, + next: expect.any(Function), + }) + + // Wrap the expectations in act() to properly handle state updates + await act(async () => { + expect(await interceptor.mock.results[0]!.value).toEqual({ output: '123' }) + expect(await executeInterceptor.mock.results[0]!.value).toEqual({ output: '123' }) + }) + }) + + it('multiple execute calls', async () => { + const { result } = renderHook(() => useServerFunction(fn)) + + expect(result.current.status).toBe('idle') + + handler.mockImplementationOnce(async () => { + await new Promise(resolve => setTimeout(resolve, 20)) + return { output: 123 } + }) + + let promise: Promise + + act(() => { + promise = result.current.execute({ input: 123 }) + }) + + expect(result.current.status).toBe('pending') + expect(result.current.executedAt).toBeDefined() + expect(result.current.input).toEqual({ input: 123 }) + expect(result.current.data).toBeUndefined() + expect(result.current.error).toBeNull() + + handler.mockImplementationOnce(async () => { + await new Promise(resolve => setTimeout(resolve, 40)) + return { output: 456 } + }) + + let promise2: Promise + + act(() => { + promise2 = result.current.execute({ input: 456 }) + }) + + expect(result.current.status).toBe('pending') + expect(result.current.executedAt).toBeDefined() + expect(result.current.input).toEqual({ input: 456 }) + expect(result.current.data).toBeUndefined() + expect(result.current.error).toBeNull() + + await act(async () => { + expect((await promise!)[1]).toEqual({ output: '123' }) + }) + + expect(result.current.status).toBe('pending') + expect(result.current.executedAt).toBeDefined() + expect(result.current.input).toEqual({ input: 456 }) + expect(result.current.data).toBeUndefined() + expect(result.current.error).toBeNull() + + await act(async () => { + expect((await promise2!)[1]).toEqual({ output: '456' }) + }) + + expect(result.current.status).toBe('success') + expect(result.current.executedAt).toBeDefined() + expect(result.current.input).toEqual({ input: 456 }) + expect(result.current.data).toEqual({ output: '456' }) + expect(result.current.error).toBeNull() + }) + + it('reset while executing', async () => { + const { result } = renderHook(() => useServerFunction(fn)) + + expect(result.current.status).toBe('idle') + + handler.mockImplementationOnce(async () => { + await new Promise(resolve => setTimeout(resolve, 20)) + return { output: 123 } + }) + + let promise: Promise + + act(() => { + promise = result.current.execute({ input: 123 }) + }) + + expect(result.current.status).toBe('pending') + expect(result.current.executedAt).toBeDefined() + expect(result.current.input).toEqual({ input: 123 }) + expect(result.current.data).toBeUndefined() + expect(result.current.error).toBeNull() + + act(() => { + result.current.reset() + }) + + expect(result.current.status).toBe('idle') + expect(result.current.executedAt).toBeUndefined() + expect(result.current.input).toBeUndefined() + expect(result.current.data).toBeUndefined() + expect(result.current.error).toBeNull() + + await act(async () => { + expect((await promise!)[1]).toEqual({ output: '123' }) + }) + + expect(result.current.status).toBe('idle') + expect(result.current.executedAt).toBeUndefined() + expect(result.current.input).toBeUndefined() + expect(result.current.data).toBeUndefined() + expect(result.current.error).toBeNull() + }) +}) diff --git a/packages/next/src/hooks/server-function.ts b/packages/next/src/hooks/server-function.ts new file mode 100644 index 000000000..e54bd7fb6 --- /dev/null +++ b/packages/next/src/hooks/server-function.ts @@ -0,0 +1,175 @@ +import type { AnyORPCErrorJSON, SafeResult } from '@orpc/client' +import type { Interceptor, PromiseWithError } from '@orpc/shared' +import type { ServerFunction, ServerFunctionError } from '../server-function' +import { createORPCErrorFromJson, safe } from '@orpc/client' +import { intercept, toArray } from '@orpc/shared' +import { useCallback, useMemo, useRef, useState, useTransition } from 'react' + +export interface UserSeverFunctionOptions { + interceptors?: Interceptor<{ input: TInput }, PromiseWithError>[] +} + +export interface UseServerFunctionExecuteOptions extends Pick, 'interceptors'> { +} + +export type UseServerFunctionExecuteRest + = undefined extends TInput + ? [input?: TInput, options?: UseServerFunctionExecuteOptions] + : [input: TInput, options?: UseServerFunctionExecuteOptions] + +export interface UseServerFunctionResultBase { + reset: () => void + execute: (...rest: UseServerFunctionExecuteRest) => Promise> +} + +export interface UseServerFunctionIdleResult extends UseServerFunctionResultBase { + input: undefined + data: undefined + error: null + isIdle: true + isPending: false + isSuccess: false + isError: false + status: 'idle' + executedAt: undefined +} + +export interface UseServerFunctionPendingResult extends UseServerFunctionResultBase { + input: TInput + data: undefined + error: null + isIdle: false + isPending: true + isSuccess: false + isError: false + status: 'pending' + executedAt: Date +} + +export interface UseServerFunctionSuccessResult extends UseServerFunctionResultBase { + input: TInput + data: TOutput + error: null + isIdle: false + isPending: false + isSuccess: true + isError: false + status: 'success' + executedAt: Date +} + +export interface UseServerFunctionErrorResult extends UseServerFunctionResultBase { + input: TInput + data: undefined + error: TError + isIdle: false + isPending: false + isSuccess: false + isError: true + status: 'error' + executedAt: Date +} + +export type UseServerFunctionResult + = | UseServerFunctionIdleResult + | UseServerFunctionSuccessResult + | UseServerFunctionErrorResult + | UseServerFunctionPendingResult + +const INITIAL_STATE = { + data: undefined, + error: null, + isIdle: true, + isPending: false, + isSuccess: false, + isError: false, + status: 'idle', +} as const + +const PENDING_STATE = { + data: undefined, + error: null, + isIdle: false, + isPending: true, + isSuccess: false, + isError: false, + status: 'pending', +} + +export function useServerFunction( + fn: ServerFunction, + options: UserSeverFunctionOptions> = {}, +): UseServerFunctionResult> { + const [state, setState] = useState> + | UseServerFunctionSuccessResult> + | UseServerFunctionErrorResult>, + keyof UseServerFunctionResultBase> | 'executedAt' | 'input' + >>(INITIAL_STATE) + + const executedAtRef = useRef(undefined) + const [input, setInput] = useState(undefined) + const [isPending, startTransition] = useTransition() + + const reset = useCallback(() => { + executedAtRef.current = undefined + setInput(undefined) + setState({ ...INITIAL_STATE }) + }, []) + + const execute = useCallback(async (input: TInput, executeOptions: UseServerFunctionExecuteOptions> = {}) => { + const executedAt = new Date() + executedAtRef.current = executedAt + + setInput(input) + + return new Promise((resolve) => { + startTransition(async () => { + const result = await safe(intercept( + [...toArray(options.interceptors), ...toArray(executeOptions.interceptors)], + { input: input as TInput }, + async ({ input }) => fn(input).then(([error, data]) => { + if (error) { + throw createORPCErrorFromJson(error) + } + + return data as TOutput + }), + )) + + /** + * If multiple execute calls are made in parallel, only the last one will be effective. + */ + if (executedAtRef.current === executedAt) { + setState({ + data: result.data, + error: result.error as any, + isIdle: false, + isPending: false, + isSuccess: !result.error, + isError: !!result.error, + status: !result.error ? 'success' : 'error', + }) + } + + resolve(result) + }) + }) + }, [fn, ...toArray(options.interceptors)]) + + const result = useMemo(() => { + const currentState = isPending && executedAtRef.current !== undefined + ? PENDING_STATE + : state + + return { + ...currentState, + executedAt: executedAtRef.current, + input, + reset, + execute, + } + }, [isPending, state, input, reset, execute]) + + return result as any +} diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts new file mode 100644 index 000000000..b6b44800d --- /dev/null +++ b/packages/next/src/index.test.ts @@ -0,0 +1,14 @@ +it('exports server function and server functionable, deferred interceptors, form helpers', async () => { + await expect(import('./index')).resolves.toMatchObject({ + createServerFunction: expect.any(Function), + createServerFunctionable: expect.any(Function), + createServerFormFunction: expect.any(Function), + createServerFormFunctionable: expect.any(Function), + onStartDeferred: expect.any(Function), + onSuccessDeferred: expect.any(Function), + onErrorDeferred: expect.any(Function), + onFinishDeferred: expect.any(Function), + getIssueMessage: expect.any(Function), + parseFormData: expect.any(Function), + }) +}) diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts new file mode 100644 index 000000000..08e78a2a2 --- /dev/null +++ b/packages/next/src/index.ts @@ -0,0 +1,9 @@ +export * from './deferred-interceptors' +export * from './server-form-function' +export * from './server-form-functionable' +export * from './server-function' +export * from './server-functionable' + +export { isInferableError } from '@orpc/client' +export { getIssueMessage, parseFormData } from '@orpc/openapi/helpers' +export type { Registry, ThrowableError } from '@orpc/shared' diff --git a/packages/next/src/server-form-function.test.ts b/packages/next/src/server-form-function.test.ts new file mode 100644 index 000000000..b5d473824 --- /dev/null +++ b/packages/next/src/server-form-function.test.ts @@ -0,0 +1,53 @@ +import * as ServerModule from '@orpc/server' +import { createServerFormFunction } from './server-form-function' + +const createProcedureClientSpy = vi.spyOn(ServerModule, 'createProcedureClient') +const { os, type } = ServerModule + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('createServerFormFunction', () => { + const procedure = os.input(type()).output(type()).handler(() => 'output') + + it('deserializes bracket notation form data before calling the client', async () => { + const client = vi.fn().mockResolvedValue(undefined) + createProcedureClientSpy.mockReturnValueOnce(client) + + const args = [procedure, { context: { context: true } }] as const + const serverFn = createServerFormFunction(...args) + expect(createProcedureClientSpy).toHaveBeenCalledTimes(1) + expect(createProcedureClientSpy).toHaveBeenCalledWith(...args) + + const form = new FormData() + form.append('user[name]', 'alice') + form.append('user[role]', 'admin') + + await expect(serverFn(form)).resolves.toBeUndefined() + + expect(client).toHaveBeenCalledWith({ + user: { + name: 'alice', + role: 'admin', + }, + }) + }) + + it('rethrow client errors', async () => { + const error = new Error('TEST') + const client = vi.fn().mockRejectedValueOnce(error) + createProcedureClientSpy.mockReturnValueOnce(client) + + const args = [procedure, { context: { context: true } }] as const + const serverFn = createServerFormFunction(...args) + expect(createProcedureClientSpy).toHaveBeenCalledTimes(1) + expect(createProcedureClientSpy).toHaveBeenCalledWith(...args) + + const form = new FormData() + form.append('user[name]', 'alice') + form.append('user[role]', 'admin') + + await expect(serverFn(form)).rejects.toBe(error) + }) +}) diff --git a/packages/next/src/server-form-function.ts b/packages/next/src/server-form-function.ts new file mode 100644 index 000000000..4bed17cda --- /dev/null +++ b/packages/next/src/server-form-function.ts @@ -0,0 +1,44 @@ +import type { AnyORPCError, AnySchema, Context, ErrorMap, Lazyable, Procedure, ProcedureClientOptions } from '@orpc/server' +import type { MaybeOptionalOptions } from '@orpc/shared' +import { BracketNotationSerializer } from '@orpc/openapi' +import { createProcedureClient } from '@orpc/server' +import { resolveMaybeOptionalOptions } from '@orpc/shared' + +export interface ServerFormFunction { + (form: FormData): Promise +} + +export function createServerFormFunction< + TInitialContext extends Context, + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + TReturnedError extends AnyORPCError, +>( + procedure: Lazyable>, + ...rest: MaybeOptionalOptions< + ProcedureClientOptions< + TInitialContext, + TOutputSchema, + TErrorMap, + TReturnedError, + object + > + > +): ServerFormFunction { + const options = resolveMaybeOptionalOptions(rest) + const client = createProcedureClient(procedure, options) + const serializer = new BracketNotationSerializer() + + return async (form) => { + const input = serializer.deserialize([...form]) + await client(input as any) + } +} diff --git a/packages/next/src/server-form-functionable.test-d.ts b/packages/next/src/server-form-functionable.test-d.ts new file mode 100644 index 000000000..9e6cd80d4 --- /dev/null +++ b/packages/next/src/server-form-functionable.test-d.ts @@ -0,0 +1,39 @@ +import type { Procedure } from '@orpc/server' +import type { ServerFormFunction } from './server-form-function' +import { os } from '@orpc/server' +import { z } from 'zod' +import { createServerFormFunctionable } from './server-form-functionable' + +const errorMap = { + BASE: { + data: z.object({ id: z.string() }), + message: 'base', + }, +} + +const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) +const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) + +describe('createServerFormFunctionable', () => { + const functionable = createServerFormFunctionable({ + context: { auth: true }, + }) + + it('returns the wrapped server function and preserves the procedure metadata', () => { + expectTypeOf( + functionable( + os.$context<{ auth: boolean }>().input(schema1).output(schema2).errors(errorMap).handler(() => ({ schema2: 123 })), + ), + ).toEqualTypeOf< + & ServerFormFunction + & Procedure<{ auth: boolean }, object, typeof schema1, typeof schema2, typeof errorMap, never> + >() + }) + + it('strict initial context', () => { + functionable(os.$context<{ auth: boolean }>().handler(() => 'output')) + + // @ts-expect-error - initial context is invalid + functionable(os.$context<{ auth: string }>().handler(() => 'output')) + }) +}) diff --git a/packages/next/src/server-form-functionable.test.ts b/packages/next/src/server-form-functionable.test.ts new file mode 100644 index 000000000..96f906302 --- /dev/null +++ b/packages/next/src/server-form-functionable.test.ts @@ -0,0 +1,29 @@ +import * as ServerModule from '@orpc/server' +import * as ServerFormFunctionModule from './server-form-function' +import { createServerFormFunctionable } from './server-form-functionable' + +const createServerFormFunctionSpy = vi.spyOn(ServerFormFunctionModule, 'createServerFormFunction') +const { os, type } = ServerModule + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('createServerFormFunctionable', () => { + const procedure = os.input(type()).output(type()).handler(() => 'output') + + it('returns the wrapped server form function and preserves the procedure metadata', () => { + const client = vi.fn().mockResolvedValue(undefined) as any + + const options = { context: { context: true } } + createServerFormFunctionSpy.mockReturnValueOnce(client) + + const functionable = createServerFormFunctionable(options)(procedure) + + expect(createServerFormFunctionSpy).toHaveBeenCalledTimes(1) + expect(createServerFormFunctionSpy).toHaveBeenCalledWith(procedure, options) + expect(functionable).toBe(client) + expect(functionable).toBeInstanceOf(ServerModule.Procedure) + expect(functionable['~orpc']).toBe(procedure['~orpc']) + }) +}) diff --git a/packages/next/src/server-form-functionable.ts b/packages/next/src/server-form-functionable.ts new file mode 100644 index 000000000..740b08dae --- /dev/null +++ b/packages/next/src/server-form-functionable.ts @@ -0,0 +1,61 @@ +import type { AnyORPCError, AnySchema, Context, ErrorMap, Procedure, ProcedureClientOptions, Schema } from '@orpc/server' +import type { MaybeOptionalOptions } from '@orpc/shared' +import type { ServerFormFunction } from './server-form-function' +import { resolveMaybeOptionalOptions } from '@orpc/shared' +import { createServerFormFunction } from './server-form-function' + +export interface ServerFormFunctionable { + < + TInjectedContext extends Context, + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + TReturnedError extends AnyORPCError, + >( + procedure: Procedure + ): + & ServerFormFunction + & Procedure +} + +export function createServerFormFunctionable( + ...rest: MaybeOptionalOptions< + ProcedureClientOptions< + TInitialContext, + Schema, + ErrorMap, + any, + object + > + > +): ServerFormFunctionable { + const options = resolveMaybeOptionalOptions(rest) + + return < + TInjectedContext extends Context, + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + TReturnedError extends AnyORPCError, + >( + procedure: Procedure< + TInitialContext, + TInjectedContext, + TInputSchema, + TOutputSchema, + TErrorMap, + TReturnedError + >, + ) => { + const functionable = createServerFormFunction( + procedure, + options, + ) as + & ServerFormFunction + & Procedure + + functionable['~orpc'] = procedure['~orpc'] + + return functionable + } +} diff --git a/packages/next/src/server-function.test-d.ts b/packages/next/src/server-function.test-d.ts new file mode 100644 index 000000000..3246ae2e9 --- /dev/null +++ b/packages/next/src/server-function.test-d.ts @@ -0,0 +1,62 @@ +import type { ORPCErrorCode } from '@orpc/server' +import { ORPCError, os } from '@orpc/server' +import { z } from 'zod' +import { createServerFunction } from './server-function' + +const errorMap = { + BASE: { + data: z.object({ id: z.string() }), + message: 'base', + }, +} + +const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) +const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) + +describe('createServerFunction', () => { + const procedure = os + .$context<{ auth: boolean }>() + .input(schema1) + .output(schema2) + .errors(errorMap) + .handler(() => { + if (Math.random() > 0.5) { + return new ORPCError('RETURNED', { data: 'string' }) + } + + return { schema2: 123 } + }) + + it('support typesafe errors and infer correct types', async () => { + // @ts-expect-error missing context + createServerFunction(procedure) + // @ts-expect-error invalid context + createServerFunction(procedure, { context: () => ({ auth: 'invalid' }) }) + const fn = createServerFunction(procedure, { context: () => ({ auth: true }) }) + + // @ts-expect-error missing input + fn() + // @ts-expect-error invalid input + fn('invalid') + const [error, data] = await fn({ schema1: 123 }) + + if (error) { + if (error.inferable) { + if (error.code === 'BASE') { + expectTypeOf(error.data).toEqualTypeOf<{ id: string }>() + } + + if (error.code === 'RETURNED') { + expectTypeOf(error.data).toEqualTypeOf() + } + } + else { + expectTypeOf(error.code).toEqualTypeOf() + expectTypeOf(error.data).toEqualTypeOf() + } + } + else { + expectTypeOf(data).toEqualTypeOf<{ schema2: string }>() + } + }) +}) diff --git a/packages/next/src/server-function.test.ts b/packages/next/src/server-function.test.ts new file mode 100644 index 000000000..ec06a2a12 --- /dev/null +++ b/packages/next/src/server-function.test.ts @@ -0,0 +1,74 @@ +import { ORPCError } from '@orpc/server' +import * as ServerModule from '@orpc/server' +import * as next from 'next/navigation' +import { createServerFunction } from './server-function' + +const createProcedureClientSpy = vi.spyOn(ServerModule, 'createProcedureClient') +const { os, type } = ServerModule + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('createServerFunction', () => { + const procedure = os.input(type()).output(type()).handler(() => 'output') + + it('returns data tuples when the client succeeds', async () => { + const client = vi.fn().mockResolvedValue({ output: 'pong' }) + createProcedureClientSpy.mockReturnValueOnce(client) + + const args = [procedure, { context: { context: true } }] as const + const serverFn = createServerFunction(...args) + expect(createProcedureClientSpy).toHaveBeenCalledTimes(1) + expect(createProcedureClientSpy).toHaveBeenCalledWith(...args) + + await expect(serverFn({ input: 'ping' })).resolves.toEqual([null, { output: 'pong' }]) + expect(client).toHaveBeenCalledWith({ input: 'ping' }) + }) + + it('serializes errors into server action tuples', async () => { + const error = new ORPCError('BAD_REQUEST', { + message: 'Invalid input', + data: { field: 'input' }, + }) + const client = vi.fn() + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(new Error('TEST')) + createProcedureClientSpy.mockReturnValueOnce(client) + + const args = [procedure, { context: { context: true } }] as const + const serverFn = createServerFunction(...args) + expect(createProcedureClientSpy).toHaveBeenCalledTimes(1) + expect(createProcedureClientSpy).toHaveBeenCalledWith(...args) + + await expect(serverFn({ input: 'ping' })).resolves.toEqual([error.toJSON(), undefined]) + await expect(serverFn({ input: 'ping' })).resolves.toEqual([new ORPCError('INTERNAL_SERVER_ERROR').toJSON(), undefined]) + }) + + it.each([ + [() => next.redirect('/foo')], + [() => next.forbidden()], + [() => next.unauthorized()], + [() => next.notFound()], + ])('rethrows special Next.js errors %s', async (createError) => { + (process as any).env.__NEXT_EXPERIMENTAL_AUTH_INTERRUPTS = true + + let error + try { + createError() + } + catch (e) { + error = e + } + + const client = vi.fn().mockRejectedValue(error) + createProcedureClientSpy.mockReturnValueOnce(client) + + const args = [procedure, { context: { context: true } }] as const + const serverFn = createServerFunction(...args) + expect(createProcedureClientSpy).toHaveBeenCalledTimes(1) + expect(createProcedureClientSpy).toHaveBeenCalledWith(...args) + + await expect(serverFn({ input: 'ping' })).rejects.toBe(error) + }) +}) diff --git a/packages/next/src/server-function.ts b/packages/next/src/server-function.ts new file mode 100644 index 000000000..0637499c0 --- /dev/null +++ b/packages/next/src/server-function.ts @@ -0,0 +1,86 @@ +import type { AnyORPCError, AnyORPCErrorJSON, AnySchema, Context, ErrorMap, InferSchemaInput, InferSchemaOutput, Lazyable, ORPCError, ORPCErrorCode, ORPCErrorFromErrorMap, ORPCErrorJSON, Procedure, ProcedureClientOptions, ThrowableError } from '@orpc/server' +import type { MaybeOptionalOptions } from '@orpc/shared' +import { createProcedureClient, toORPCError } from '@orpc/server' +import { resolveMaybeOptionalOptions } from '@orpc/shared' + +export type ServerFunctionORPCErrorJSON + = T extends ORPCError + ? ORPCErrorJSON & { inferable: true } + : ORPCErrorJSON & { inferable: false } + +export type ServerFunctionError + = T extends ORPCErrorJSON & { inferable: true } + ? ORPCError + : ThrowableError + +export type ServerFunctionRest + = | [input: TInput] + | (undefined extends TInput ? [input?: TInput] : [input: TInput]) + +export type ServerFunctionResult = [error: null, data: TOutput] | [error: TError, data: undefined] + +export interface ServerFunction { + (...rest: ServerFunctionRest): Promise> +} + +export type ProcedureServerFunction< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + TReturnedORPCError extends AnyORPCError, +> = ServerFunction< + InferSchemaInput, + InferSchemaOutput, + ServerFunctionORPCErrorJSON | TReturnedORPCError | ThrowableError> +> + +export function createServerFunction< + TInitialContext extends Context, + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + TReturnedError extends AnyORPCError, +>( + procedure: Lazyable>, + ...rest: MaybeOptionalOptions< + ProcedureClientOptions< + TInitialContext, + TOutputSchema, + TErrorMap, + TReturnedError, + object + > + > +): ProcedureServerFunction { + const options = resolveMaybeOptionalOptions(rest) + const client = createProcedureClient(procedure, options) + + return async (...[input]) => { + try { + return [null, await client(input as any)] + } + catch (error) { + // special next.js errors + if ( + error instanceof Error + && 'digest' in error + && typeof error.digest === 'string' + && error.digest.startsWith('NEXT_') + ) { + throw error + } + + return [ + toORPCError(error).toJSON() as ServerFunctionORPCErrorJSON | TReturnedError | ThrowableError>, + undefined, + ] + } + } +} diff --git a/packages/next/src/server-functionable.test-d.ts b/packages/next/src/server-functionable.test-d.ts new file mode 100644 index 000000000..4934a2bb8 --- /dev/null +++ b/packages/next/src/server-functionable.test-d.ts @@ -0,0 +1,39 @@ +import type { Procedure } from '@orpc/server' +import type { ProcedureServerFunction } from './server-function' +import { os } from '@orpc/server' +import { z } from 'zod' +import { createServerFunctionable } from './server-functionable' + +const errorMap = { + BASE: { + data: z.object({ id: z.string() }), + message: 'base', + }, +} + +const schema1 = z.object({ schema1: z.number().transform(n => `${n}`) }) +const schema2 = z.object({ schema2: z.number().transform(n => `${n}`) }) + +describe('createServerFunctionable', () => { + const functionable = createServerFunctionable({ + context: { auth: true }, + }) + + it('returns the wrapped server function and preserves the procedure metadata', () => { + expectTypeOf( + functionable( + os.$context<{ auth: boolean }>().input(schema1).output(schema2).errors(errorMap).handler(() => ({ schema2: 123 })), + ), + ).toEqualTypeOf< + & ProcedureServerFunction + & Procedure<{ auth: boolean }, object, typeof schema1, typeof schema2, typeof errorMap, never> + >() + }) + + it('strict initial context', () => { + functionable(os.$context<{ auth: boolean }>().handler(() => 'output')) + + // @ts-expect-error - initial context is invalid + functionable(os.$context<{ auth: string }>().handler(() => 'output')) + }) +}) diff --git a/packages/next/src/server-functionable.test.ts b/packages/next/src/server-functionable.test.ts new file mode 100644 index 000000000..d10337576 --- /dev/null +++ b/packages/next/src/server-functionable.test.ts @@ -0,0 +1,29 @@ +import * as ServerModule from '@orpc/server' +import * as ServerFunctionModule from './server-function' +import { createServerFunctionable } from './server-functionable' + +const createServerFunctionSpy = vi.spyOn(ServerFunctionModule, 'createServerFunction') +const { os, type } = ServerModule + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('createServerFunctionable', () => { + const procedure = os.input(type()).output(type()).handler(() => 'output') + + it('returns the wrapped server function and preserves the procedure metadata', () => { + const client = vi.fn().mockResolvedValue([null, { output: 'pong' }]) as any + + const options = { context: { context: true } } + createServerFunctionSpy.mockReturnValueOnce(client) + + const functionable = createServerFunctionable(options)(procedure) + + expect(createServerFunctionSpy).toHaveBeenCalledTimes(1) + expect(createServerFunctionSpy).toHaveBeenCalledWith(procedure, options) + expect(functionable).toBe(client) + expect(functionable).toBeInstanceOf(ServerModule.Procedure) + expect(functionable['~orpc']).toBe(procedure['~orpc']) + }) +}) diff --git a/packages/next/src/server-functionable.ts b/packages/next/src/server-functionable.ts new file mode 100644 index 000000000..5567bc8f5 --- /dev/null +++ b/packages/next/src/server-functionable.ts @@ -0,0 +1,54 @@ +import type { AnyORPCError, AnySchema, Context, ErrorMap, Procedure, ProcedureClientOptions, Schema } from '@orpc/server' +import type { MaybeOptionalOptions } from '@orpc/shared' +import type { ProcedureServerFunction } from './server-function' +import { resolveMaybeOptionalOptions } from '@orpc/shared' +import { createServerFunction } from './server-function' + +export interface ServerFunctionable { + < + TInjectedContext extends Context, + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + TReturnedError extends AnyORPCError, + >( + procedure: Procedure + ): + & ProcedureServerFunction + & Procedure +} + +export function createServerFunctionable( + ...rest: MaybeOptionalOptions< + ProcedureClientOptions< + TInitialContext, + Schema, + ErrorMap, + any, + object + > + > +): ServerFunctionable { + const options = resolveMaybeOptionalOptions(rest) + + return < + TInjectedContext extends Context, + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + TReturnedError extends AnyORPCError, + >( + procedure: Procedure, + ) => { + const functionable = createServerFunction( + procedure, + options, + ) as + & ProcedureServerFunction + & Procedure + + functionable['~orpc'] = procedure['~orpc'] + + return functionable + } +} diff --git a/packages/next/tsconfig.json b/packages/next/tsconfig.json new file mode 100644 index 000000000..18a5decb8 --- /dev/null +++ b/packages/next/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.lib.json", + "references": [ + { "path": "../client" }, + { "path": "../server" }, + { "path": "../openapi" }, + { "path": "../shared" } + ], + "include": ["package.json", "src"], + "exclude": [ + "**/*.test.*", + "**/*.test-d.ts", + "**/*.bench.*", + "**/__tests__/**", + "**/__mocks__/**", + "**/__snapshots__/**" + ] +} diff --git a/packages/openapi-client/.gitignore b/packages/openapi-client/.gitignore deleted file mode 100644 index f3620b55e..000000000 --- a/packages/openapi-client/.gitignore +++ /dev/null @@ -1,26 +0,0 @@ -# Hidden folders and files -.* -!.gitignore -!.*.example - -# Common generated folders -logs/ -node_modules/ -out/ -dist/ -dist-ssr/ -build/ -coverage/ -temp/ - -# Common generated files -*.log -*.log.* -*.tsbuildinfo -*.vitest-temp.json -vite.config.ts.timestamp-* -vitest.config.ts.timestamp-* - -# Common manual ignore files -*.local -*.pem \ No newline at end of file diff --git a/packages/openapi-client/README.md b/packages/openapi-client/README.md deleted file mode 100644 index 33330715b..000000000 --- a/packages/openapi-client/README.md +++ /dev/null @@ -1,194 +0,0 @@ -
- oRPC logo -
- -

- - - -

Typesafe APIs Made Simple 🪄

- -**oRPC is a powerful combination of RPC and OpenAPI**, makes it easy to build APIs that are end-to-end type-safe and adhere to OpenAPI standards - ---- - -## Highlights - -- **🔗 End-to-End Type Safety**: Ensure type-safe inputs, outputs, and errors from client to server. -- **📘 First-Class OpenAPI**: Built-in support that fully adheres to the OpenAPI standard. -- **📝 Contract-First Development**: Optionally define your API contract before implementation. -- **🔍 First-Class OpenTelemetry**: Seamlessly integrate with OpenTelemetry for observability. -- **⚙️ Framework Integrations**: Seamlessly integrate with TanStack Query (React, Vue, Solid, Svelte, Angular), SWR, Pinia Colada, and more. -- **🚀 Server Actions**: Fully compatible with React Server Actions on Next.js, TanStack Start, and other platforms. -- **🔠 Standard Schema Support**: Works out of the box with Zod, Valibot, ArkType, and other schema validators. -- **🗃️ Native Types**: Supports native types like Date, File, Blob, BigInt, URL, and more. -- **⏱️ Lazy Router**: Enhance cold start times with our lazy routing feature. -- **📡 SSE & Streaming**: Enjoy full type-safe support for SSE and streaming. -- **🌍 Multi-Runtime Support**: Fast and lightweight on Cloudflare, Deno, Bun, Node.js, and beyond. -- **🔌 Extendability**: Easily extend functionality with plugins, middleware, and interceptors. - -## Documentation - -You can find the full documentation [here](https://orpc.dev). - -## Packages - -- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Build your API contract. -- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build your API or implement API contract. -- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API on the client with type-safety. -- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Generate OpenAPI specs and handle OpenAPI requests. -- [@orpc/otel](https://www.npmjs.com/package/@orpc/otel): [OpenTelemetry](https://opentelemetry.io/) integration for observability. -- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Deeply integrate oRPC with [NestJS](https://nestjs.com/). -- [@orpc/react](https://www.npmjs.com/package/@orpc/react): Utilities for integrating oRPC with React and React Server Actions. -- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): [TanStack Query](https://tanstack.com/query/latest) integration. -- [@orpc/experimental-react-swr](https://www.npmjs.com/package/@orpc/experimental-react-swr): [SWR](https://swr.vercel.app/) integration. -- [@orpc/vue-colada](https://www.npmjs.com/package/@orpc/vue-colada): Integration with [Pinia Colada](https://pinia-colada.esm.dev/). -- [@orpc/hey-api](https://www.npmjs.com/package/@orpc/hey-api): [Hey API](https://heyapi.dev/) integration. -- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): More schemas that [Zod](https://zod.dev/) doesn't support yet. -- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): OpenAPI spec generation from [Valibot](https://valibot.dev/). -- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): OpenAPI spec generation from [ArkType](https://arktype.io/). - -## `@orpc/openapi-client` - -Provides core serializer for OpenAPI requests and responses. - -## Sponsors - -If you find oRPC valuable and would like to support its development, you can do so here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). - -### 🏆 Platinum Sponsor - - - - - -
ScreenshotOne.com
ScreenshotOne.com
- -### 🥈 Silver Sponsor - - - - - -
村上さん
村上さん
- -### Generous Sponsors - - - - - -
LN Markets
LN Markets
- -### Sponsors - - - - - - - - - - - - - - - - - - - - - - - - -
Reece McDonald
Reece McDonald
nk
nk
supastarter
supastarter
Dexter Miguel
Dexter Miguel
herrfugbaum
herrfugbaum
Ryota Murakami
Ryota Murakami
David Cramer
David Cramer
Valerii Petryniak
Valerii Petryniak
Valerii Strilets
Valerii Strilets
Kyle Mistele
Kyle Mistele
Andrew Peters
Andrew Peters
Ryan Vogel
Ryan Vogel
christ12938
christ12938
Peter Adam
Peter Adam
Ryan Soderberg
Ryan Soderberg
shota
shota
- -### Backers - - - - - - - - - - - - - - - - - - - - - - - - - -
David Walsh
David Walsh
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
Alex
Alex
nattstack
nattstack
Andrey Gubanov
Andrey Gubanov
- -### Past Sponsors - -

- Maxie - Stijn Timmer - あわわわとーにゅ - Zuplo - motopods - Francisco Hermida - Théo LUDWIG - Abhay Ramesh - shr.ink oü - 0x4e32 - Ryuz - happyboy - yicchi - Saksham - Roman Hrynevych - rokitg - Omar Khatib - Yu-Sabo - Bapusaheb Patil - grim - Nelson Lai - Lê Cao Nguyên - Robert Soriano - SKostyukovich - Fabworks - Novak Antonijevic - Laduni Estu Syalwa - Chen, Zhi-Yuan - Illarion Koperski - Anees Iqbal - Sefa Eyeoglu - Adam Tkaczyk - plancraft -

- -## License - -Distributed under the MIT License. See [LICENSE](https://github.com/middleapi/orpc/blob/main/LICENSE) for more information. diff --git a/packages/openapi-client/package.json b/packages/openapi-client/package.json deleted file mode 100644 index a2469908d..000000000 --- a/packages/openapi-client/package.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "name": "@orpc/openapi-client", - "type": "module", - "version": "1.14.6", - "license": "MIT", - "homepage": "https://orpc.dev", - "repository": { - "type": "git", - "url": "git+https://github.com/middleapi/orpc.git", - "directory": "packages/openapi-client" - }, - "keywords": [ - "orpc" - ], - "sideEffects": false, - "publishConfig": { - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" - }, - "./helpers": { - "types": "./dist/helpers/index.d.mts", - "import": "./dist/helpers/index.mjs", - "default": "./dist/helpers/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" - } - } - }, - "exports": { - ".": "./src/index.ts", - "./helpers": "./src/helpers/index.ts", - "./standard": "./src/adapters/standard/index.ts", - "./fetch": "./src/adapters/fetch/index.ts" - }, - "files": [ - "dist" - ], - "scripts": { - "build": "unbuild", - "build:watch": "pnpm run build --watch", - "type:check": "tsc -b" - }, - "dependencies": { - "@orpc/client": "workspace:*", - "@orpc/contract": "workspace:*", - "@orpc/shared": "workspace:*", - "@orpc/standard-server": "workspace:*" - }, - "devDependencies": { - "@orpc/server": "workspace:*" - } -} diff --git a/packages/openapi-client/src/adapters/fetch/index.ts b/packages/openapi-client/src/adapters/fetch/index.ts deleted file mode 100644 index 71a44b9b6..000000000 --- a/packages/openapi-client/src/adapters/fetch/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './openapi-link' diff --git a/packages/openapi-client/src/adapters/fetch/openapi-link.test.ts b/packages/openapi-client/src/adapters/fetch/openapi-link.test.ts deleted file mode 100644 index aa1a1bb4d..000000000 --- a/packages/openapi-client/src/adapters/fetch/openapi-link.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { createORPCClient } from '@orpc/client' -import { os } from '@orpc/server' -import { OpenAPIHandler } from '../../../../openapi/src/adapters/fetch/openapi-handler' -import { OpenAPILink } from './openapi-link' - -describe('openAPILink', () => { - const date = new Date() - const blob = new Blob(['hello'], { type: 'text/plain' }) - - const router = { - GET: os.route({ method: 'GET', path: '/ping/{pong}' }).handler(({ input }) => input), - POST: os.handler(({ input }) => input), - } - - const handler = new OpenAPIHandler(router, {}) - - const link = new OpenAPILink(router, { - url: 'http://localhost:3000/api', - fetch: async (request) => { - const { matched, response } = await handler.handle(request, { - prefix: '/api', - }) - - if (matched) { - return response - } - - throw new Error('No procedure match') - }, - }) - - const client = createORPCClient(link) as any - - it('method: GET', async () => { - expect(await client.GET({ - pong: 'pong', - a: 1, - b: 2, - nested: { - date, - arr: [3, date], - }, - })).toEqual({ - pong: 'pong', - a: '1', - b: '2', - nested: { - date: date.toISOString(), - arr: ['3', date.toISOString()], - }, - }) - }) - - it('method: POST', async () => { - expect(await client.POST({ - a: 1, - b: 2, - nested: { - date, - arr: [3, date], - }, - })).toEqual({ - a: 1, - b: 2, - nested: { - date: date.toISOString(), - arr: [3, date.toISOString()], - }, - }) - }) - - it('method: POST with blob', async () => { - expect(await client.POST({ - a: 1, - b: 2, - nested: { - date, - arr: [3, date], - }, - blob, - })).toEqual({ - a: '1', - b: '2', - nested: { - date: date.toISOString(), - arr: ['3', date.toISOString()], - }, - blob: expect.any(File), - }) - }) - - it('method: POST with blob in root', async () => { - expect(await client.POST({ - blob, - })).toEqual({ - blob: expect.any(File), - }) - }) -}) diff --git a/packages/openapi-client/src/adapters/fetch/openapi-link.ts b/packages/openapi-client/src/adapters/fetch/openapi-link.ts deleted file mode 100644 index d34c3f763..000000000 --- a/packages/openapi-client/src/adapters/fetch/openapi-link.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ClientContext } from '@orpc/client' -import type { LinkFetchClientOptions } from '@orpc/client/fetch' -import type { AnyContractRouter } from '@orpc/contract' -import type { StandardOpenAPILinkOptions } from '../standard' -import { LinkFetchClient } from '@orpc/client/fetch' -import { StandardOpenAPILink } from '../standard' - -export interface OpenAPILinkOptions - extends LinkFetchClientOptions, Omit, 'plugins'> { } - -/** - * The OpenAPI Link for fetch runtime communicates with the server that follow the OpenAPI specification. - * - * @see {@link https://orpc.dev/docs/openapi/client/openapi-link OpenAPI Link Docs} - * @see {@link https://swagger.io/specification/ OpenAPI Specification} - */ -export class OpenAPILink extends StandardOpenAPILink { - constructor(contract: AnyContractRouter, options: OpenAPILinkOptions) { - const linkClient = new LinkFetchClient(options) - - super(contract, linkClient, options) - } -} diff --git a/packages/openapi-client/src/adapters/standard/bracket-notation-utils.test.ts b/packages/openapi-client/src/adapters/standard/bracket-notation-utils.test.ts deleted file mode 100644 index acf5e643d..000000000 --- a/packages/openapi-client/src/adapters/standard/bracket-notation-utils.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { getIssueMessage, parseFormData } from './bracket-notation-utils' - -it('parseFormData', () => { - expect(parseFormData(new FormData())).toEqual({}) - - const form = new FormData() - form.append('a', '1') - form.append('user[name]', 'John') - form.append('user[age]', '20') - form.append('user[friends][]', 'Bob') - form.append('user[friends][]', 'Alice') - form.append('user[friends][]', 'Charlie') - form.append('thumb', new Blob(['hello']), 'thumb.png') - - expect(parseFormData(form)).toEqual({ - a: '1', - user: { - name: 'John', - age: '20', - friends: ['Bob', 'Alice', 'Charlie'], - }, - thumb: form.get('thumb'), - }) -}) - -it('getIssueMessage', () => { - expect(getIssueMessage(undefined, 'user[name]')).toBeUndefined() - expect(getIssueMessage({}, 'user[name]')).toBeUndefined() - expect(getIssueMessage({ data: {} }, 'user[name]')).toBeUndefined() - expect(getIssueMessage({ data: { issues: {} } }, 'user[name]')).toBeUndefined() - expect(getIssueMessage({ data: { issues: [] } }, 'user[name]')).toBeUndefined() - expect(getIssueMessage({ data: { issues: [{}] } }, 'user[name]')).toBeUndefined() - - expect(getIssueMessage({ data: { issues: [{ message: 'hi' }] } }, '')).toBe('hi') - expect(getIssueMessage({ data: { issues: [{ message: 'hi' }] } }, 'user[name]')).toBeUndefined() - - expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: ['user', 'name'] }] } }, 'user[name]')).toBe('hi') - expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: ['user', 'name'] }] } }, 'user[age]')).toBeUndefined() - - expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: [{ key: 'user' }, { key: 'name' }] }] } }, 'user[name]')).toBe('hi') - expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: [{ key: 'user' }, { key: 'name' }] }] } }, 'user[age]')).toBeUndefined() - - expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: ['users', '0'] }] } }, 'users[0]')).toBe('hi') - expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: ['users', '0'] }] } }, 'users[]')).toBe('hi') - expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: ['users', '0'] }] } }, 'users[1]')).toBeUndefined() - - expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: [{ key: '0' }] }] } }, '0')).toBe('hi') - expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: [{ key: '0' }] }] } }, '')).toBe('hi') - expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: [{ key: '0' }] }] } }, '1')).toBeUndefined() -}) diff --git a/packages/openapi-client/src/adapters/standard/bracket-notation-utils.ts b/packages/openapi-client/src/adapters/standard/bracket-notation-utils.ts deleted file mode 100644 index 2d3670158..000000000 --- a/packages/openapi-client/src/adapters/standard/bracket-notation-utils.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { isSchemaIssue } from '@orpc/contract' -import { isTypescriptObject } from '@orpc/shared' -import { StandardBracketNotationSerializer } from './bracket-notation' - -/** - * parse a form data with bracket notation - * - * @example - * ```ts - * const form = new FormData() - * form.append('a', '1') - * form.append('user[name]', 'John') - * form.append('user[age]', '20') - * form.append('user[friends][]', 'Bob') - * form.append('user[friends][]', 'Alice') - * form.append('user[friends][]', 'Charlie') - * form.append('thumb', new Blob(['hello']), 'thumb.png') - * - * parseFormData(form) - * // { - * // a: '1', - * // user: { - * // name: 'John', - * // age: '20', - * // friends: ['Bob', 'Alice', 'Charlie'], - * // }, - * // thumb: form.get('thumb'), - * // } - * ``` - * - * @see {@link https://orpc.dev/docs/openapi/bracket-notation Bracket Notation Docs} - */ -export function parseFormData(form: FormData): any { - const serializer = new StandardBracketNotationSerializer() - return serializer.deserialize(Array.from(form.entries())) as any -} - -/** - * Get the issue message from the error. - * - * @param error - The error (can be anything) can contain `data.issues` (standard schema issues) - * @param path - The path of the field that has the issue follow [bracket notation](https://orpc.dev/docs/openapi/bracket-notation) - * - * @example - * ```tsx - * const { error, data, execute } = useServerAction(someAction) - * - * return
execute(parseFormData(form))}> - * - *

{getIssueMessage(error, 'user[name]')}

- * - * - *

{getIssueMessage(error, 'user[age]')}

- * - * - *

{getIssueMessage(error, 'images[]')}

- *
- * - */ -export function getIssueMessage(error: unknown, path: string): string | undefined { - if (!isTypescriptObject(error) || !isTypescriptObject(error.data) || !Array.isArray(error.data.issues)) { - return undefined - } - - const serializer = new StandardBracketNotationSerializer() - - for (const issue of error.data.issues) { - if (!isSchemaIssue(issue)) { - continue - } - - if (issue.path === undefined) { - if (path === '') { - return issue.message - } - - continue - } - - const issuePath = serializer.stringifyPath( - issue.path.map(segment => typeof segment === 'object' ? segment.key.toString() : segment.toString()), - ) - - if (issuePath === path) { - return issue.message - } - - if (path.endsWith('[]') && issuePath.replace(/\[(?:0|[1-9]\d*)\]$/, '[]') === path) { - return issue.message - } - - if (path === '' && issuePath.match(/(?:0|[1-9]\d*)$/)) { - return issue.message - } - } -} diff --git a/packages/openapi-client/src/adapters/standard/bracket-notation.test.ts b/packages/openapi-client/src/adapters/standard/bracket-notation.test.ts deleted file mode 100644 index 7e427b7a8..000000000 --- a/packages/openapi-client/src/adapters/standard/bracket-notation.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { StandardBracketNotationSerializer } from './bracket-notation' - -describe('standardBracketNotationSerializer', () => { - const serializer = new StandardBracketNotationSerializer() - - it('.stringifyPath', () => { - expect(serializer.stringifyPath([])).toBe('') - expect(serializer.stringifyPath(['a', 'b', 'c', 1, 2, 3])).toBe('a[b][c][1][2][3]') - expect(serializer.stringifyPath(['\\a', '[b]', '\\c[d]'])).toBe('\\\\a[\\[b\\]][\\\\c\\[d\\]]') - }) - - it('.parsePath', () => { - expect(serializer.parsePath('')).toEqual(['']) - expect(serializer.parsePath('a[b][c][1][2][3]')).toEqual(['a', 'b', 'c', '1', '2', '3']) - expect(serializer.parsePath('\\\\a[\\[b\\]][\\\\c\\[d\\]]')).toEqual(['\\a', '[b]', '\\c[d]']) - expect(serializer.parsePath('a[b]c[d]')).toEqual(['a', 'b]c[d']) - expect(serializer.parsePath('a[b]c[d')).toEqual(['a[b]c[d']) - expect(serializer.parsePath('a[[b]]')).toEqual(['a', '[b]']) - expect(serializer.parsePath('a\\[[b]]')).toEqual(['a[', 'b]']) - expect(serializer.parsePath('abc[]')).toEqual(['abc', '']) - - expect(serializer.parsePath('abc[def')).toEqual(['abc[def']) - expect(serializer.parsePath('abc[d][ef')).toEqual(['abc[d][ef']) - expect(serializer.parsePath('abc[d][')).toEqual(['abc[d][']) - expect(serializer.parsePath('abc[')).toEqual(['abc[']) - expect(serializer.parsePath('abc]')).toEqual(['abc]']) - }) - - it.each([ - [['a', 'b', 'c']], - [['\\a', '[b]', '\\c[d]']], - [['[]', '[b]]]', '\\c[d\\][]']], - [['', '', '']], - ])('.stringifyPath + .parsePath', (segments) => { - expect(serializer.parsePath(serializer.stringifyPath(segments))).toEqual(segments) - }) - - describe('.serialize', () => { - it('can serialize primitive values', () => { - expect(serializer.serialize(1)).toEqual([ - ['', 1], - ]) - }) - - it('can serialize objects', () => { - expect(serializer.serialize({ a: 1, b: 2, c: 3 })).toEqual([ - ['a', 1], - ['b', 2], - ['c', 3], - ]) - }) - - it('can serialize arrays', () => { - expect(serializer.serialize([1, 2, 3])).toEqual([ - ['0', 1], - ['1', 2], - ['2', 3], - ]) - }) - - it('can serialize nested objects', () => { - expect(serializer.serialize({ a: { b: { c: 1, d: 2 }, e: 3, f: 4 } })).toEqual([ - ['a[b][c]', 1], - ['a[b][d]', 2], - ['a[e]', 3], - ['a[f]', 4], - ]) - }) - - it('can serialize nested arrays', () => { - expect(serializer.serialize({ a: [[1, 2], 3, 4] })).toEqual([ - ['a[0][0]', 1], - ['a[0][1]', 2], - ['a[1]', 3], - ['a[2]', 4], - ]) - }) - - it('can serialize mixed nested structures', () => { - expect(serializer.serialize({ a: { b: 1, c: [2, { d: 3, f: 4 }] } })).toEqual([ - ['a[b]', 1], - ['a[c][0]', 2], - ['a[c][1][d]', 3], - ['a[c][1][f]', 4], - ]) - }) - }) - - describe('.deserialize', () => { - it('can deserialize empty objects', () => { - expect(serializer.deserialize([])).toEqual({}) - }) - - it('can deserialize arrays', () => { - expect(serializer.deserialize([ - ['', 1], - ['', 2], - ['', 3], - ])).toEqual([1, 2, 3]) - - expect(serializer.deserialize([ - ['0', 1], - ['1', 2], - ['2', 3], - ])).toEqual([1, 2, 3]) - }) - - it('can deserialize arrays missing items', () => { - expect(serializer.deserialize([ - ['0', 1], - ['2', 2], - ])).toEqual([1, undefined, 2]) - }) - - it('can deserialize objects', () => { - expect(serializer.deserialize([ - ['a', 1], - ['b', 2], - ['c', 3], - ])).toEqual({ a: 1, b: 2, c: 3 }) - }) - - it('can deserialize number-key objects', () => { - expect(serializer.deserialize([ - ['0', 1], - ['1', 2], - ['a', 3], - ])).toEqual({ 0: 1, 1: 2, a: 3 }) - - expect(serializer.deserialize([ - ['a', 3], - ['0', 1], - ['1', 2], - ])).toEqual({ 0: 1, 1: 2, a: 3 }) - }) - - it('can deserialize empty-key objects', () => { - expect(serializer.deserialize([ - ['', 1], - ['a', 3], - ])).toEqual({ '': 1, 'a': 3 }) - - expect(serializer.deserialize([ - ['a', 3], - ['', 1], - ])).toEqual({ '': 1, 'a': 3 }) - - expect(serializer.deserialize([ - ['[a]', 1], - ['[b]', 3], - ])).toEqual({ '': { a: 1, b: 3 } }) - }) - - it('can deserialize objects when both number-key and empty-key appear', () => { - expect(serializer.deserialize([ - ['0', 1], - ['', 2], - ])).toEqual({ '0': 1, '': 2 }) - expect(serializer.deserialize([ - ['', 2], - ['0', 1], - ])).toEqual({ '0': 1, '': 2 }) - }) - - it('should be an array if conflict keys', () => { - expect(serializer.deserialize([ - ['a', 1], - ['a', 2], - ])).toEqual({ a: [1, 2] }) - - expect(serializer.deserialize([ - ['0', 1], - ['0', 2], - ])).toEqual([[1, 2]]) - - expect(serializer.deserialize([ - ['a', 1], - ['a', 2], - ['a[2]', 3], - ])).toEqual({ a: [1, 2, 3] }) - - expect(serializer.deserialize([ - ['0', 1], - ['0', 2], - ['0[user]', 3], - ])).toEqual([{ - 0: 1, - 1: 2, - user: 3, - }]) - }) - - it('should be an array if [] conflict keys', () => { - expect(serializer.deserialize([ - ['users[]', 1], - ['users[]', 2], - ['users[name]', 3], - ])).toEqual({ - users: { - '': [1, 2], - 'name': 3, - }, - }) - - expect(serializer.deserialize([ - ['users[]', 1], - ['users[]', 2], - ['users[name][]', 3], - ['users[name][]', 4], - ['users[]', 5], - ['users[name][]', 6], - ])).toEqual({ - users: { - '': [1, 2, 5], - 'name': [3, 4, 6], - }, - }) - - expect(serializer.deserialize([ - ['a[]', 1], - ['a[b][]', 2], - ['a[b][c][]', 3], - ['a[]', 4], - ['a[b][]', 5], - ['a[b][c][]', 6], - ])).toEqual({ - a: { - '': [1, 4], - 'b': { - '': [2, 5], - 'c': [3, 6], - }, - }, - }) - }) - - it('can deserialize mixed nested structures', () => { - expect(serializer.deserialize([ - ['a[b]', 1], - ['a[c][0]', 2], - ['a[c][1][d]', 3], - ['a[c][1][f]', 4], - ])).toEqual({ a: { b: 1, c: [2, { d: 3, f: 4 }] } }) - }) - - it('limits array indices to maxArrayIndex', () => { - expect(serializer.deserialize([ - ['arr[1]', 1], - ['arr[9999]', 2], - ['arr[10000]', 3], - ])).toEqual({ arr: { 1: 1, 9999: 2, 10000: 3 } }) - - expect(serializer.deserialize([ - ['arr[9999]', 3], - ])).toEqual({ arr: (() => { - const arr = [] - arr[9999] = 3 - return arr - })() }) - - expect(serializer.deserialize([ - ['arr[10000]', 3], - ])).toEqual({ arr: { 10000: 3 } }) - - // if not use index, we still can exceed maxArrayIndex - expect(serializer.deserialize([ - ['arr[9999]', 3], - ['arr', 4], - ])).toEqual({ - arr: (() => { - const arr = [] - arr[9999] = 3 - arr[10000] = 4 - return arr - })(), - }) - }) - - it('safety against prototype pollution', () => { - const result = serializer.deserialize([ - ['__proto__[polluted]', '1'], - ['constructor[polluted]', '2'], - ['nested[__proto__][polluted]', '3'], - ['nested[constructor][polluted]', '4'], - ]) as any - - // eslint-disable-next-line no-proto, no-restricted-properties - expect(result.__proto__).toEqual({ polluted: '1' }) - expect(result.constructor).toEqual({ polluted: '2' }) - // eslint-disable-next-line no-proto, no-restricted-properties - expect(result.nested.__proto__).toEqual({ polluted: '3' }) - expect(result.nested.constructor).toEqual({ polluted: '4' }) - - // if `.polluted` is not handled correctly, access may fall back to `.__proto__.polluted` and cause pollution - expect(result.polluted).toBeUndefined() - expect(result.nested.polluted).toBeUndefined() - - // does not affect the global object prototype - // eslint-disable-next-line no-proto, no-restricted-properties - expect(({} as any).__proto__.polluted).toBeUndefined() - expect(({} as any).constructor.polluted).toBeUndefined() - expect(({} as any).polluted).toBeUndefined() - }) - }) - - it.each([ - [{ }], - [{ a: 1, b: 2, c: [1, 2, { a: 1, b: 2 }, new Date(), new Blob([]), new Set([1, 2]), new Map([[1, 2]])] }], - ])('.serialize + .deserialize', (value) => { - expect(serializer.deserialize(serializer.serialize(value))).toEqual(value) - }) -}) diff --git a/packages/openapi-client/src/adapters/standard/bracket-notation.ts b/packages/openapi-client/src/adapters/standard/bracket-notation.ts deleted file mode 100644 index c96c1b247..000000000 --- a/packages/openapi-client/src/adapters/standard/bracket-notation.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { Segment } from '@orpc/shared' -import { isObject, NullProtoObj } from '@orpc/shared' - -export type StandardBracketNotationSerialized = [string, unknown][] - -export interface StandardBracketNotationSerializerOptions { - /** - * Maximum allowed array index for bracket notation deserialization. - * - * This helps protect against memory exhaustion attacks where malicious input - * uses extremely large array indices (e.g., `?arr[4294967296]=value`). - * - * While bracket notation creates sparse arrays that handle large indices efficiently, - * downstream code might inadvertently convert these sparse arrays to dense arrays, - * potentially creating millions of undefined elements and causing memory issues. - * - * @note Only applies to deserialization. - * @default 9_999 (array with 10,000 elements) - */ - maxBracketNotationArrayIndex?: number -} - -export class StandardBracketNotationSerializer { - private readonly maxArrayIndex: number - - constructor(options: StandardBracketNotationSerializerOptions = {}) { - this.maxArrayIndex = options.maxBracketNotationArrayIndex ?? 9_999 - } - - serialize(data: unknown, segments: Segment[] = [], result: StandardBracketNotationSerialized = []): StandardBracketNotationSerialized { - if (Array.isArray(data)) { - data.forEach((item, i) => { - this.serialize(item, [...segments, i], result) - }) - } - - else if (isObject(data)) { - for (const key in data) { - this.serialize(data[key], [...segments, key], result) - } - } - - else { - result.push([this.stringifyPath(segments), data]) - } - - return result - } - - deserialize(serialized: StandardBracketNotationSerialized): Record | unknown[] { - if (serialized.length === 0) { - return {} - } - - const arrayPushStyles = new WeakSet() - const ref: { value: Record | unknown[] } = { value: [] } - - for (const [path, value] of serialized) { - const segments = this.parsePath(path) - - let currentRef: any = ref - let nextSegment: string = 'value' - - segments.forEach((segment, i) => { - if (!Array.isArray(currentRef[nextSegment]) && !isObject(currentRef[nextSegment])) { - currentRef[nextSegment] = [] - } - - if (i !== segments.length - 1) { - if (Array.isArray(currentRef[nextSegment]) && !isValidArrayIndex(segment, this.maxArrayIndex)) { - if (arrayPushStyles.has(currentRef[nextSegment])) { - arrayPushStyles.delete(currentRef[nextSegment]) - currentRef[nextSegment] = pushStyleArrayToObject(currentRef[nextSegment]) - } - else { - currentRef[nextSegment] = arrayToObject(currentRef[nextSegment]) - } - } - } - else { - if (Array.isArray(currentRef[nextSegment])) { - if (segment === '') { - if (currentRef[nextSegment].length && !arrayPushStyles.has(currentRef[nextSegment])) { - currentRef[nextSegment] = arrayToObject(currentRef[nextSegment]) - } - } - else { - if (arrayPushStyles.has(currentRef[nextSegment])) { - arrayPushStyles.delete(currentRef[nextSegment]) - currentRef[nextSegment] = pushStyleArrayToObject(currentRef[nextSegment]) - } - - else if (!isValidArrayIndex(segment, this.maxArrayIndex)) { - currentRef[nextSegment] = arrayToObject(currentRef[nextSegment]) - } - } - } - } - - currentRef = currentRef[nextSegment] - nextSegment = segment - }) - - if (Array.isArray(currentRef) && nextSegment === '') { - arrayPushStyles.add(currentRef) - currentRef.push(value) - } - else if (nextSegment in currentRef) { - if (Array.isArray(currentRef[nextSegment])) { - currentRef[nextSegment].push(value) - } - else { - currentRef[nextSegment] = [currentRef[nextSegment], value] - } - } - else { - currentRef[nextSegment] = value - } - } - - return ref.value - } - - stringifyPath(segments: readonly Segment[]): string { - return segments - .map((segment) => { - return segment.toString().replace(/[\\[\]]/g, (match) => { - switch (match) { - case '\\': - return '\\\\' - case '[': - return '\\[' - case ']': - return '\\]' - /* v8 ignore next 2 */ - default: - return match - } - }) - }) - .reduce((result, segment, i) => { - if (i === 0) { - return segment - } - - return `${result}[${segment}]` - }, '') - } - - parsePath(path: string): string[] { - const segments: string[] = [] - - let inBrackets = false - let currentSegment = '' - let backslashCount = 0 - - for (let i = 0; i < path.length; i++) { - const char = path[i]! - const nextChar = path[i + 1] - - if (inBrackets && char === ']' && (nextChar === undefined || nextChar === '[') && backslashCount % 2 === 0) { - if (nextChar === undefined) { - inBrackets = false - } - - segments.push(currentSegment) - currentSegment = '' - i++ - } - - else if (segments.length === 0 && char === '[' && backslashCount % 2 === 0) { - inBrackets = true - segments.push(currentSegment) - currentSegment = '' - } - - else if (char === '\\') { - backslashCount++ - } - - else { - currentSegment += '\\'.repeat(backslashCount / 2) + char - backslashCount = 0 - } - } - - return inBrackets || segments.length === 0 ? [path] : segments - } -} - -function isValidArrayIndex(value: string, maxIndex: number): boolean { - return /^0$|^[1-9]\d*$/.test(value) && Number(value) <= maxIndex -} - -function arrayToObject(array: any[]): Record { - const obj = new NullProtoObj() - - array.forEach((item, i) => { - obj[i] = item - }) - - return obj -} - -function pushStyleArrayToObject(array: any[]): Record { - const obj = new NullProtoObj() - - obj[''] = array.length === 1 ? array[0] : array - - return obj -} diff --git a/packages/openapi-client/src/adapters/standard/index.ts b/packages/openapi-client/src/adapters/standard/index.ts deleted file mode 100644 index 3a225b6bb..000000000 --- a/packages/openapi-client/src/adapters/standard/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './bracket-notation' -export * from './bracket-notation-utils' -export * from './openapi-json-serializer' -export * from './openapi-link' -export * from './openapi-link-codec' -export * from './openapi-serializer' -export * from './utils' diff --git a/packages/openapi-client/src/adapters/standard/openapi-json-serializer.test.ts b/packages/openapi-client/src/adapters/standard/openapi-json-serializer.test.ts deleted file mode 100644 index 132b74a1e..000000000 --- a/packages/openapi-client/src/adapters/standard/openapi-json-serializer.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { StandardOpenAPIJsonSerializer } from './openapi-json-serializer' - -type TestCase = { - data: unknown - expected?: unknown -} - -enum Test { - A = 1, - B = 2, - C = 'C', - D = 'D', -} - -const builtInCases: TestCase[] = [ - { - data: Test.B, - expected: Test.B, - }, - { - data: 'some-string', - expected: 'some-string', - }, - { - data: 123, - expected: 123, - }, - { - data: Number.NaN, - expected: null, - }, - { - data: true, - expected: true, - }, - { - data: false, - expected: false, - }, - { - data: null, - expected: null, - }, - // { - // data: undefined, - // expected: expect.toSatisfy(v => v === null || v === undefined), CANNOT ASSERT UNDEFINED IN OBJECT? - // }, - { - data: new Date('2023-01-01'), - expected: new Date('2023-01-01').toISOString(), - }, - { - data: new Date('Invalid'), - expected: null, - }, - { - data: 99999999999999999999999999999n, - expected: '99999999999999999999999999999', - }, - { - data: /npa|npb/, - expected: '/npa|npb/', - }, - { - data: /uic/gi, - expected: '/uic/gi', - }, - { - data: new URL('https://orpc.dev'), - expected: new URL('https://orpc.dev').href, - }, - { - data: { a: 1, b: 2, c: 3 }, - expected: { a: 1, b: 2, c: 3 }, - }, - { - data: [1, 2, 3], - expected: [1, 2, 3], - }, - { - data: new Map([[1, 2], [3, 4]]), - expected: [[1, 2], [3, 4]], - }, - { - data: new Set([1, 2, 3]), - expected: [1, 2, 3], - }, - { - data: new Blob(['blob'], { type: 'text/plain' }), - expected: expect.toSatisfy((file: any) => { - expect(file).toBeInstanceOf(Blob) - expect(file.type).toBe('text/plain') - expect(file.size).toBe(4) - - return true - }), - }, - { - data: new File(['"name"'], 'file.json', { type: 'application/json' }), - expected: expect.toSatisfy((file: any) => { - expect(file).toBeInstanceOf(File) - expect(file.name).toBe('file.json') - expect(file.type).toBe('application/json') - expect(file.size).toBe(6) - - return true - }), - }, -] - -class Person { - constructor( - public name: string, - public date: Date, - ) { } - - toJSON() { - return { - name: this.name, - date: this.date, - } - } -} - -class Person2 { - constructor( - public name: string, - public data: any, - ) { } - - toJSON() { - return { - name: this.name, - data: this.data, - } - } -} - -const customSupportedDataTypes: TestCase[] = [ - { - data: new Person('Dinh Le', new Date('2023-01-01')), - expected: { name: 'Dinh Le', date: '2023-01-01T00:00:00.000Z' }, - }, - { - data: new Person2('Dinh Le - 2', [{ nested: new Date('2023-01-02') }, /uic/gi]), - expected: { name: 'Dinh Le - 2', data: [{ nested: '2023-01-02T00:00:00.000Z' }, '/uic/gi'] }, - }, - { - data: { value: { toJSON: () => 'hello' } }, - expected: { value: { } }, - }, - { - data: { value: { toJSON: 'hello' } }, - expected: { value: { toJSON: 'hello' } }, - }, -] - -describe.each([ - ...builtInCases, - ...customSupportedDataTypes, -])('serialize %p', ({ data, expected = data }) => { - const serializer = new StandardOpenAPIJsonSerializer({ - customJsonSerializers: [ - { - condition: data => data instanceof Person, - serialize: data => data.toJSON(), - }, - { - condition: data => data instanceof Person2, - serialize: data => data.toJSON(), - }, - ], - }) - - it('flat', () => { - const [json, hasBlob] = serializer.serialize(data) - - expect(json).toEqual(expected) - expect(hasBlob).toBe(data instanceof Blob) - }) - - it('object', () => { - const [json, hasBlob] = serializer.serialize({ value: data }) - - expect(json).toEqual({ value: expected }) - expect(hasBlob).toBe(data instanceof Blob) - }) - - it('array', () => { - const [json, hasBlob] = serializer.serialize([data]) - - expect(json).toEqual([expected]) - expect(hasBlob).toBe(data instanceof Blob) - }) - - it('set', () => { - const [json, hasBlob] = serializer.serialize(new Set([data])) - - expect(json).toEqual([expected]) - expect(hasBlob).toBe(data instanceof Blob) - }) - - it('map', () => { - const [json, hasBlob] = serializer.serialize(new Map([[data, data]])) - - expect(json).toEqual([[expected, expected]]) - expect(hasBlob).toBe(data instanceof Blob) - }) - - it('complex', () => { - const [json, hasBlob] = serializer.serialize({ - 'date': new Date('2023-01-01'), - 'regexp': /uic/gi, - 'url': new URL('https://orpc.dev'), - '!@#$%^^&()[]>?<~_<:"~+!_': data, - 'list': [data], - 'map': new Map([[data, data]]), - 'set': new Set([data]), - 'nested': { - nested: data, - }, - }) - - expect(json).toEqual({ - 'date': new Date('2023-01-01').toISOString(), - 'regexp': (/uic/gi).toString(), - 'url': new URL('https://orpc.dev').href, - '!@#$%^^&()[]>?<~_<:"~+!_': expected, - 'list': [expected], - 'map': [[expected, expected]], - 'set': [expected], - 'nested': { - nested: expected, - }, - }) - - expect(hasBlob).toBe(data instanceof Blob) - }) -}) - -describe('serialize undefined', () => { - const serializer = new StandardOpenAPIJsonSerializer() - - it('in object', () => { - const [json, hasBlob] = serializer.serialize({ value: undefined }) - - expect(json).toEqual({ value: undefined }) - expect(hasBlob).toBe(false) - }) - - it('in array', () => { - const [json, hasBlob] = serializer.serialize([undefined]) - - expect(json).toEqual([null]) - expect(hasBlob).toBe(false) - }) -}) diff --git a/packages/openapi-client/src/adapters/standard/openapi-json-serializer.ts b/packages/openapi-client/src/adapters/standard/openapi-json-serializer.ts deleted file mode 100644 index ae8fefeab..000000000 --- a/packages/openapi-client/src/adapters/standard/openapi-json-serializer.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { isObject } from '@orpc/shared' - -export type StandardOpenAPIJsonSerialized = [json: unknown, hasBlob: boolean] - -export interface StandardOpenAPICustomJsonSerializer { - condition(data: unknown): boolean - serialize(data: any): unknown -} - -export interface StandardOpenAPIJsonSerializerOptions { - customJsonSerializers?: readonly StandardOpenAPICustomJsonSerializer[] -} - -export class StandardOpenAPIJsonSerializer { - private readonly customSerializers: readonly StandardOpenAPICustomJsonSerializer[] - - constructor(options: StandardOpenAPIJsonSerializerOptions = {}) { - this.customSerializers = options.customJsonSerializers ?? [] - } - - serialize(data: unknown, hasBlobRef: { value: boolean } = { value: false }): StandardOpenAPIJsonSerialized { - for (const custom of this.customSerializers) { - if (custom.condition(data)) { - const result = this.serialize(custom.serialize(data), hasBlobRef) - - return result - } - } - - if (data instanceof Blob) { - hasBlobRef.value = true - return [data, hasBlobRef.value] - } - - if (data instanceof Set) { - return this.serialize(Array.from(data), hasBlobRef) - } - - if (data instanceof Map) { - return this.serialize(Array.from(data.entries()), hasBlobRef) - } - - if (Array.isArray(data)) { - const json = data.map(v => v === undefined ? null : this.serialize(v, hasBlobRef)[0]) - return [json, hasBlobRef.value] - } - - if (isObject(data)) { - const json: Record = {} - - for (const k in data) { - /** - * Skip custom toJSON methods to avoid JSON.stringify invoking them, - * which could cause meta and serialized data mismatches during deserialization. - * Instead, rely on custom serializers. - */ - if (k === 'toJSON' && typeof data[k] === 'function') { - continue - } - - json[k] = this.serialize(data[k], hasBlobRef)[0] - } - - return [json, hasBlobRef.value] - } - - if (typeof data === 'bigint' || data instanceof RegExp || data instanceof URL) { - return [data.toString(), hasBlobRef.value] - } - - if (data instanceof Date) { - return [Number.isNaN(data.getTime()) ? null : data.toISOString(), hasBlobRef.value] - } - - if (Number.isNaN(data)) { - return [null, hasBlobRef.value] - } - - return [data, hasBlobRef.value] - } -} diff --git a/packages/openapi-client/src/adapters/standard/openapi-link-codec.test.ts b/packages/openapi-client/src/adapters/standard/openapi-link-codec.test.ts deleted file mode 100644 index 6602b1e00..000000000 --- a/packages/openapi-client/src/adapters/standard/openapi-link-codec.test.ts +++ /dev/null @@ -1,489 +0,0 @@ -import * as ClientModule from '@orpc/client' -import * as ClientStandardModule from '@orpc/client/standard' -import * as StandardServer from '@orpc/standard-server' -import { oc } from '../../../../contract/src/builder' -import { StandardBracketNotationSerializer } from './bracket-notation' -import { StandardOpenAPIJsonSerializer } from './openapi-json-serializer' -import { StandardOpenapiLinkCodec } from './openapi-link-codec' -import { StandardOpenAPISerializer } from './openapi-serializer' - -const ORPCError = ClientModule.ORPCError -const isORPCErrorStatusSpy = vi.spyOn(ClientModule, 'isORPCErrorStatus') -const mergeStandardHeadersSpy = vi.spyOn(StandardServer, 'mergeStandardHeaders') -const getMalformedResponseErrorCodeSpy = vi.spyOn(ClientStandardModule, 'getMalformedResponseErrorCode') - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('standardOpenapiLinkCodecOptions', () => { - const serializer = new StandardOpenAPISerializer(new StandardOpenAPIJsonSerializer(), new StandardBracketNotationSerializer()) - - const serialize = vi.spyOn(serializer, 'serialize') - const deserialize = vi.spyOn(serializer, 'deserialize') - - const signal = AbortSignal.timeout(100) - const date = new Date() - const blob = new Blob(['blob'], { type: 'text/plain' }) - - describe('.encode', () => { - it('throw error if not found procedure', async () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc }, serializer, { - url: 'http://localhost:3000', - }) - - expect(codec.encode(['test'], 'input', { context: {} })).rejects.toThrow('[StandardOpenapiLinkCodec] expect a contract procedure at test') - }) - - it('with lastEventId', async () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc }, serializer, { - url: 'http://localhost:3000', - headers: { 'x-custom': 'value' }, - }) - - const request = await codec.encode(['ping'], 'input', { context: {}, lastEventId: '1' }) - - expect(mergeStandardHeadersSpy).toBeCalledWith({ 'x-custom': 'value' }, { 'last-event-id': '1' }) - expect(request.headers).toBe(mergeStandardHeadersSpy.mock.results[0]!.value) - - expect(request.headers['last-event-id']).toEqual('1') - expect(request.headers['x-custom']).toEqual('value') - }) - - it('support fetch headers', async () => { - const headers = new Headers() - headers.append('cookie', 'a=1') - headers.append('cookie', 'b=2') - headers.append('set-cookie', 'a1=1') - headers.append('set-cookie', 'b1=2') - - const codec = new StandardOpenapiLinkCodec({ ping: oc }, serializer, { - url: 'http://localhost:3000', - headers, - }) - - const request = await codec.encode(['ping'], 'input', { context: {} }) - - expect(request.headers).toEqual({ - 'cookie': 'a=1; b=2', - 'set-cookie': ['a1=1', 'b1=2'], - }) - }) - - describe('inputStructure=compact', () => { - describe('with dynamic params', () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc.route({ path: '/ping/{date}' }) }, serializer, { - url: 'http://localhost:3000', - headers: { 'x-custom': 'value' }, - }) - - it('works', async () => { - const request = await codec.encode(['ping'], { date, a: 1, b: true }, { context: {}, signal }) - - expect(request.method).toEqual('POST') - expect(request.url.toString()).toEqual(`http://localhost:3000/ping/${encodeURIComponent(date.toISOString())}`) - expect(request.body).toBe(serialize.mock.results[1]!.value) - expect(request.signal).toEqual(signal) - expect(request.headers).toEqual({ 'x-custom': 'value' }) - - expect(serialize).toHaveBeenCalledTimes(2) - expect(serialize).toHaveBeenNthCalledWith(1, date) - expect(serialize).toHaveBeenNthCalledWith(2, { a: 1, b: true }) - }) - - it('throw on invalid input', async () => { - await expect(codec.encode(['ping'], 'invalid', { context: {}, signal })).rejects.toThrow('Invalid input') - }) - - it('body=undefined when all field used for dynamic params', async () => { - const request = await codec.encode(['ping'], { date }, { context: {}, signal }) - - expect(request.method).toEqual('POST') - expect(request.url.toString()).toEqual(`http://localhost:3000/ping/${encodeURIComponent(date.toISOString())}`) - expect(request.body).toBeUndefined() - expect(request.signal).toEqual(signal) - expect(request.headers).toEqual({ 'x-custom': 'value' }) - - expect(serialize).toHaveBeenCalledTimes(2) - expect(serialize).toHaveBeenCalledWith(date) - expect(serialize).toHaveBeenCalledWith(undefined) - }) - }) - - it('with method=GET', async () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc.route({ method: 'GET' }) }, serializer, { - url: 'http://localhost:3000', - headers: { 'x-custom-header': 'custom-value' }, - }) - - const request = await codec.encode(['ping'], { date, blob }, { context: {}, signal }) - - expect(request.method).toEqual('GET') - expect(request.url.toString()).toEqual(`http://localhost:3000/ping?date=${encodeURIComponent(date.toISOString())}`) - expect(request.body).toBeUndefined() - expect(request.signal).toEqual(signal) - expect(request.headers).toEqual({ 'x-custom-header': 'custom-value' }) - - expect(serialize).toHaveBeenCalledOnce() - expect(serialize).toHaveBeenCalledWith({ date, blob }, { outputFormat: 'URLSearchParams' }) - }) - - it('with method=POST', async () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc.route({ method: 'POST' }) }, serializer, { - url: 'http://localhost:3000', - headers: { 'x-custom-header': 'custom-value' }, - }) - - const request = await codec.encode(['ping'], { date, blob }, { context: {}, signal }) - - expect(request.method).toEqual('POST') - expect(request.url.toString()).toEqual(`http://localhost:3000/ping`) - expect(request.body).toBe(serialize.mock.results[0]!.value) - expect(request.signal).toEqual(signal) - expect(request.headers).toEqual({ 'x-custom-header': 'custom-value' }) - - expect(serialize).toHaveBeenCalledOnce() - expect(serialize).toHaveBeenCalledWith({ date, blob }) - }) - }) - - describe('inputStructure=detailed', () => { - describe('with dynamic params', () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc.route({ path: '/ping/{date}', inputStructure: 'detailed' }) }, serializer, { - url: 'http://localhost:3000', - headers: { 'x-custom': 'value' }, - }) - - it('works', async () => { - const request = await codec.encode(['ping'], { params: { date } }, { context: {}, signal }) - - expect(request.method).toEqual('POST') - expect(request.url.toString()).toEqual(`http://localhost:3000/ping/${encodeURIComponent(date.toISOString())}`) - expect(request.body).toBeUndefined() - expect(request.signal).toEqual(signal) - expect(request.headers).toEqual({ 'x-custom': 'value' }) - - expect(serialize).toHaveBeenCalledTimes(2) - expect(serialize).toHaveBeenNthCalledWith(1, date) - expect(serialize).toHaveBeenNthCalledWith(2, undefined) - }) - - it('throw on invalid input.params', async () => { - await expect(codec.encode(['ping'], { params: 'invalid' }, { context: {}, signal })).rejects.toThrow('Invalid input.params shape for "detailed" structure when has dynamic params at ping.') - }) - }) - - it('query', async () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc.route({ inputStructure: 'detailed' }) }, serializer, { - url: 'http://localhost:3000', - }) - - const request = await codec.encode(['ping'], { query: { b: true, date } }, { context: {}, signal }) - - expect(request.method).toEqual('POST') - expect(request.url.toString()).toEqual(`http://localhost:3000/ping?b=true&date=${encodeURIComponent(date.toISOString())}`) - expect(request.body).toBeUndefined() - expect(request.signal).toEqual(signal) - - expect(serialize).toHaveBeenCalledTimes(2) - expect(serialize).toHaveBeenNthCalledWith(1, { b: true, date }, { outputFormat: 'URLSearchParams' }) - expect(serialize).toHaveBeenNthCalledWith(2, undefined) - }) - - describe('headers', async () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc.route({ inputStructure: 'detailed' }) }, serializer, { - url: 'http://localhost:3000', - headers: { 'x-custom': 'value' }, - }) - - it('works', async () => { - const request = await codec.encode(['ping'], { headers: { a: '1', b: 'true' } }, { context: {}, signal }) - - expect(request.method).toEqual('POST') - expect(request.url.toString()).toEqual(`http://localhost:3000/ping`) - expect(request.body).toBeUndefined() - expect(request.signal).toEqual(signal) - expect(request.headers).toEqual({ 'x-custom': 'value', 'a': '1', 'b': 'true' }) - - expect(serialize).toHaveBeenCalledTimes(1) - expect(serialize).toHaveBeenCalledWith(undefined) - - expect(mergeStandardHeadersSpy).toHaveBeenCalledTimes(1) - expect(mergeStandardHeadersSpy).toHaveBeenCalledWith({ a: '1', b: 'true' }, { 'x-custom': 'value' }) - }) - - it('throw if input.headers is not an object', async () => { - await expect(codec.encode(['ping'], { headers: 'invalid' }, { context: {}, signal })).rejects.toThrow('Invalid input.headers shape for "detailed" structure at ping.') - }) - }) - - it('body', async () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc.route({ inputStructure: 'detailed' }) }, serializer, { - url: 'http://localhost:3000', - headers: { 'x-custom': 'value' }, - }) - - const request = await codec.encode(['ping'], { body: { a: 1, b: true, blob } }, { context: {}, signal }) - - expect(request.method).toEqual('POST') - expect(request.url.toString()).toEqual(`http://localhost:3000/ping`) - expect(request.body).toBe(serialize.mock.results[0]!.value) - expect(request.signal).toEqual(signal) - expect(request.headers).toEqual({ 'x-custom': 'value' }) - - expect(serialize).toHaveBeenCalledTimes(1) - expect(serialize).toHaveBeenNthCalledWith(1, { a: 1, b: true, blob }) - }) - - it('with method=GET', async () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc.route({ inputStructure: 'detailed', method: 'GET' }) }, serializer, { - url: 'http://localhost:3000', - headers: { 'x-custom': 'value' }, - }) - - const request = await codec.encode(['ping'], { query: { query: true }, headers: { 'x-orpc': 'value' }, body: { blob } }, { context: {}, signal }) - - expect(request.method).toEqual('GET') - expect(request.url.toString()).toEqual(`http://localhost:3000/ping?query=true`) - expect(request.body).toBe(undefined) - expect(request.signal).toEqual(signal) - expect(request.headers).toBe(mergeStandardHeadersSpy.mock.results[0]!.value) - - expect(serialize).toHaveBeenCalledTimes(1) - expect(serialize).toHaveBeenCalledWith({ query: true }, { outputFormat: 'URLSearchParams' }) - - expect(mergeStandardHeadersSpy).toHaveBeenCalledTimes(1) - expect(mergeStandardHeadersSpy).toHaveBeenCalledWith({ 'x-orpc': 'value' }, { 'x-custom': 'value' }) - }) - - it('with method=POST', async () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc.route({ inputStructure: 'detailed', method: 'POST' }) }, serializer, { - url: 'http://localhost:3000', - headers: { 'x-custom': 'value' }, - }) - - const request = await codec.encode(['ping'], { query: { query: true }, headers: { 'x-orpc': 'value' }, body: { blob } }, { context: {}, signal }) - - expect(request.method).toEqual('POST') - expect(request.url.toString()).toEqual(`http://localhost:3000/ping?query=true`) - expect(request.body).toBe(serialize.mock.results[1]!.value) - expect(request.signal).toEqual(signal) - expect(request.headers).toBe(mergeStandardHeadersSpy.mock.results[0]!.value) - - expect(serialize).toHaveBeenCalledTimes(2) - expect(serialize).toHaveBeenNthCalledWith(1, { query: true }, { outputFormat: 'URLSearchParams' }) - expect(serialize).toHaveBeenNthCalledWith(2, { blob }) - - expect(mergeStandardHeadersSpy).toHaveBeenCalledTimes(1) - expect(mergeStandardHeadersSpy).toHaveBeenCalledWith({ 'x-orpc': 'value' }, { 'x-custom': 'value' }) - }) - - it('throw on invalid input shape', async () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc.route({ inputStructure: 'detailed' }) }, serializer, { - url: 'http://localhost:3000', - }) - - await expect(codec.encode(['ping'], 'invalid', { context: {}, signal })).rejects.toThrow('Invalid input') - }) - }) - - describe('base url', () => { - it('works with /prefix', async () => { - const codec = new StandardOpenapiLinkCodec({ test: oc.route({ path: '/test', method: 'GET' }) }, serializer, { - url: 'http://localhost:3000/prefix', - }) - - const request = await codec.encode(['test'], { value: '123' }, { context: {} }) - - expect(request.url.toString()).toEqual('http://localhost:3000/prefix/test?value=123') - }) - - it('works with /prefix/', async () => { - const codec = new StandardOpenapiLinkCodec({ test: oc.route({ path: '/test', method: 'GET' }) }, serializer, { - url: 'http://localhost:3000/prefix/', - }) - - const request = await codec.encode(['test'], { value: '123' }, { context: {} }) - - expect(request.url.toString()).toEqual('http://localhost:3000/prefix/test?value=123') - }) - - it('works with /prefix/?a=5', async () => { - const codec = new StandardOpenapiLinkCodec({ test: oc.route({ path: '/test', method: 'GET' }) }, serializer, { - url: 'http://localhost:3000/prefix/?a=5', - }) - - const request = await codec.encode(['test'], { value: '123' }, { context: {} }) - - expect(request.url.toString()).toEqual('http://localhost:3000/prefix/test?a=5&value=123') - }) - }) - }) - - describe('.decode', () => { - const form = new FormData() - - it('outputStructure=compact', async () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc.route({ outputStructure: 'compact' }) }, serializer, { - url: 'http://localhost:3000', - }) - - const output = await codec.decode({ - headers: { 'x-custom': 'value' }, - body: async () => form, - status: 201, - }, { context: {}, signal }, ['ping']) - - expect(output).toBe(deserialize.mock.results[0]!.value) - - expect(deserialize).toHaveBeenCalledTimes(1) - expect(deserialize).toHaveBeenCalledWith(form) - - expect(isORPCErrorStatusSpy).toHaveBeenCalledTimes(1) - expect(isORPCErrorStatusSpy).toHaveBeenCalledWith(201) - }) - - it('outputStructure=detailed', async () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc.route({ outputStructure: 'detailed' }) }, serializer, { - url: 'http://localhost:3000', - }) - - const output = await codec.decode({ - headers: { 'x-custom': 'value' }, - body: async () => form, - status: 201, - }, { context: {}, signal }, ['ping']) - - expect(output).toEqual({ - status: 201, - headers: { 'x-custom': 'value' }, - body: deserialize.mock.results[0]!.value, - }) - - expect((output as any).body).toBe(deserialize.mock.results[0]!.value) - - expect(deserialize).toHaveBeenCalledTimes(1) - expect(deserialize).toHaveBeenCalledWith(form) - - expect(isORPCErrorStatusSpy).toHaveBeenCalledTimes(1) - expect(isORPCErrorStatusSpy).toHaveBeenCalledWith(201) - }) - - it('deserialize error', async () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc }, serializer, { - url: 'http://localhost:3000', - }) - - await expect(codec.decode({ - headers: { 'x-custom': 'value' }, - body: async () => new ORPCError('BAD_GATEWAY', { status: 501, message: 'message', data: 'data' }).toJSON(), - status: 501, - }, { context: {}, signal }, ['ping'])).rejects.toSatisfy((error: any) => { - expect(error).toBeInstanceOf(ORPCError) - expect(error.code).toEqual('BAD_GATEWAY') - expect(error.status).toBe(501) - expect(error.message).toBe('message') - expect(error.data).toBe('data') - - return true - }) - - getMalformedResponseErrorCodeSpy.mockReturnValueOnce('__MOCKED_CODE__') - - await expect(codec.decode({ - headers: { 'x-custom': 'value' }, - body: async () => ({ something: 'data' }), - status: 409, - }, { context: {}, signal }, ['ping'])).rejects.toSatisfy((error: any) => { - expect(error).toBeInstanceOf(ORPCError) - expect(error.defined).toBe(false) - expect(error.code).toEqual('__MOCKED_CODE__') - expect(error.status).toBe(409) - expect(error.data).toEqual({ - body: { - something: 'data', - }, - headers: { - 'x-custom': 'value', - }, - status: 409, - }) - - return true - }) - - expect(isORPCErrorStatusSpy).toHaveBeenCalledTimes(2) - expect(isORPCErrorStatusSpy).toHaveBeenCalledWith(501) - expect(isORPCErrorStatusSpy).toHaveBeenCalledWith(409) - - expect(getMalformedResponseErrorCodeSpy).toHaveBeenCalledTimes(1) - expect(getMalformedResponseErrorCodeSpy).toHaveBeenCalledWith(409) - }) - - it('throw if not found a procedure', async () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc }, serializer, { - url: 'http://localhost:3000', - }) - - await expect(codec.decode({ - headers: { 'x-custom': 'value' }, - body: async () => form, - status: 201, - }, { context: {}, signal }, ['not_found'])).rejects.toThrow('[StandardOpenapiLinkCodec] expect a contract procedure at not_found') - }) - - it('throw if cannot parse response body', async () => { - const codec = new StandardOpenapiLinkCodec({ ping: oc }, serializer, { - url: 'http://localhost:3000', - }) - - await expect(codec.decode({ - headers: { 'x-custom': 'value' }, - body: async () => { throw new Error('Invalid response body') }, - status: 201, - }, { context: {}, signal }, ['ping'])).rejects.toThrow('Cannot parse response body, please check the response body and content-type.') - }) - - it('throw if deserialization fails', async () => { - deserialize.mockImplementationOnce(() => { - throw new Error('Cannot parse response body') - }) - - const codec = new StandardOpenapiLinkCodec({ ping: oc }, serializer, { - url: 'http://localhost:3000', - }) - - await expect(codec.decode({ - headers: { 'x-custom': 'value' }, - body: async () => form, - status: 201, - }, { context: {}, signal }, ['ping'])).rejects.toThrow('Invalid OpenAPI response format.') - }) - - it('customErrorResponseBodyDecoder', async () => { - const error = new ORPCError('TEST') - let time = 1 - const customErrorResponseBodyDecoder = vi.fn(() => { - if (time++ === 2) { - return null // fallback to default - } - return error - }) - - const codec = new StandardOpenapiLinkCodec({ ping: oc }, serializer, { - url: 'http://localhost:3000', - customErrorResponseBodyDecoder, - }) - - const response1 = { headers: { 'x-custom': 'value' }, body: async () => 'body', status: 400 } - await expect(codec.decode(response1, { context: {} }, ['ping'])).rejects.toSatisfy(e => e === error) - - const response2 = { headers: { 'x-custom': 'value2' }, body: async () => 'body2', status: 405 } - await expect(codec.decode(response2, { context: {} }, ['ping'])).rejects.toSatisfy(e => e.status === 405) // default behavior - - expect(customErrorResponseBodyDecoder).toHaveBeenCalledTimes(2) - expect(customErrorResponseBodyDecoder).toHaveBeenCalledWith(deserialize.mock.results[0]!.value, response1) - expect(customErrorResponseBodyDecoder).toHaveBeenCalledWith(deserialize.mock.results[1]!.value, response2) - }) - }) -}) diff --git a/packages/openapi-client/src/adapters/standard/openapi-link-codec.ts b/packages/openapi-client/src/adapters/standard/openapi-link-codec.ts deleted file mode 100644 index 4276141c5..000000000 --- a/packages/openapi-client/src/adapters/standard/openapi-link-codec.ts +++ /dev/null @@ -1,265 +0,0 @@ -import type { ClientContext, ClientOptions, HTTPPath } from '@orpc/client' -import type { StandardLinkCodec } from '@orpc/client/standard' -import type { AnyContractProcedure, AnyContractRouter } from '@orpc/contract' -import type { Promisable, Value } from '@orpc/shared' -import type { StandardHeaders, StandardLazyResponse, StandardRequest, StandardResponse } from '@orpc/standard-server' -import type { StandardOpenAPISerializer } from './openapi-serializer' -import { createORPCErrorFromJson, isORPCErrorJson, isORPCErrorStatus } from '@orpc/client' -import { getMalformedResponseErrorCode, toHttpPath, toStandardHeaders } from '@orpc/client/standard' -import { fallbackContractConfig, isContractProcedure, ORPCError } from '@orpc/contract' -import { get, isObject, value } from '@orpc/shared' -import { mergeStandardHeaders } from '@orpc/standard-server' -import { getDynamicParams, standardizeHTTPPath } from './utils' - -export interface StandardOpenapiLinkCodecOptions { - /** - * Base url for all requests. - */ - url: Value, [ - options: ClientOptions, - path: readonly string[], - input: unknown, - ]> - - /** - * Inject headers to the request. - */ - headers?: Value, [ - options: ClientOptions, - path: readonly string[], - input: unknown, - ]> - - /** - * Customize how a response body is decoded into an ORPC error. - * Useful when the default decoder cannot fully interpret - * your server's error format. - * - * @remarks - * - Return `null | undefined` to fallback to default behavior. - */ - customErrorResponseBodyDecoder?: (deserializedBody: unknown, response: StandardLazyResponse) => ORPCError | null | undefined -} - -export class StandardOpenapiLinkCodec implements StandardLinkCodec { - private readonly baseUrl: Exclude['url'], undefined> - private readonly headers: Exclude['headers'], undefined> - private readonly customErrorResponseBodyDecoder: StandardOpenapiLinkCodecOptions['customErrorResponseBodyDecoder'] - - constructor( - private readonly contract: AnyContractRouter, - private readonly serializer: StandardOpenAPISerializer, - options: StandardOpenapiLinkCodecOptions, - ) { - this.baseUrl = options.url - this.headers = options.headers ?? {} - this.customErrorResponseBodyDecoder = options.customErrorResponseBodyDecoder - } - - async encode(path: readonly string[], input: unknown, options: ClientOptions): Promise { - let headers = toStandardHeaders(await value(this.headers, options, path, input)) - if (options.lastEventId !== undefined) { - headers = mergeStandardHeaders(headers, { 'last-event-id': options.lastEventId }) - } - - const baseUrl = await value(this.baseUrl, options, path, input) - const procedure = get(this.contract, path) - - if (!isContractProcedure(procedure)) { - throw new Error(`[StandardOpenapiLinkCodec] expect a contract procedure at ${path.join('.')}`) - } - - const inputStructure = fallbackContractConfig('defaultInputStructure', procedure['~orpc'].route.inputStructure) - - return inputStructure === 'compact' - ? this.#encodeCompact(procedure, path, input, options, baseUrl, headers) - : this.#encodeDetailed(procedure, path, input, options, baseUrl, headers) - } - - #encodeCompact( - procedure: AnyContractProcedure, - path: readonly string[], - input: unknown, - options: ClientOptions, - baseUrl: string | URL, - headers: StandardHeaders, - ): StandardRequest { - let httpPath = standardizeHTTPPath(procedure['~orpc'].route.path ?? toHttpPath(path)) - let httpBody = input - - const dynamicParams = getDynamicParams(httpPath) - - if (dynamicParams?.length) { - if (!isObject(input)) { - throw new TypeError(`[StandardOpenapiLinkCodec] Invalid input shape for "compact" structure when has dynamic params at ${path.join('.')}.`) - } - - const body = { ...input } - - for (const param of dynamicParams) { - const value = input[param.name] - httpPath = httpPath.replace(param.raw, `/${encodeURIComponent(`${this.serializer.serialize(value)}`)}`) as HTTPPath - delete body[param.name] - } - - httpBody = Object.keys(body).length ? body : undefined - } - - const method = fallbackContractConfig('defaultMethod', procedure['~orpc'].route.method) - const url = new URL(baseUrl) - url.pathname = `${url.pathname.replace(/\/$/, '')}${httpPath}` - - if (method === 'GET') { - const serialized = this.serializer.serialize(httpBody, { outputFormat: 'URLSearchParams' }) as URLSearchParams - - for (const [key, value] of serialized) { - url.searchParams.append(key, value) - } - - return { - url, - method, - headers, - body: undefined, - signal: options.signal, - } - } - - return { - url, - method, - headers, - body: this.serializer.serialize(httpBody), - signal: options.signal, - } - } - - #encodeDetailed( - procedure: AnyContractProcedure, - path: readonly string[], - input: unknown, - options: ClientOptions, - baseUrl: string | URL, - headers: StandardHeaders, - ): StandardRequest { - let httpPath = standardizeHTTPPath(procedure['~orpc'].route.path ?? toHttpPath(path)) - const dynamicParams = getDynamicParams(httpPath) - - if (!isObject(input) && input !== undefined) { - throw new TypeError(`[StandardOpenapiLinkCodec] Invalid input shape for "detailed" structure at ${path.join('.')}.`) - } - - if (dynamicParams?.length) { - if (!isObject(input?.params)) { - throw new TypeError(`[StandardOpenapiLinkCodec] Invalid input.params shape for "detailed" structure when has dynamic params at ${path.join('.')}.`) - } - - for (const param of dynamicParams) { - const value = input.params[param.name] - httpPath = httpPath.replace(param.raw, `/${encodeURIComponent(`${this.serializer.serialize(value)}`)}`) as HTTPPath - } - } - - let mergedHeaders = headers - - if (input?.headers !== undefined) { - if (!isObject(input.headers)) { - throw new TypeError(`[StandardOpenapiLinkCodec] Invalid input.headers shape for "detailed" structure at ${path.join('.')}.`) - } - - mergedHeaders = mergeStandardHeaders(input.headers as StandardHeaders, headers) - } - - const method = fallbackContractConfig('defaultMethod', procedure['~orpc'].route.method) - const url = new URL(baseUrl) - url.pathname = `${url.pathname.replace(/\/$/, '')}${httpPath}` - - if (input?.query !== undefined) { - const query = this.serializer.serialize(input.query, { outputFormat: 'URLSearchParams' }) as URLSearchParams - - for (const [key, value] of query) { - url.searchParams.append(key, value) - } - } - - if (method === 'GET') { - return { - url, - method, - headers: mergedHeaders, - body: undefined, - signal: options.signal, - } - } - - return { - url, - method, - headers: mergedHeaders, - body: this.serializer.serialize(input?.body), - signal: options.signal, - } - } - - async decode(response: StandardLazyResponse, _options: ClientOptions, path: readonly string[]): Promise { - const isOk = !isORPCErrorStatus(response.status) - - const deserialized = await (async () => { - let isBodyOk = false - - try { - const body = await response.body() - - isBodyOk = true - - return this.serializer.deserialize(body) - } - catch (error) { - if (!isBodyOk) { - throw new Error('Cannot parse response body, please check the response body and content-type.', { - cause: error, - }) - } - - throw new Error('Invalid OpenAPI response format.', { - cause: error, - }) - } - })() - - if (!isOk) { - const error = this.customErrorResponseBodyDecoder?.(deserialized, response) - - if (error !== null && error !== undefined) { - throw error - } - - if (isORPCErrorJson(deserialized)) { - throw createORPCErrorFromJson(deserialized) - } - - throw new ORPCError(getMalformedResponseErrorCode(response.status), { - status: response.status, - data: { ...response, body: deserialized }, - }) - } - - const procedure = get(this.contract, path) - - if (!isContractProcedure(procedure)) { - throw new Error(`[StandardOpenapiLinkCodec] expect a contract procedure at ${path.join('.')}`) - } - - const outputStructure = fallbackContractConfig('defaultOutputStructure', procedure['~orpc'].route.outputStructure) - - if (outputStructure === 'compact') { - return deserialized - } - - return { - status: response.status, - headers: response.headers, - body: deserialized, - } - } -} diff --git a/packages/openapi-client/src/adapters/standard/openapi-link.test.ts b/packages/openapi-client/src/adapters/standard/openapi-link.test.ts deleted file mode 100644 index b9c1e928d..000000000 --- a/packages/openapi-client/src/adapters/standard/openapi-link.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { createORPCClient } from '@orpc/client' -import { os } from '@orpc/server' -import { StandardOpenAPIHandler } from '../../../../openapi/src/adapters/standard/openapi-handler' -import { StandardOpenAPILink } from './openapi-link' - -describe('standardOpenAPILink', () => { - const date = new Date() - const blob = new Blob(['hello'], { type: 'text/plain' }) - - const router = { - GET: os.route({ method: 'GET', path: '/ping/{pong}' }).handler(({ input }) => input), - POST: os.handler(({ input }) => input), - } - - const handler = new StandardOpenAPIHandler(router, {}) - - const link = new StandardOpenAPILink(router, { - async call(request, options, path, input) { - const { response } = await handler.handle({ ...request, body: () => Promise.resolve(request.body) }, { - context: {}, - prefix: '/api', - }) - - if (!response) { - throw new Error('No response') - } - - return { ...response, body: () => Promise.resolve(response.body) } - }, - }, { - url: 'http://localhost:3000/api', - }) - - const client = createORPCClient(link) as any - - it('method: GET', async () => { - expect(await client.GET({ - pong: 'pong', - a: 1, - b: 2, - nested: { - date, - arr: [3, date], - }, - })).toEqual({ - pong: 'pong', - a: '1', - b: '2', - nested: { - date: date.toISOString(), - arr: ['3', date.toISOString()], - }, - }) - }) - - it('method: POST', async () => { - expect(await client.POST({ - a: 1, - b: 2, - nested: { - date, - arr: [3, date], - }, - })).toEqual({ - a: 1, - b: 2, - nested: { - date: date.toISOString(), - arr: [3, date.toISOString()], - }, - }) - }) - - it('method: POST with blob', async () => { - expect(await client.POST({ - a: 1, - b: 2, - nested: { - date, - arr: [3, date], - }, - blob, - })).toEqual({ - a: '1', - b: '2', - nested: { - date: date.toISOString(), - arr: ['3', date.toISOString()], - }, - blob: expect.any(File), - }) - }) - - it('method: POST with blob in root', async () => { - expect(await client.POST({ - blob, - })).toEqual({ - blob: expect.any(File), - }) - }) -}) diff --git a/packages/openapi-client/src/adapters/standard/openapi-link.ts b/packages/openapi-client/src/adapters/standard/openapi-link.ts deleted file mode 100644 index 20715f349..000000000 --- a/packages/openapi-client/src/adapters/standard/openapi-link.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ClientContext } from '@orpc/client' -import type { StandardLinkClient, StandardLinkOptions } from '@orpc/client/standard' -import type { AnyContractRouter } from '@orpc/contract' -import type { StandardOpenAPIJsonSerializerOptions } from './openapi-json-serializer' -import type { StandardOpenapiLinkCodecOptions } from './openapi-link-codec' -import { StandardLink } from '@orpc/client/standard' -import { StandardBracketNotationSerializer } from './bracket-notation' -import { StandardOpenAPIJsonSerializer } from './openapi-json-serializer' -import { StandardOpenapiLinkCodec } from './openapi-link-codec' -import { StandardOpenAPISerializer } from './openapi-serializer' - -export interface StandardOpenAPILinkOptions - extends StandardLinkOptions, StandardOpenapiLinkCodecOptions, StandardOpenAPIJsonSerializerOptions {} - -export class StandardOpenAPILink extends StandardLink { - constructor(contract: AnyContractRouter, linkClient: StandardLinkClient, options: StandardOpenAPILinkOptions) { - const jsonSerializer = new StandardOpenAPIJsonSerializer(options) - // Server response is trusted, so we can use the maximum possible array index. - const bracketNotationSerializer = new StandardBracketNotationSerializer({ maxBracketNotationArrayIndex: 4_294_967_294 }) - const serializer = new StandardOpenAPISerializer(jsonSerializer, bracketNotationSerializer) - const linkCodec = new StandardOpenapiLinkCodec(contract, serializer, options) - - super(linkCodec, linkClient, options) - } -} diff --git a/packages/openapi-client/src/adapters/standard/openapi-serializer.test.ts b/packages/openapi-client/src/adapters/standard/openapi-serializer.test.ts deleted file mode 100644 index b302ae2d5..000000000 --- a/packages/openapi-client/src/adapters/standard/openapi-serializer.test.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { ORPCError } from '@orpc/contract' -import { isObject } from '@orpc/shared' -import { ErrorEvent, getEventMeta, withEventMeta } from '@orpc/standard-server' -import { StandardBracketNotationSerializer } from './bracket-notation' -import { StandardOpenAPIJsonSerializer } from './openapi-json-serializer' -import { StandardOpenAPISerializer } from './openapi-serializer' - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('standardOpenAPIJsonSerializer', () => { - const jsonSerializer = new StandardOpenAPIJsonSerializer() - const serialize = vi.fn(v => jsonSerializer.serialize(v)) - - const openapiSerializer = new StandardOpenAPISerializer({ - serialize, - } as any, new StandardBracketNotationSerializer()) - - describe('.serialize', () => { - it('with undefined', () => { - expect(openapiSerializer.serialize(undefined)).toBeUndefined() - }) - - it('with blob', () => { - const blob = new Blob([]) - expect(openapiSerializer.serialize(blob)).toBe(blob) - }) - - it('with data', () => { - const data = { - date: new Date(), - number: 123, - nested: { - date: new Date(), - }, - } - - expect(openapiSerializer.serialize(data)).toBe( - serialize.mock.results[0]!.value[0], - ) - - expect(serialize).toHaveBeenCalledOnce() - expect(serialize).toHaveBeenCalledWith(data) - }) - - it('with data and blobs', async () => { - const data = { - date: new Date(), - number: 123, - nested: { - date: new Date(), - }, - blob: new Blob(['hello'], { type: 'text/plain' }), - } - - const serialized = openapiSerializer.serialize(data) - - expect(serialized).toBeInstanceOf(FormData) - expect((serialized as any).get('date')).toBe(data.date.toISOString()) - expect((serialized as any).get('number')).toBe(data.number.toString()) - expect((serialized as any).get('nested[date]')).toBe(data.nested.date.toISOString()) - expect((serialized as any).get('blob')).toBeInstanceOf(Blob) - expect((serialized as any).get('blob').type).toBe('text/plain') - expect(await (serialized as any).get('blob').text()).toBe('hello') - - expect(serialize).toHaveBeenCalledOnce() - expect(serialize).toHaveBeenCalledWith(data) - }) - - describe('with event iterator', async () => { - it('on success', async () => { - const date = new Date() - const blob = new Blob(['hi']) - - const serialized = openapiSerializer.serialize((async function* () { - yield 1 - yield withEventMeta({ order: 2, date }, { retry: 1000 }) - return withEventMeta({ order: 3, blob }, { id: '123456' }) - })()) as any - - await expect(serialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toBe(serialize.mock.results[0]!.value[0]) - expect(getEventMeta(value)).toEqual(undefined) - - return true - }) - - expect(serialize).toHaveBeenCalledOnce() - expect(serialize).toHaveBeenCalledWith(1) - serialize.mockClear() - - await expect(serialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual(serialize.mock.results[0]!.value[0]) - expect(getEventMeta(value)).toEqual({ retry: 1000 }) - - return true - }) - - expect(serialize).toHaveBeenCalledOnce() - expect(serialize).toHaveBeenCalledWith({ order: 2, date }) - serialize.mockClear() - - await expect(serialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(true) - expect(value).toEqual(serialize.mock.results[0]!.value[0]) - expect(getEventMeta(value)).toEqual({ id: '123456' }) - - return true - }) - - expect(serialize).toHaveBeenCalledOnce() - expect(serialize).toHaveBeenCalledWith({ order: 3, blob }) - serialize.mockClear() - }) - - it('on error with ORPCError', async () => { - const blob = new Blob(['hi']) - const error = withEventMeta(new ORPCError('BAD_GATEWAY', { data: { order: 3 } }), { id: '123456' }) - - const serialized = openapiSerializer.serialize((async function* () { - yield 1 - yield withEventMeta({ order: 2, blob }, { retry: 1000 }) - throw error - })()) as any - - await expect(serialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toBe(serialize.mock.results[0]!.value[0]) - expect(getEventMeta(value)).toEqual(undefined) - - return true - }) - - expect(serialize).toHaveBeenCalledOnce() - expect(serialize).toHaveBeenCalledWith(1) - serialize.mockClear() - - await expect(serialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual(serialize.mock.results[0]!.value[0]) - expect(getEventMeta(value)).toEqual({ retry: 1000 }) - - return true - }) - - expect(serialize).toHaveBeenCalledOnce() - expect(serialize).toHaveBeenCalledWith({ order: 2, blob }) - serialize.mockClear() - - await expect(serialized.next()).rejects.toSatisfy((e: any) => { - expect(e).toBeInstanceOf(ErrorEvent) - expect(e.data).toEqual(serialize.mock.results[0]!.value[0]) - expect(e.cause).toBe(error) - expect(getEventMeta(e)).toEqual({ id: '123456' }) - - return true - }) - - expect(serialize).toHaveBeenCalledOnce() - expect(serialize).toHaveBeenCalledWith(expect.objectContaining({ code: 'BAD_GATEWAY', data: { order: 3 } })) - serialize.mockClear() - }) - }) - - describe('outputFormat: URLSearchParams', async () => { - it('works', () => { - const data = { - a: 1, - b: true, - nested: { - c: [new Date()], - }, - blob: new Blob(['hi']), - } - - const serialized = openapiSerializer.serialize(data, { outputFormat: 'URLSearchParams' }) as URLSearchParams - - expect(serialized).toBeInstanceOf(URLSearchParams) - expect(serialized.get('a')).toBe('1') - expect(serialized.get('b')).toBe('true') - expect(serialized.get('nested[c][0]')).toBe(data.nested.c[0]!.toISOString()) - expect(serialized.get('nested[blob]')).toBe(null) - - expect(serialize).toHaveBeenCalledOnce() - expect(serialize).toHaveBeenCalledWith(data) - }) - - it('with undefined at root', () => { - const serialized = openapiSerializer.serialize(undefined, { outputFormat: 'URLSearchParams' }) as URLSearchParams - - expect([...serialized.entries()]).toEqual([]) - }) - }) - - it('outputFormat: plain', async () => { - const data = { - a: 1, - b: true, - nested: { - c: [new Date()], - }, - } - - const serialized = openapiSerializer.serialize(data, { outputFormat: 'plain' }) - - expect(serialized).toBe(serialize.mock.results[0]!.value[0]) - - expect(serialize).toHaveBeenCalledOnce() - expect(serialize).toHaveBeenCalledWith(data) - }) - }) - - describe('.deserialize', () => { - it('with undefined', () => { - expect(openapiSerializer.deserialize(undefined)).toBeUndefined() - }) - - it('with blob', () => { - const blob = new Blob([]) - expect(openapiSerializer.deserialize(blob)).toBe(blob) - }) - - it('with data', () => { - const data = { - date: new Date(), - number: 123, - nested: { - date: new Date(), - }, - } - expect(openapiSerializer.deserialize(data)).toBe(data) - }) - - it('with formdata', async () => { - const data = { - date: new Date(), - number: 123, - nested: { - date: new Date(), - }, - blob: new Blob(['hello'], { type: 'text/plain' }), - } - - const serialized = new FormData() - serialized.append('date', data.date.toString()) - serialized.append('number', data.number.toString()) - serialized.append('nested[date]', data.nested.date.toString()) - serialized.append('blob', data.blob) - - const deserialized = openapiSerializer.deserialize(serialized) - - expect(deserialized).toSatisfy(isObject) - expect(deserialized).toEqual({ - date: data.date.toString(), - number: data.number.toString(), - nested: { - date: data.nested.date.toString(), - }, - blob: expect.any(File), - }) - - expect((deserialized as any).blob.name).toBe('blob') - expect((deserialized as any).blob.type).toBe('text/plain') - expect(await (deserialized as any).blob.text()).toBe('hello') - }) - - it('with URLSearchParams', async () => { - const data = { - date: new Date().toString(), - nested: { - date: new Date().toString(), - }, - } - - const serialized = new URLSearchParams() - serialized.append('date', data.date) - serialized.append('nested[date]', data.nested.date) - - const deserialized = openapiSerializer.deserialize(serialized) - - expect(deserialized).toSatisfy(isObject) - expect(deserialized).toEqual(data) - }) - - describe('with event iterator', async () => { - it('on success', async () => { - const date = new Date() - - const serialized = openapiSerializer.deserialize((async function* () { - yield 1 - yield withEventMeta({ order: 2, date }, { retry: 1000 }) - return withEventMeta({ order: 3 }, { id: '123456' }) - })()) as any - - await expect(serialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual(1) - expect(getEventMeta(value)).toEqual(undefined) - - return true - }) - - await expect(serialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual({ order: 2, date }) - expect(getEventMeta(value)).toEqual({ retry: 1000 }) - - return true - }) - - await expect(serialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(true) - expect(value).toEqual({ order: 3 }) - expect(getEventMeta(value)).toEqual({ id: '123456' }) - - return true - }) - }) - - it('on error has valid ORPCError format', async () => { - const date = new Date() - const error = withEventMeta(new ErrorEvent({ - data: new ORPCError('BAD_GATEWAY', { data: { order: 3 } }).toJSON(), - }), { id: '123456' }) - - const serialized = openapiSerializer.deserialize((async function* () { - yield 1 - yield withEventMeta({ order: 2, date }, { retry: 1000 }) - throw error - })()) as any - - await expect(serialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual(1) - expect(getEventMeta(value)).toEqual(undefined) - - return true - }) - - await expect(serialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual({ order: 2, date }) - expect(getEventMeta(value)).toEqual({ retry: 1000 }) - - return true - }) - - await expect(serialized.next()).rejects.toSatisfy((e: any) => { - expect(e).toBeInstanceOf(ORPCError) - expect(e.code).toBe('BAD_GATEWAY') - expect(e.data).toEqual({ order: 3 }) - expect(e.cause).toBe(error) - expect(getEventMeta(e)).toEqual({ id: '123456' }) - - return true - }) - }) - - it('on error has invalid ORPCError format', async () => { - const date = new Date() - const error = withEventMeta(new ErrorEvent({ - data: { order: 3 }, - }), { id: '123456' }) - - const serialized = openapiSerializer.deserialize((async function* () { - yield 1 - yield withEventMeta({ order: 2, date }, { retry: 1000 }) - throw error - })()) as any - - await expect(serialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual(1) - expect(getEventMeta(value)).toEqual(undefined) - - return true - }) - - await expect(serialized.next()).resolves.toSatisfy(({ value, done }) => { - expect(done).toBe(false) - expect(value).toEqual({ order: 2, date }) - expect(getEventMeta(value)).toEqual({ retry: 1000 }) - - return true - }) - - await expect(serialized.next()).rejects.toSatisfy((e: any) => { - expect(e).toBe(error) - - return true - }) - }) - }) - }) -}) diff --git a/packages/openapi-client/src/adapters/standard/openapi-serializer.ts b/packages/openapi-client/src/adapters/standard/openapi-serializer.ts deleted file mode 100644 index 78ed41eac..000000000 --- a/packages/openapi-client/src/adapters/standard/openapi-serializer.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { StandardBracketNotationSerializer } from './bracket-notation' -import type { StandardOpenAPIJsonSerializer } from './openapi-json-serializer' -import { createORPCErrorFromJson, isORPCErrorJson, mapEventIterator, toORPCError } from '@orpc/client' -import { isAsyncIteratorObject } from '@orpc/shared' -import { ErrorEvent } from '@orpc/standard-server' - -export interface StandardOpenAPISerializeOptions { - outputFormat?: 'plain' | 'URLSearchParams' -} - -export class StandardOpenAPISerializer { - constructor( - private readonly jsonSerializer: StandardOpenAPIJsonSerializer, - private readonly bracketNotation: StandardBracketNotationSerializer, - ) { - } - - serialize(data: unknown, options: StandardOpenAPISerializeOptions = {}): unknown { - if (isAsyncIteratorObject(data) && !options.outputFormat) { - return mapEventIterator(data, { - value: async value => this.#serialize(value, { outputFormat: 'plain' }), - error: async (e) => { - return new ErrorEvent({ - data: this.#serialize(toORPCError(e).toJSON(), { outputFormat: 'plain' }), - cause: e, - }) - }, - }) - } - - return this.#serialize(data, options) - } - - #serialize(data: unknown, options: StandardOpenAPISerializeOptions): unknown { - const [json, hasBlob] = this.jsonSerializer.serialize(data) - - if (options.outputFormat === 'plain') { - return json - } - - if (options.outputFormat === 'URLSearchParams') { - const params = new URLSearchParams() - - for (const [path, value] of this.bracketNotation.serialize(json)) { - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - params.append(path, value.toString()) - } - } - - return params - } - - if (json instanceof Blob || json === undefined || !hasBlob) { - return json - } - - const form = new FormData() - - for (const [path, value] of this.bracketNotation.serialize(json)) { - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - form.append(path, value.toString()) - } - else if (value instanceof Blob) { - form.append(path, value) - } - } - - return form - } - - deserialize(data: unknown): unknown { - if (data instanceof URLSearchParams || data instanceof FormData) { - return this.bracketNotation.deserialize(Array.from(data.entries())) - } - - if (isAsyncIteratorObject(data)) { - return mapEventIterator(data, { - value: async value => value, - error: async (e) => { - if (e instanceof ErrorEvent && isORPCErrorJson(e.data)) { - return createORPCErrorFromJson(e.data, { cause: e }) - } - - return e - }, - }) - } - - return data - } -} diff --git a/packages/openapi-client/src/adapters/standard/utils.test.ts b/packages/openapi-client/src/adapters/standard/utils.test.ts deleted file mode 100644 index 9bd50a1e9..000000000 --- a/packages/openapi-client/src/adapters/standard/utils.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { getDynamicParams, standardizeHTTPPath } from './utils' - -it('standardizeHTTPPath', () => { - expect(standardizeHTTPPath('/path')).toBe('/path') - expect(standardizeHTTPPath('/path/')).toBe('/path') - expect(standardizeHTTPPath('/path//to/something')).toBe('/path/to/something') - expect(standardizeHTTPPath('//path//to//something//')).toBe('/path/to/something') -}) - -it('getDynamicParams', () => { - expect(getDynamicParams(undefined)).toBe(undefined) - expect(getDynamicParams('/path')).toBe(undefined) - expect(getDynamicParams('/path/{id}')).toEqual([{ raw: '/{id}', name: 'id' }]) - expect(getDynamicParams('/path/{id}/{name}')).toEqual([{ raw: '/{id}', name: 'id' }, { raw: '/{name}', name: 'name' }]) - expect(getDynamicParams('/path/{name}/{+id}')).toEqual([{ raw: '/{name}', name: 'name' }, { raw: '/{+id}', name: 'id' }]) - expect(getDynamicParams('/path//{+id}//something{+name}//')).toEqual([{ raw: '/{+id}', name: 'id' }]) -}) diff --git a/packages/openapi-client/src/adapters/standard/utils.ts b/packages/openapi-client/src/adapters/standard/utils.ts deleted file mode 100644 index 7d5ffa6e3..000000000 --- a/packages/openapi-client/src/adapters/standard/utils.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { HTTPPath } from '@orpc/client' - -/** - * @internal - */ -export function standardizeHTTPPath(path: HTTPPath): HTTPPath { - return `/${path.replace(/\/{2,}/g, '/').replace(/^\/|\/$/g, '')}` -} - -/** - * @internal - */ -export function getDynamicParams(path: HTTPPath | undefined): { raw: string, name: string }[] | undefined { - return path - ? standardizeHTTPPath(path).match(/\/\{[^}]+\}/g)?.map(v => ({ - raw: v, - name: v.match(/\{\+?([^}]+)\}/)![1]!, - })) - : undefined -} diff --git a/packages/openapi-client/src/helpers/index.test.ts b/packages/openapi-client/src/helpers/index.test.ts deleted file mode 100644 index 764842156..000000000 --- a/packages/openapi-client/src/helpers/index.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -it('exports something', async () => { - expect(await import('./index')).toHaveProperty('getIssueMessage') -}) diff --git a/packages/openapi-client/src/helpers/index.ts b/packages/openapi-client/src/helpers/index.ts deleted file mode 100644 index 7a15b46bd..000000000 --- a/packages/openapi-client/src/helpers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { getIssueMessage, parseFormData } from '../adapters/standard' diff --git a/packages/openapi-client/src/index.ts b/packages/openapi-client/src/index.ts deleted file mode 100644 index c9f6f047d..000000000 --- a/packages/openapi-client/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './types' diff --git a/packages/openapi-client/src/types.ts b/packages/openapi-client/src/types.ts deleted file mode 100644 index 06715ae5b..000000000 --- a/packages/openapi-client/src/types.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Client, NestedClient, ORPCError } from '@orpc/client' - -export type JsonifiedValue - = T extends string ? T - : T extends number ? T - : T extends boolean ? T - : T extends null ? T - : T extends undefined ? T - : T extends Array ? JsonifiedArray - : T extends Record ? { [K in keyof T]: JsonifiedValue } - : T extends Date ? string - : T extends bigint ? string - : T extends File ? File - : T extends Blob ? Blob - : T extends RegExp ? string - : T extends URL ? string - : T extends Map ? JsonifiedArray<[K, V][]> - : T extends Set ? JsonifiedArray - : T extends AsyncIteratorObject ? AsyncIteratorObject, JsonifiedValue> - : unknown - -export type JsonifiedArray> = T extends readonly [] - ? [] - : T extends readonly [infer U, ...infer V] - ? [U extends undefined ? null : JsonifiedValue, ...JsonifiedArray] - : T extends Array - ? Array> - : unknown - -/** - * Convert types that JSON not support to corresponding json types - * - * @see {@link https://orpc.dev/docs/openapi/client/openapi-link OpenAPI Link Docs} - */ -export type JsonifiedClient> - = T extends Client - ? Client, UError extends ORPCError ? ORPCError> : UError> - : { - [K in keyof T]: T[K] extends NestedClient ? JsonifiedClient : T[K]; - } diff --git a/packages/openapi-client/tsconfig.json b/packages/openapi-client/tsconfig.json deleted file mode 100644 index 28b4a2dc4..000000000 --- a/packages/openapi-client/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "../../tsconfig.lib.json", - "references": [ - { "path": "../client" }, - { "path": "../contract" }, - { "path": "../shared" }, - { "path": "../standard-server" } - ], - "include": ["src"], - "exclude": [ - "**/*.test.*", - "**/*.bench.*", - "**/*.test-d.ts", - "**/__tests__/**", - "**/__mocks__/**", - "**/__snapshots__/**" - ] -} diff --git a/packages/openapi/README.md b/packages/openapi/README.md index db0218bb7..14c2ae8e5 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -1,8 +1,4 @@ -
- oRPC logo -
- -

+

oRPC - Typesafe APIs Made Simple 🪄

-

Typesafe APIs Made Simple 🪄

+## Documentation -**oRPC is a powerful combination of RPC and OpenAPI**, makes it easy to build APIs that are end-to-end type-safe and adhere to OpenAPI standards +You can read the documentation [here](https://orpc.dev). ---- +## Packages -## Highlights +**Core** -- **🔗 End-to-End Type Safety**: Ensure type-safe inputs, outputs, and errors from client to server. -- **📘 First-Class OpenAPI**: Built-in support that fully adheres to the OpenAPI standard. -- **📝 Contract-First Development**: Optionally define your API contract before implementation. -- **🔍 First-Class OpenTelemetry**: Seamlessly integrate with OpenTelemetry for observability. -- **⚙️ Framework Integrations**: Seamlessly integrate with TanStack Query (React, Vue, Solid, Svelte, Angular), SWR, Pinia Colada, and more. -- **🚀 Server Actions**: Fully compatible with React Server Actions on Next.js, TanStack Start, and other platforms. -- **🔠 Standard Schema Support**: Works out of the box with Zod, Valibot, ArkType, and other schema validators. -- **🗃️ Native Types**: Supports native types like Date, File, Blob, BigInt, URL, and more. -- **⏱️ Lazy Router**: Enhance cold start times with our lazy routing feature. -- **📡 SSE & Streaming**: Enjoy full type-safe support for SSE and streaming. -- **🌍 Multi-Runtime Support**: Fast and lightweight on Cloudflare, Deno, Bun, Node.js, and beyond. -- **🔌 Extendability**: Easily extend functionality with plugins, middleware, and interceptors. +- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Define type-safe contracts for your API. +- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Implement your API or contract on the server. +- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API from the client with full type safety. +- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Bring OpenAPI compatibility to your APIs. -## Documentation +**Schema validation** -You can find the full documentation [here](https://orpc.dev). +- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): Integrate with [Zod](https://zod.dev/). +- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): Integrate with [Valibot](https://valibot.dev/). +- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): Integrate with [ArkType](https://arktype.io/). -## Packages +**Framework & ecosystem integrations** -- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Build your API contract. -- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build your API or implement API contract. -- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API on the client with type-safety. -- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Generate OpenAPI specs and handle OpenAPI requests. -- [@orpc/otel](https://www.npmjs.com/package/@orpc/otel): [OpenTelemetry](https://opentelemetry.io/) integration for observability. -- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Deeply integrate oRPC with [NestJS](https://nestjs.com/). -- [@orpc/react](https://www.npmjs.com/package/@orpc/react): Utilities for integrating oRPC with React and React Server Actions. -- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): [TanStack Query](https://tanstack.com/query/latest) integration. -- [@orpc/experimental-react-swr](https://www.npmjs.com/package/@orpc/experimental-react-swr): [SWR](https://swr.vercel.app/) integration. -- [@orpc/vue-colada](https://www.npmjs.com/package/@orpc/vue-colada): Integration with [Pinia Colada](https://pinia-colada.esm.dev/). -- [@orpc/hey-api](https://www.npmjs.com/package/@orpc/hey-api): [Hey API](https://heyapi.dev/) integration. -- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): More schemas that [Zod](https://zod.dev/) doesn't support yet. -- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): OpenAPI spec generation from [Valibot](https://valibot.dev/). -- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): OpenAPI spec generation from [ArkType](https://arktype.io/). - -## `@orpc/openapi` - -Generate OpenAPI specs and handle OpenAPI requests. Read the [documentation](https://orpc.dev/docs/openapi/getting-started) for more information. - -```ts -import { createServer } from 'node:http' -import { OpenAPIHandler } from '@orpc/openapi/node' -import { CORSPlugin } from '@orpc/server/plugins' - -const handler = new OpenAPIHandler(router, { - plugins: [new CORSPlugin()] -}) - -const server = createServer(async (req, res) => { - const result = await handler.handle(req, res, { - context: { headers: req.headers } - }) - - if (!result.matched) { - res.statusCode = 404 - res.end('No procedure matched') - } -}) - -server.listen(3000, '127.0.0.1', () => console.log('Listening on 127.0.0.1:3000')) -``` +- [@orpc/next](https://www.npmjs.com/package/@orpc/react): Use oRPC with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). +- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). +- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). + +**Built-in features** + +- [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub. +- [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting. +- [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. + +**Observability** + +- [@orpc/opentelemetry](https://www.npmjs.com/package/@orpc/opentelemetry): Instrument your API with [OpenTelemetry](https://opentelemetry.io/). +- [@orpc/pino](https://www.npmjs.com/package/@orpc/pino): Log with [Pino](https://getpino.io/). +- [@orpc/evlog](https://www.npmjs.com/package/@orpc/evlog): Log with [Evlog](https://evlog.dev/). ## Sponsors -If you find oRPC valuable and would like to support its development, you can do so here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). +Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). Every bit helps! 🚀 ### 🏆 Platinum Sponsor @@ -212,6 +176,13 @@ If you find oRPC valuable and would like to support its development, you can do plancraft

+## 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/openapi/package.json b/packages/openapi/package.json index b6e0cad75..270a4d9e8 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -1,7 +1,7 @@ { "name": "@orpc/openapi", "type": "module", - "version": "1.14.6", + "version": "1.13.4", "license": "MIT", "homepage": "https://orpc.dev", "repository": { @@ -10,16 +10,25 @@ "directory": "packages/openapi" }, "keywords": [ - "orpc" + "orpc", + "openapi" + ], + "sideEffects": [ + "./dist/extensions/route.mjs" ], - "sideEffects": false, "publishConfig": { "exports": { + "./package.json": "./package.json", ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs", "default": "./dist/index.mjs" }, + "./helpers": { + "types": "./dist/helpers/index.d.mts", + "import": "./dist/helpers/index.mjs", + "default": "./dist/helpers/index.mjs" + }, "./plugins": { "types": "./dist/plugins/index.d.mts", "import": "./dist/plugins/index.mjs", @@ -40,48 +49,46 @@ "import": "./dist/adapters/node/index.mjs", "default": "./dist/adapters/node/index.mjs" }, - "./fastify": { - "types": "./dist/adapters/fastify/index.d.mts", - "import": "./dist/adapters/fastify/index.mjs", - "default": "./dist/adapters/fastify/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" + "./extensions/route": { + "types": "./dist/extensions/route.d.mts", + "import": "./dist/extensions/route.mjs", + "default": "./dist/extensions/route.mjs" } } }, "exports": { + "./package.json": "./package.json", ".": "./src/index.ts", + "./helpers": "./src/helpers/index.ts", "./plugins": "./src/plugins/index.ts", "./standard": "./src/adapters/standard/index.ts", "./fetch": "./src/adapters/fetch/index.ts", "./node": "./src/adapters/node/index.ts", - "./fastify": "./src/adapters/fastify/index.ts", - "./aws-lambda": "./src/adapters/aws-lambda/index.ts" + "./extensions/route": "./src/extensions/route.ts" }, "files": [ "dist" ], "scripts": { "build": "unbuild", - "build:watch": "pnpm run build --watch", "type:check": "tsc -b" }, "dependencies": { + "@hey-api/spec-types": "0.0.0-next-20260408030107", "@orpc/client": "workspace:*", "@orpc/contract": "workspace:*", - "@orpc/interop": "workspace:*", - "@orpc/openapi-client": "workspace:*", + "@orpc/json-schema": "workspace:*", "@orpc/server": "workspace:*", "@orpc/shared": "workspace:*", - "@orpc/standard-server": "workspace:*", - "json-schema-typed": "^8.0.2", - "rou3": "^0.7.12" + "@scalar/api-reference": "^1.57.2", + "@standardserver/core": "^0.0.24", + "@standardserver/fetch": "^0.0.24", + "@types/swagger-ui": "^5.32.0", + "rou3": "^0.7.12", + "swagger-ui": "^5.32.6" }, "devDependencies": { - "fastify": "^5.8.3", - "zod": "^4.3.6" + "fastify": "^5.6.2", + "zod": "^4.4.3" } } diff --git a/packages/openapi/src/adapters/aws-lambda/index.ts b/packages/openapi/src/adapters/aws-lambda/index.ts deleted file mode 100644 index a2825c287..000000000 --- a/packages/openapi/src/adapters/aws-lambda/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './openapi-handler' diff --git a/packages/openapi/src/adapters/aws-lambda/openapi-handler.test.ts b/packages/openapi/src/adapters/aws-lambda/openapi-handler.test.ts deleted file mode 100644 index 819f5e41a..000000000 --- a/packages/openapi/src/adapters/aws-lambda/openapi-handler.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { os } from '@orpc/server' -import { sendStandardResponse, toStandardLazyRequest } from '@orpc/standard-server-aws-lambda' -import { OpenAPIHandler } from './openapi-handler' - -vi.mock('@orpc/standard-server-aws-lambda', () => ({ - toStandardLazyRequest: vi.fn(), - sendStandardResponse: vi.fn(), -})) - -vi.mock('../standard', async origin => ({ - ...await origin(), - StandardHandler: vi.fn(), -})) - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('openAPIHandler', async () => { - const handlerOptions = { eventIteratorKeepAliveComment: '__test__' } - - const handler = new OpenAPIHandler({ - ping: os.route({ method: 'GET' }).handler(({ input }) => ({ output: input })), - }, handlerOptions) - - const event: any = { req: true } - const responseStream: any = { res: true } - - const standardRequest = { - method: 'GET', - url: new URL('https://example.com/api/v1/ping?key=value'), - headers: { - 'content-type': 'application/json', - 'content-length': '12', - }, - body: () => Promise.resolve('value'), - signal: undefined, - } - - it('on match', async () => { - vi.mocked(toStandardLazyRequest).mockReturnValueOnce(standardRequest) - const options = { prefix: '/api/v1', context: { db: 'postgres' } } as const - - const result = await handler.handle(event, responseStream, options) - - expect(result).toEqual({ - matched: true, - }) - - expect(toStandardLazyRequest).toHaveBeenCalledOnce() - expect(toStandardLazyRequest).toHaveBeenCalledWith(event, responseStream) - - expect(sendStandardResponse).toHaveBeenCalledOnce() - expect(sendStandardResponse).toHaveBeenCalledWith(responseStream, { - status: 200, - headers: {}, - body: { output: { key: 'value' } }, - }, handlerOptions) - }) - - it('on mismatch', async () => { - vi.mocked(toStandardLazyRequest).mockReturnValueOnce({ - ...standardRequest, - url: new URL('https://example.com/api/v1/not-found'), - }) - - const options = { prefix: '/api/v1', context: { db: 'postgres' } } as const - const result = await handler.handle(event, responseStream, options) - - expect(result).toEqual({ - matched: false, - response: undefined, - }) - - expect(toStandardLazyRequest).toHaveBeenCalledOnce() - expect(toStandardLazyRequest).toHaveBeenCalledWith(event, responseStream) - - expect(sendStandardResponse).not.toHaveBeenCalled() - }) -}) diff --git a/packages/openapi/src/adapters/aws-lambda/openapi-handler.ts b/packages/openapi/src/adapters/aws-lambda/openapi-handler.ts deleted file mode 100644 index 4e1b292f4..000000000 --- a/packages/openapi/src/adapters/aws-lambda/openapi-handler.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Context, Router } from '@orpc/server' -import type { AwsLambdaHandlerOptions } from '@orpc/server/aws-lambda' -import type { StandardOpenAPIHandlerOptions } from '../standard' -import { AwsLambdaHandler } from '@orpc/server/aws-lambda' -import { StandardOpenAPIHandler } from '../standard' - -/** - * OpenAPI Handler for AWS Lambda. - * - * @see {@link https://orpc.dev/docs/openapi/openapi-handler OpenAPI Handler Docs} - * @see {@link https://orpc.dev/docs/adapters/http HTTP Adapter Docs} - */ -export class OpenAPIHandler extends AwsLambdaHandler { - constructor(router: Router, options: NoInfer & AwsLambdaHandlerOptions> = {}) { - super(new StandardOpenAPIHandler(router, options), options) - } -} diff --git a/packages/openapi/src/adapters/fastify/index.test.ts b/packages/openapi/src/adapters/fastify/index.test.ts deleted file mode 100644 index 0822e8028..000000000 --- a/packages/openapi/src/adapters/fastify/index.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -it('exports OpenAPIHandler', async () => { - expect(Object.keys(await import('./index'))).toContain('OpenAPIHandler') -}) diff --git a/packages/openapi/src/adapters/fastify/index.ts b/packages/openapi/src/adapters/fastify/index.ts deleted file mode 100644 index a2825c287..000000000 --- a/packages/openapi/src/adapters/fastify/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './openapi-handler' diff --git a/packages/openapi/src/adapters/fastify/openapi-handler.test.ts b/packages/openapi/src/adapters/fastify/openapi-handler.test.ts deleted file mode 100644 index 42d1a30c7..000000000 --- a/packages/openapi/src/adapters/fastify/openapi-handler.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { os } from '@orpc/server' -import Fastify from 'fastify' -import request from 'supertest' -import { OpenAPIHandler } from './openapi-handler' - -describe('openAPIHandler', () => { - it('works', async () => { - const handler = new OpenAPIHandler(os.route({ method: 'GET', path: '/ping' }).handler(({ input }) => ({ output: input }))) - - const fastify = Fastify() - - fastify.all('/*', async (req, reply) => { - await handler.handle(req, reply, { prefix: '/prefix' }) - }) - await fastify.ready() - const res = await request(fastify.server).get('/prefix/ping?input=hello') - - expect(res.text).toContain('hello') - expect(res.status).toBe(200) - }) -}) diff --git a/packages/openapi/src/adapters/fastify/openapi-handler.ts b/packages/openapi/src/adapters/fastify/openapi-handler.ts deleted file mode 100644 index b5a9ff234..000000000 --- a/packages/openapi/src/adapters/fastify/openapi-handler.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Context, Router } from '@orpc/server' -import type { FastifyHandlerOptions } from '@orpc/server/fastify' -import type { StandardOpenAPIHandlerOptions } from '../standard' -import { FastifyHandler } from '@orpc/server/fastify' -import { StandardOpenAPIHandler } from '../standard' - -export interface OpenAPIHandlerOptions extends FastifyHandlerOptions, StandardOpenAPIHandlerOptions { -} - -/** - * OpenAPI Handler for Fastify Server - * - * @see {@link https://orpc.dev/docs/openapi/openapi-handler OpenAPI Handler Docs} - * @see {@link https://orpc.dev/docs/adapters/http HTTP Adapter Docs} - */ -export class OpenAPIHandler extends FastifyHandler { - constructor(router: Router, options: NoInfer> = {}) { - super(new StandardOpenAPIHandler(router, options), options) - } -} diff --git a/packages/openapi/src/adapters/fetch/index.test.ts b/packages/openapi/src/adapters/fetch/index.test.ts new file mode 100644 index 000000000..117a49488 --- /dev/null +++ b/packages/openapi/src/adapters/fetch/index.test.ts @@ -0,0 +1,6 @@ +it('exports OpenAPIHandler and OpenAPILink', async () => { + await expect(import('.')).resolves.toMatchObject({ + OpenAPIHandler: expect.any(Function), + OpenAPILink: expect.any(Function), + }) +}) diff --git a/packages/openapi/src/adapters/fetch/index.ts b/packages/openapi/src/adapters/fetch/index.ts index a2825c287..8e9995cf6 100644 --- a/packages/openapi/src/adapters/fetch/index.ts +++ b/packages/openapi/src/adapters/fetch/index.ts @@ -1 +1,2 @@ export * from './openapi-handler' +export * from './openapi-link' diff --git a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts index 119b90e7a..b1f221f8e 100644 --- a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts +++ b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts @@ -1,15 +1,73 @@ +import type { FetchHandlerPlugin } from '@orpc/server/fetch' import { os } from '@orpc/server' +import { openapi } from '../../meta' import { OpenAPIHandler } from './openapi-handler' -describe('openAPIHandler', () => { - it('works', async () => { - const handler = new OpenAPIHandler(os.route({ method: 'GET', path: '/ping' }).handler(({ input }) => ({ output: input }))) +describe('openapiHandler', () => { + beforeEach(() => { + vi.clearAllMocks() + }) - const { response } = await handler.handle(new Request('https://example.com/api/v1/ping?input=hello'), { - prefix: '/api/v1', + it('accepts context and prefix options in handle method', async () => { + const contextHandler = new OpenAPIHandler({ + ping: os + .$context<{ userId: string }>() + .meta(openapi({ method: 'POST', path: '/ping/pong' })) + .handler(({ context }) => context.userId), }) - await expect(response?.text()).resolves.toContain('hello') - expect(response?.status).toBe(200) + const { matched, response } = await contextHandler.handle( + new Request('https://example.com/api/v1/ping/pong', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ json: null }), + }), + { + context: { userId: 'u_123' }, + prefix: '/api/v1', + }, + ) + + expect(matched).toBe(true) + expect(response!.status).toBe(200) + await expect(response!.text()).resolves.toContain('u_123') + + const misMatchPrefixResult = await contextHandler.handle( + new Request('https://example.com/invalid/ping', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ json: null }), + }), + { + context: { userId: 'u_123' }, + prefix: '/api/v1', + }, + ) + + expect(misMatchPrefixResult.matched).toBe(false) + expect(misMatchPrefixResult.response).toBeUndefined() + }) + + it('support fetch handler plugin', async () => { + const plugin: FetchHandlerPlugin = { + name: 'test', + initFetchHandlerOptions(options) { + return { + ...options, + fetchInterceptors: [ + async () => ({ matched: true, response: new Response('intercepted') }), + ], + } + }, + } + + const handler = new OpenAPIHandler({}, { 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/openapi/src/adapters/fetch/openapi-handler.ts b/packages/openapi/src/adapters/fetch/openapi-handler.ts index 3d01ee4fe..b0697d891 100644 --- a/packages/openapi/src/adapters/fetch/openapi-handler.ts +++ b/packages/openapi/src/adapters/fetch/openapi-handler.ts @@ -1,20 +1,21 @@ import type { Context, Router } from '@orpc/server' import type { FetchHandlerOptions } from '@orpc/server/fetch' -import type { StandardOpenAPIHandlerOptions } from '../standard' +import type { StandardHandlerOptions } from '@orpc/server/standard' +import type { OpenAPIHandlerCodecOptions } from '../standard' import { FetchHandler } from '@orpc/server/fetch' -import { StandardOpenAPIHandler } from '../standard' +import { StandardHandler } from '@orpc/server/standard' +import { OpenAPIHandlerCodec } from '../standard' -export interface OpenAPIHandlerOptions extends FetchHandlerOptions, Omit, 'plugins'> { -} +export interface OpenAPIHandlerOptions + extends FetchHandlerOptions, Omit, 'plugins'>, OpenAPIHandlerCodecOptions {} -/** - * OpenAPI Handler for Fetch Server - * - * @see {@link https://orpc.dev/docs/openapi/openapi-handler OpenAPI Handler Docs} - * @see {@link https://orpc.dev/docs/adapters/http HTTP Adapter Docs} - */ export class OpenAPIHandler extends FetchHandler { - constructor(router: Router, options: NoInfer> = {}) { - super(new StandardOpenAPIHandler(router, options), options) + constructor( + router: Router, + options: NoInfer> = {}, + ) { + const codec = new OpenAPIHandlerCodec(router, options) + const handler = new StandardHandler(codec, options) + super(handler, options) } } diff --git a/packages/openapi/src/adapters/fetch/openapi-link.test.ts b/packages/openapi/src/adapters/fetch/openapi-link.test.ts new file mode 100644 index 000000000..fc4549f00 --- /dev/null +++ b/packages/openapi/src/adapters/fetch/openapi-link.test.ts @@ -0,0 +1,165 @@ +import type { FetchLinkTransportPlugin } from '@orpc/client/fetch' +import { createORPCClient } from '@orpc/client' +import { os } from '@orpc/server' +import { openapi } from '../../meta' +import { OpenAPIHandler } from './openapi-handler' +import { OpenAPILink } from './openapi-link' + +describe('openapiLink', () => { + const date = new Date('2024-01-02T03:04:05.000Z') + const blob = new Blob(['hello'], { type: 'text/plain' }) + + const router = { + get: os + .meta(openapi({ method: 'GET', path: '/ping/{pong}' })) + .handler(({ input }) => input), + post: os.handler(({ input }) => input), + } + + const handler = new OpenAPIHandler(router) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('calls a GET OpenAPI endpoint through fetch transport', async () => { + const fetch = vi.fn(async (url: string, init: RequestInit) => { + const request = new Request(url, init) + const { matched, response } = await handler.handle(request, { + prefix: '/api', + }) + + if (!matched || !response) { + throw new Error('No procedure match') + } + + return response + }) + + const client = createORPCClient(new OpenAPILink(router, { + fetch, + origin: 'http://localhost:3000', + url: '/api', + })) as any + + await expect(client.get({ + pong: 'pong', + a: 1, + nested: { + date, + arr: [3, date], + }, + })).resolves.toEqual({ + pong: 'pong', + a: '1', + nested: { + date: date.toISOString(), + arr: ['3', date.toISOString()], + }, + }) + + expect(fetch).toHaveBeenCalledOnce() + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('http://localhost:3000/api/ping/pong'), + expect.objectContaining({ + method: 'GET', + redirect: 'manual', + }), + expect.objectContaining({ context: {} }), + ['get'], + ) + }) + + it('calls a POST OpenAPI endpoint with JSON payloads', async () => { + const client = createORPCClient(new OpenAPILink(router, { + origin: 'http://localhost:3000', + url: '/api', + fetch: async (url, init) => { + const request = new Request(url, init) + const { matched, response } = await handler.handle(request, { + prefix: '/api', + }) + + if (!matched || !response) { + throw new Error('No procedure match') + } + + return response + }, + })) as any + + await expect(client.post({ + a: 1, + b: 2, + nested: { + date, + arr: [3, date], + }, + })).resolves.toEqual({ + a: 1, + b: 2, + nested: { + date: date.toISOString(), + arr: [3, date.toISOString()], + }, + }) + }) + + it('calls a POST OpenAPI endpoint with multipart payloads', async () => { + const client = createORPCClient(new OpenAPILink(router, { + origin: 'http://localhost:3000', + url: '/api', + fetch: async (url, init) => { + const request = new Request(url, init) + const { matched, response } = await handler.handle(request, { + prefix: '/api', + }) + + if (!matched || !response) { + throw new Error('No procedure match') + } + + return response + }, + })) as any + + await expect(client.post({ + a: 1, + nested: { + date, + arr: [3, date], + }, + blob, + })).resolves.toEqual({ + a: '1', + nested: { + date: date.toISOString(), + arr: ['3', date.toISOString()], + }, + blob: expect.any(File), + }) + }) + + it('supports fetch transport plugins', async () => { + const plugin: FetchLinkTransportPlugin = { + name: 'test', + init() { + return { + transportInterceptors: [ + async () => ({ + status: 200, + headers: {}, + resolveBody: async () => 'intercepted', + }), + ], + } + }, + } + + const client = createORPCClient(new OpenAPILink(router, { + plugins: [plugin], + })) as any + + await expect(client.post('ignored')).resolves.toBe('intercepted') + }) +}) diff --git a/packages/openapi/src/adapters/fetch/openapi-link.ts b/packages/openapi/src/adapters/fetch/openapi-link.ts new file mode 100644 index 000000000..26537df94 --- /dev/null +++ b/packages/openapi/src/adapters/fetch/openapi-link.ts @@ -0,0 +1,24 @@ +import type { ClientContext } from '@orpc/client' +import type { FetchLinkTransportOptions } from '@orpc/client/fetch' +import type { StandardLinkOptions } from '@orpc/client/standard' +import type { RouterContract } from '@orpc/contract' +import type { OpenAPILinkCodecOptions } from '../standard' +import { FetchLinkTransport } from '@orpc/client/fetch' +import { StandardLink } from '@orpc/client/standard' +import { OpenAPILinkCodec } from '../standard' + +export interface OpenAPILinkOptions + extends Omit, 'plugins'>, FetchLinkTransportOptions, OpenAPILinkCodecOptions { +} + +export class OpenAPILink extends StandardLink { + constructor( + router: RouterContract, + options: OpenAPILinkOptions = {}, + ) { + const codec = new OpenAPILinkCodec(router, options) + const transport = new FetchLinkTransport(options) + + super(codec, transport, options) + } +} diff --git a/packages/openapi/src/adapters/node/index.test.ts b/packages/openapi/src/adapters/node/index.test.ts new file mode 100644 index 000000000..5d1d114b1 --- /dev/null +++ b/packages/openapi/src/adapters/node/index.test.ts @@ -0,0 +1,5 @@ +it('exports OpenAPIHandler', async () => { + await expect(import('.')).resolves.toMatchObject({ + OpenAPIHandler: expect.any(Function), + }) +}) diff --git a/packages/openapi/src/adapters/node/openapi-handler.test.ts b/packages/openapi/src/adapters/node/openapi-handler.test.ts index cc756fec9..112dd9338 100644 --- a/packages/openapi/src/adapters/node/openapi-handler.test.ts +++ b/packages/openapi/src/adapters/node/openapi-handler.test.ts @@ -1,17 +1,74 @@ +import type { NodeHttpHandlerPlugin } from '@orpc/server/node' import type { IncomingMessage, ServerResponse } from 'node:http' import { os } from '@orpc/server' import request from 'supertest' +import { openapi } from '../../meta' import { OpenAPIHandler } from './openapi-handler' -describe('openAPIHandler', () => { - it('works', async () => { - const handler = new OpenAPIHandler(os.route({ method: 'GET', path: '/ping' }).handler(({ input }) => ({ output: input }))) +describe('openapiHandler', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('accepts context and prefix options in handle method', async () => { + const handler = new OpenAPIHandler({ + ping: os + .$context<{ userId: string }>() + .meta(openapi({ method: 'POST', path: '/ping/pong' })) + .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/ping/pong').set('content-type', 'application/json').send({ json: null }) + + expect(res.status).toBe(200) + expect(res.text).toContain('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/ping').set('content-type', 'application/json').send({ json: null }) + + expect(mismatchRes.status).toBe(404) + expect(mismatchRes.text).toBe('not matched') + }) + + it('supports node http handler plugin', async () => { + const plugin: NodeHttpHandlerPlugin = { + name: 'test', + initNodeHttpHandlerOptions(options) { + return { + ...options, + nodeHttpInterceptors: [ + async ({ response }) => { + response.statusCode = 200 + response.end('intercepted') + + return { matched: true } + }, + ], + } + }, + } + + const handler = new OpenAPIHandler({}, { plugins: [plugin] }) - const res = await request(async (req: IncomingMessage, res: ServerResponse) => { - await handler.handle(req, res, { prefix: '/prefix' }) - }).get('/prefix/ping?input=hello') + const res = await request(async (req: IncomingMessage, response: ServerResponse) => { + await handler.handle(req as any, response as any) + }).get('/test') - expect(res.text).toContain('hello') expect(res.status).toBe(200) + expect(res.text).toBe('intercepted') }) }) diff --git a/packages/openapi/src/adapters/node/openapi-handler.ts b/packages/openapi/src/adapters/node/openapi-handler.ts index 8b17df237..745e1e1c1 100644 --- a/packages/openapi/src/adapters/node/openapi-handler.ts +++ b/packages/openapi/src/adapters/node/openapi-handler.ts @@ -1,20 +1,21 @@ import type { Context, Router } from '@orpc/server' import type { NodeHttpHandlerOptions } from '@orpc/server/node' -import type { StandardOpenAPIHandlerOptions } from '../standard' +import type { StandardHandlerOptions } from '@orpc/server/standard' +import type { OpenAPIHandlerCodecOptions } from '../standard' import { NodeHttpHandler } from '@orpc/server/node' -import { StandardOpenAPIHandler } from '../standard' +import { StandardHandler } from '@orpc/server/standard' +import { OpenAPIHandlerCodec } from '../standard' -export interface OpenAPIHandlerOptions extends NodeHttpHandlerOptions, Omit, 'plugins'> { -} +export interface OpenAPIHandlerOptions + extends NodeHttpHandlerOptions, Omit, 'plugins'>, OpenAPIHandlerCodecOptions {} -/** - * OpenAPI Handler for Node Server - * - * @see {@link https://orpc.dev/docs/openapi/openapi-handler OpenAPI Handler Docs} - * @see {@link https://orpc.dev/docs/adapters/http HTTP Adapter Docs} - */ export class OpenAPIHandler extends NodeHttpHandler { - constructor(router: Router, options: NoInfer> = {}) { - super(new StandardOpenAPIHandler(router, options), options) + constructor( + router: Router, + options: NoInfer> = {}, + ) { + const codec = new OpenAPIHandlerCodec(router, options) + const handler = new StandardHandler(codec, options) + super(handler, options) } } diff --git a/packages/openapi/src/adapters/standard/index.test.ts b/packages/openapi/src/adapters/standard/index.test.ts new file mode 100644 index 000000000..fe7788852 --- /dev/null +++ b/packages/openapi/src/adapters/standard/index.test.ts @@ -0,0 +1,7 @@ +it('exports OpenAPIMatcher, OpenAPIHandlerCodec, OpenAPILinkCodec', async () => { + await expect(import('.')).resolves.toMatchObject({ + OpenAPIMatcher: expect.any(Function), + OpenAPIHandlerCodec: expect.any(Function), + OpenAPILinkCodec: expect.any(Function), + }) +}) diff --git a/packages/openapi/src/adapters/standard/index.ts b/packages/openapi/src/adapters/standard/index.ts index f2d79f1f0..a754da32a 100644 --- a/packages/openapi/src/adapters/standard/index.ts +++ b/packages/openapi/src/adapters/standard/index.ts @@ -1,4 +1,3 @@ -export * from './openapi-codec' -export * from './openapi-handler' +export * from './openapi-handler-codec' +export * from './openapi-link-codec' export * from './openapi-matcher' -export * from './utils' diff --git a/packages/openapi/src/adapters/standard/openapi-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-codec.test.ts deleted file mode 100644 index f85040977..000000000 --- a/packages/openapi/src/adapters/standard/openapi-codec.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { ORPCError } from '@orpc/contract' -import { Procedure } from '@orpc/server' -import { ping } from '../../../../server/tests/shared' -import { StandardOpenAPICodec } from './openapi-codec' - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('standardOpenAPICodec', () => { - const serializer = { - serialize: vi.fn(), - deserialize: vi.fn(), - } as any - - const codec = new StandardOpenAPICodec(serializer) - - describe('.decode', () => { - describe('with compact structure', () => { - it('with GET method', async () => { - serializer.deserialize.mockReturnValueOnce(undefined) - - const url = new URL('http://localhost/api/v1?data=data') - url.searchParams.append('data', JSON.stringify('__data__')) - - const input = await codec.decode({ - method: 'GET', - url, - body: vi.fn(), - headers: {}, - signal: undefined, - }, { name: 'John Doe' }, ping) - - expect(input).toEqual({ name: 'John Doe' }) - - expect(serializer.deserialize).toHaveBeenCalledOnce() - expect(serializer.deserialize).toHaveBeenCalledWith(url.searchParams) - }) - - it('with non-GET method', async () => { - const serialized = '__data__' - - serializer.deserialize.mockReturnValueOnce('__deserialized__') - - const input = await codec.decode({ - method: 'POST', - url: new URL('http://localhost/api/v1?data=data'), - body: vi.fn(async () => serialized), - headers: {}, - signal: undefined, - }, undefined, ping) - - expect(input).toEqual('__deserialized__') - - expect(serializer.deserialize).toHaveBeenCalledOnce() - expect(serializer.deserialize).toHaveBeenCalledWith(serialized) - }) - - it('params and body are merged', async () => { - const serialized = '__data__' - - serializer.deserialize.mockReturnValueOnce({ v1: 'v1' }) - - const input = await codec.decode({ - method: 'POST', - url: new URL('http://localhost/api/v1?data=data'), - body: vi.fn(async () => serialized), - headers: {}, - signal: undefined, - }, { v2: 'v2' }, ping) - - expect(input).toEqual({ v1: 'v1', v2: 'v2' }) - - expect(serializer.deserialize).toHaveBeenCalledOnce() - expect(serializer.deserialize).toHaveBeenCalledWith(serialized) - }) - }) - - describe('with detailed structure', () => { - const procedure = new Procedure({ - ...ping['~orpc'], - route: { - inputStructure: 'detailed', - }, - }) - - it('with GET method', async () => { - serializer.deserialize.mockReturnValue('__deserialized__') - - const url = new URL('http://localhost/api/v1?data=data') - url.searchParams.append('data', JSON.stringify('__data__')) - - const input = await codec.decode({ - method: 'GET', - url, - body: vi.fn(), - headers: { - 'content-type': 'application/json', - }, - signal: undefined, - }, { name: 'John Doe' }, procedure) - - expect(input).toEqual({ - params: { name: 'John Doe' }, - query: '__deserialized__', - headers: { - 'content-type': 'application/json', - }, - body: '__deserialized__', - }) - - expect(serializer.deserialize).toHaveBeenCalledTimes(2) - expect(serializer.deserialize).toHaveBeenNthCalledWith(1, undefined) - expect(serializer.deserialize).toHaveBeenNthCalledWith(2, url.searchParams) - }) - - it('with non-GET method', async () => { - const serialized = '__data__' - - serializer.deserialize.mockReturnValue('__deserialized__') - const url = new URL('http://localhost/api/v1?data=data') - - const input = await codec.decode({ - method: 'POST', - url, - body: vi.fn(async () => serialized), - headers: { - 'content-type': 'application/json', - }, - signal: undefined, - }, { name: 'John Doe' }, procedure) - - expect(input).toEqual({ - params: { name: 'John Doe' }, - query: '__deserialized__', - headers: { - 'content-type': 'application/json', - }, - body: '__deserialized__', - }) - - expect(serializer.deserialize).toHaveBeenCalledTimes(2) - expect(serializer.deserialize).toHaveBeenNthCalledWith(1, serialized) - expect(serializer.deserialize).toHaveBeenNthCalledWith(2, url.searchParams) - }) - - it('can set query', async () => { - const serialized = '__data__' - - serializer.deserialize.mockReturnValue('__deserialized__') - const url = new URL('http://localhost/api/v1?data=data') - - const input = await codec.decode({ - method: 'POST', - url, - body: vi.fn(async () => serialized), - headers: { - 'content-type': 'application/json', - }, - signal: undefined, - }, { name: 'John Doe' }, procedure) as any - - input.query = { name: 'John Doe' } - expect(input.query).toEqual({ name: 'John Doe' }) - }) - }) - }) - - describe('.encode', async () => { - it('with compact structure', async () => { - serializer.serialize.mockReturnValueOnce('__serialized__') - - const response = codec.encode('__output__', ping) - - expect(response).toEqual({ - status: 200, - headers: {}, - body: '__serialized__', - }) - - expect(serializer.serialize).toHaveBeenCalledOnce() - expect(serializer.serialize).toHaveBeenCalledWith('__output__') - }) - - it('with ReadableStream bypasses serialization and respects successStatus', () => { - const procedure = new Procedure({ - ...ping['~orpc'], - route: { - successStatus: 202, - }, - }) - const stream = new ReadableStream() - - const response = codec.encode(stream, procedure) - - expect(response).toEqual({ - status: 202, - headers: {}, - body: stream, - }) - - expect(serializer.serialize).not.toHaveBeenCalled() - }) - - describe('with detailed structure', async () => { - const procedure = new Procedure({ - ...ping['~orpc'], - route: { - outputStructure: 'detailed', - successStatus: 298, - }, - }) - - it('works', async () => { - serializer.serialize.mockReturnValue('__serialized__') - - const output = { - body: '__output__', - headers: { - 'x-custom-header': 'custom-value', - }, - } - const response = codec.encode(output, procedure) - - expect(response).toEqual({ - status: 298, - headers: { - 'x-custom-header': 'custom-value', - }, - body: '__serialized__', - }) - - expect(serializer.serialize).toHaveBeenCalledTimes(1) - expect(serializer.serialize).toHaveBeenCalledWith('__output__') - }) - - it('works with empty output', async () => { - serializer.serialize.mockReturnValue('__serialized__') - - expect(codec.encode({}, procedure)).toEqual({ - status: 298, - headers: {}, - body: '__serialized__', - }) - - expect(serializer.serialize).toHaveBeenCalledTimes(1) - expect(serializer.serialize).toHaveBeenCalledWith(undefined) - }) - - it('works with custom status', async () => { - serializer.serialize.mockReturnValue('__serialized__') - - const output = { - status: 201, - body: '__output__', - headers: { - 'x-custom-header': 'custom-value', - }, - } - const response = codec.encode(output, procedure) - - expect(response).toEqual({ - status: 201, - headers: { - 'x-custom-header': 'custom-value', - }, - body: '__serialized__', - }) - - expect(serializer.serialize).toHaveBeenCalledTimes(1) - expect(serializer.serialize).toHaveBeenCalledWith('__output__') - }) - - it('works with ReadableStream body', () => { - const stream = new ReadableStream() - const output = { - body: stream, - headers: { 'content-type': 'application/zip' }, - } - - const response = codec.encode(output, procedure) - - expect(response).toEqual({ - status: 298, - headers: { 'content-type': 'application/zip' }, - body: stream, - }) - - expect(serializer.serialize).not.toHaveBeenCalled() - }) - - it.each([ - 'invalid', - { status: 'invalid' }, - { status: 400 }, - { status: 200.1 }, - { status: 'invalid' }, - { headers: 'invalid' }, - ])('throw on invalid output: %s', async (output) => { - expect(() => codec.encode(output, procedure)).toThrowError() - }) - }) - }) - - describe('.encodeError', () => { - it('works', async () => { - serializer.serialize.mockReturnValueOnce('__serialized__') - - const error = new ORPCError('BAD_GATEWAY', { - data: '__data__', - }) - const response = codec.encodeError(error) - - expect(response).toEqual({ - status: error.status, - headers: {}, - body: '__serialized__', - }) - - expect(serializer.serialize).toHaveBeenCalledOnce() - expect(serializer.serialize).toHaveBeenCalledWith(error.toJSON(), { outputFormat: 'plain' }) - }) - - it('customErrorResponseBodyEncoder', async () => { - let time = 1 - const customErrorResponseBodyEncoder = vi.fn(() => { - if (time++ === 2) { - return null // default behavior - } - - return '__custom_error_body__' - }) - - const codec = new StandardOpenAPICodec(serializer, { - customErrorResponseBodyEncoder, - }) - - let time2 = 1 - serializer.serialize.mockImplementation(() => `__serialized${time2++}__`) - - const error1 = new ORPCError('BAD_GATEWAY', { data: '__data1__' }) - const response1 = codec.encodeError(error1) - expect(response1).toEqual({ status: error1.status, headers: {}, body: '__serialized1__' }) - - const error2 = new ORPCError('TEST_2', { data: '__data2__' }) - const response2 = codec.encodeError(error2) - expect(response2).toEqual({ status: error2.status, headers: {}, body: '__serialized2__' }) - - expect(customErrorResponseBodyEncoder).toHaveBeenCalledTimes(2) - expect(customErrorResponseBodyEncoder).toHaveBeenNthCalledWith(1, error1) - expect(customErrorResponseBodyEncoder).toHaveBeenNthCalledWith(2, error2) - - expect(serializer.serialize).toHaveBeenCalledTimes(2) - expect(serializer.serialize).toHaveBeenNthCalledWith(1, '__custom_error_body__', { outputFormat: 'plain' }) - expect(serializer.serialize).toHaveBeenNthCalledWith(2, error2.toJSON(), { outputFormat: 'plain' }) // default behavior - }) - }) -}) diff --git a/packages/openapi/src/adapters/standard/openapi-codec.ts b/packages/openapi/src/adapters/standard/openapi-codec.ts deleted file mode 100644 index 9b719f557..000000000 --- a/packages/openapi/src/adapters/standard/openapi-codec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { ORPCError } from '@orpc/client' -import type { StandardOpenAPISerializer } from '@orpc/openapi-client/standard' -import type { AnyProcedure } from '@orpc/server' -import type { StandardCodec, StandardParams } from '@orpc/server/standard' -import type { StandardHeaders, StandardLazyRequest, StandardResponse } from '@orpc/standard-server' -import { isORPCErrorStatus } from '@orpc/client' -import { fallbackContractConfig } from '@orpc/contract' -import { isObject, stringifyJSON } from '@orpc/shared' - -export interface StandardOpenAPICodecOptions { - /** - * Customize how an ORPC error is encoded into a response body. - * Use this if your API needs a different error output structure. - * - * @remarks - * - Return `null | undefined` to fallback to default behavior - * - * @default ((e) => e.toJSON()) - */ - customErrorResponseBodyEncoder?: (error: ORPCError) => unknown -} - -export class StandardOpenAPICodec implements StandardCodec { - private readonly customErrorResponseBodyEncoder: StandardOpenAPICodecOptions['customErrorResponseBodyEncoder'] - - constructor( - private readonly serializer: StandardOpenAPISerializer, - options: StandardOpenAPICodecOptions = {}, - ) { - this.customErrorResponseBodyEncoder = options.customErrorResponseBodyEncoder - } - - async decode(request: StandardLazyRequest, params: StandardParams | undefined, procedure: AnyProcedure): Promise { - const inputStructure = fallbackContractConfig('defaultInputStructure', procedure['~orpc'].route.inputStructure) - - if (inputStructure === 'compact') { - const data = request.method === 'GET' - ? this.serializer.deserialize(request.url.searchParams) - : this.serializer.deserialize(await request.body()) - - if (data === undefined) { - return params - } - - if (isObject(data)) { - return { - ...params, - ...data, - } - } - - return data - } - - const deserializeSearchParams = () => { - return this.serializer.deserialize(request.url.searchParams) - } - - return { - params, - get query() { - const value = deserializeSearchParams() - Object.defineProperty(this, 'query', { value, writable: true }) - return value - }, - set query(value) { - Object.defineProperty(this, 'query', { value, writable: true }) - }, - headers: request.headers, - body: this.serializer.deserialize(await request.body()), - } - } - - encode(output: unknown, procedure: AnyProcedure): StandardResponse { - const successStatus = fallbackContractConfig('defaultSuccessStatus', procedure['~orpc'].route.successStatus) - - const outputStructure = fallbackContractConfig('defaultOutputStructure', procedure['~orpc'].route.outputStructure) - - if (outputStructure === 'compact') { - if (output instanceof ReadableStream) { - return { - status: successStatus, - headers: {}, - body: output, - } - } - - return { - status: successStatus, - headers: {}, - body: this.serializer.serialize(output), - } - } - - if (!this.#isDetailedOutput(output)) { - throw new Error(` - Invalid "detailed" output structure: - • Expected an object with optional properties: - - status (number 200-399) - - headers (Record) - - body (any) - • No extra keys allowed. - - Actual value: - ${stringifyJSON(output)} - `) - } - - if (output.body instanceof ReadableStream) { - return { - status: output.status ?? successStatus, - headers: output.headers ?? {}, - body: output.body, - } - } - - return { - status: output.status ?? successStatus, - headers: output.headers ?? {}, - body: this.serializer.serialize(output.body), - } - } - - encodeError(error: ORPCError): StandardResponse { - const body = this.customErrorResponseBodyEncoder?.(error) ?? error.toJSON() - - return { - status: error.status, - headers: {}, - body: this.serializer.serialize(body, { outputFormat: 'plain' }), - } - } - - #isDetailedOutput(output: unknown): output is { status?: number, body?: unknown, headers?: StandardHeaders } { - if (!isObject(output)) { - return false - } - - if (output.headers && !isObject(output.headers)) { - return false - } - - if (output.status !== undefined && (typeof output.status !== 'number' || !Number.isInteger(output.status) || isORPCErrorStatus(output.status))) { - return false - } - - return true - } -} diff --git a/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts new file mode 100644 index 000000000..becf4b982 --- /dev/null +++ b/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts @@ -0,0 +1,827 @@ +import type { StandardLazyRequest } from '@standardserver/core' +import { ORPCError } from '@orpc/client' +import { DEFAULT_ERROR_STATUS, os } from '@orpc/server' +import { openapi } from '../../meta' +import { OpenAPIHandlerCodec } from './openapi-handler-codec' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('openAPIHandlerCodec', () => { + const options = { + context: {}, + } as const + + function createRequest(overrides: Partial = {}) { + return { + method: 'GET', + url: '/' as const, + resolveBody: vi.fn(), + headers: {}, + signal: undefined, + ...overrides, + } + } + + describe('.resolveProcedure', () => { + describe('routing', () => { + it('returns undefined when no route matches', async () => { + const codec = new OpenAPIHandlerCodec( + os.meta(openapi({ method: 'GET', path: '/items' })).handler(vi.fn()), + ) + + const result = await codec.resolveProcedure(createRequest({ + method: 'GET', + url: '/missing', + }), options as any) + + expect(result).toBeUndefined() + }) + + it('respects the runtime prefix option', async () => { + const procedure = os + .meta(openapi({ method: 'GET', prefix: '/api/v1', path: '/items/{id}' })) + .handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure) + + const result = await codec.resolveProcedure(createRequest({ + method: 'GET', + url: '/gateway/api/v1/items/42', + }), { + ...options, + prefix: '/gateway', + } as any) + + expect(result).toBeDefined() + + await expect(result!.decodeInput()).resolves.toEqual({ + id: '42', + }) + }) + }) + + describe('compact GET input', () => { + it('merges path params and query without reading the body', async () => { + const procedure = os + .meta(openapi({ method: 'GET', path: '/{id}' })) + .handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure) + const resolveBody = vi.fn() + + const result = await codec.resolveProcedure(createRequest({ + method: 'GET', + url: '/42?plain=value&filter[status]=active', + resolveBody, + }), options as any) + + expect(result).toBeDefined() + expect(result!.procedure).toBe(procedure) + + await expect(result!.decodeInput()).resolves.toEqual({ + id: '42', + plain: 'value', + filter: { status: 'active' }, + }) + + expect(resolveBody).not.toHaveBeenCalled() + }) + + it('returns query directly when there are no path params', async () => { + const procedure = os + .meta(openapi({ method: 'GET', path: '/status' })) + .handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure) + + const result = await codec.resolveProcedure(createRequest({ + method: 'GET', + url: '/status?plain=value&filter[status]=active', + }), options as any) + + expect(result).toBeDefined() + expect(result!.procedure).toBe(procedure) + + await expect(result!.decodeInput()).resolves.toEqual({ + plain: 'value', + filter: { status: 'active' }, + }) + }) + + it('converts a bracket-notation root array query to an object and merges with path params', async () => { + const procedure = os + .meta(openapi({ method: 'GET', path: '/{id}' })) + .handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure) + + const result = await codec.resolveProcedure(createRequest({ + method: 'GET', + url: '/42?0=zero&1=one', + }), options as any) + + expect(result).toBeDefined() + + await expect(result!.decodeInput()).resolves.toEqual({ + id: '42', + 0: 'zero', + 1: 'one', + }) + }) + + it('decodes path params using simple array and object styles', async () => { + const codec = new OpenAPIHandlerCodec( + os.meta(openapi({ + method: 'GET', + path: '/{id}/{tags}/{filters}', + paramsStyles: { + id: 'primitive', + tags: 'comma-delimited-array', + filters: 'comma-delimited-object', + }, + })).handler(vi.fn()), + ) + + const result = await codec.resolveProcedure(createRequest({ + method: 'GET', + url: '/42/red,blue/size,large,brand,nike?page=2', + }), options as any) + + expect(result).toBeDefined() + + await expect(result!.decodeInput()).resolves.toEqual({ + id: '42', + tags: ['red', 'blue'], + filters: { size: 'large', brand: 'nike' }, + page: '2', + }) + }) + }) + + describe('compact non-GET input', () => { + it('returns only path params when the body deserializes to undefined', async () => { + const serializer = { + serialize: vi.fn(), + deserialize: vi.fn().mockReturnValueOnce(undefined), + } as any + + const codec = new OpenAPIHandlerCodec( + os.meta(openapi({ method: 'POST', path: '/{id}', requestBodyHint: 'url-search-params' })).handler(vi.fn()), + { serializer }, + ) + const resolveBody = vi.fn().mockResolvedValueOnce(undefined) + const result = await codec.resolveProcedure(createRequest({ + method: 'POST', + url: '/24', + resolveBody, + }), options as any) + + expect(result).toBeDefined() + + await expect(result!.decodeInput()).resolves.toEqual({ id: '24' }) + + expect(resolveBody).toHaveBeenCalledOnce() + expect(resolveBody).toHaveBeenCalledWith('url-search-params') + expect(serializer.deserialize).toHaveBeenCalledWith(undefined) + }) + + it('merges object body with path params', async () => { + const procedure = os + .meta(openapi({ method: 'POST', path: '/{id}', requestBodyHint: 'url-search-params' })) + .handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure) + const resolveBody = vi.fn().mockResolvedValueOnce(new URLSearchParams([ + ['title', 'hello'], + ['published', 'true'], + ])) + + const result = await codec.resolveProcedure(createRequest({ + method: 'POST', + url: '/24', + resolveBody, + }), options as any) + + expect(result).toBeDefined() + expect(result!.procedure).toBe(procedure) + + await expect(result!.decodeInput()).resolves.toEqual({ + id: '24', + title: 'hello', + published: 'true', + }) + + expect(resolveBody).toHaveBeenCalledOnce() + expect(resolveBody).toHaveBeenCalledWith('url-search-params') + }) + + it('returns a primitive body as-is when it cannot be merged with path params', async () => { + const serializer = { + serialize: vi.fn(), + deserialize: vi.fn() + .mockReturnValueOnce(undefined) + .mockReturnValueOnce('raw-body'), + } as any + + const codec = new OpenAPIHandlerCodec( + os.meta(openapi({ method: 'POST', path: '/{id}', requestBodyHint: 'url-search-params' })).handler(vi.fn()), + { serializer }, + ) + const resolveBody = vi.fn().mockResolvedValueOnce('__body__') + + const result = await codec.resolveProcedure(createRequest({ + method: 'POST', + url: '/24', + resolveBody, + }), options as any) + + expect(result).toBeDefined() + + await expect(result!.decodeInput()).resolves.toBe('raw-body') + expect(serializer.deserialize).toHaveBeenNthCalledWith(1, expect.any(URLSearchParams)) + expect(serializer.deserialize).toHaveBeenCalledWith('__body__') + }) + + it('returns body directly when there are no path params', async () => { + const serializer = { + serialize: vi.fn(), + deserialize: vi.fn() + .mockReturnValueOnce(undefined) + .mockReturnValueOnce({ name: 'din' }), + } as any + + const procedure = os + .meta(openapi({ method: 'POST', path: '/submit' })) + .handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure, { serializer }) + const resolveBody = vi.fn().mockResolvedValueOnce('__body__') + + const result = await codec.resolveProcedure(createRequest({ + method: 'POST', + url: '/submit', + resolveBody, + }), options as any) + + expect(result).toBeDefined() + expect(result!.procedure).toBe(procedure) + + await expect(result!.decodeInput()).resolves.toEqual({ name: 'din' }) + expect(resolveBody).toHaveBeenCalledWith(undefined) + }) + + it('returns an array body as-is even when path params exist', async () => { + const serializer = { + serialize: vi.fn(), + deserialize: vi.fn() + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(['first', 'second']), + } as any + + const procedure = os + .meta(openapi({ method: 'POST', path: '/{id}', requestBodyHint: 'json' })) + .handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure, { serializer }) + const resolveBody = vi.fn().mockResolvedValueOnce('__body__') + + const result = await codec.resolveProcedure(createRequest({ + method: 'POST', + url: '/24', + resolveBody, + }), options as any) + + expect(result).toBeDefined() + expect(result!.procedure).toBe(procedure) + + await expect(result!.decodeInput()).resolves.toEqual(['first', 'second']) + }) + }) + + describe('detailed input', () => { + it('decodes input into { params, query, headers, body }', async () => { + const procedure = os + .meta(openapi({ + method: 'POST', + path: '/{id}', + inputStructure: 'detailed', + requestBodyHint: 'url-search-params', + })) + .handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure) + const resolveBody = vi.fn().mockResolvedValueOnce(new URLSearchParams([ + ['name', 'alice'], + ['active', 'true'], + ])) + + const result = await codec.resolveProcedure(createRequest({ + method: 'POST', + url: '/99?page=2', + headers: { 'x-trace-id': 'abc' }, + resolveBody, + }), options as any) + + expect(result).toBeDefined() + expect(result!.procedure).toBe(procedure) + + await expect(result!.decodeInput()).resolves.toEqual({ + params: { id: '99' }, + query: { page: '2' }, + headers: { 'x-trace-id': 'abc' }, + body: { name: 'alice', active: 'true' }, + }) + + expect(resolveBody).toHaveBeenCalledOnce() + expect(resolveBody).toHaveBeenCalledWith('url-search-params') + }) + + it('decodes styled path params inside the detailed params object', async () => { + const procedure = os + .meta(openapi({ + method: 'POST', + path: '/{id}/{tags}/{filters}', + inputStructure: 'detailed', + paramsStyles: { + id: 'primitive', + tags: 'comma-delimited-array', + filters: 'comma-delimited-object', + }, + })) + .handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure) + const resolveBody = vi.fn().mockResolvedValueOnce(undefined) + + const result = await codec.resolveProcedure(createRequest({ + method: 'POST', + url: '/42/red,blue/size,large,brand,nike?page=2', + headers: { 'x-trace-id': 'abc' }, + resolveBody, + }), options as any) + + expect(result).toBeDefined() + + await expect(result!.decodeInput()).resolves.toEqual({ + params: { + id: '42', + tags: ['red', 'blue'], + filters: { size: 'large', brand: 'nike' }, + }, + query: { page: '2' }, + headers: { 'x-trace-id': 'abc' }, + body: undefined, + }) + }) + + it('decodes styled query values inside the detailed query object', async () => { + const procedure = os + .meta(openapi({ + method: 'POST', + path: '/{id}', + inputStructure: 'detailed', + queryStyles: { + keyword: 'primitive', + tags: 'array', + meta: 'json', + }, + })) + .handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure) + const resolveBody = vi.fn().mockResolvedValueOnce(undefined) + + const result = await codec.resolveProcedure(createRequest({ + method: 'POST', + url: '/42?keyword=first&keyword=last&tags=red&tags=blue&meta=%7B%22enabled%22%3Atrue%7D&plain=value', + headers: { 'x-trace-id': 'abc' }, + resolveBody, + }), options as any) + + expect(result).toBeDefined() + + await expect(result!.decodeInput()).resolves.toEqual({ + params: { id: '42' }, + query: { + keyword: 'last', + tags: ['red', 'blue'], + meta: { enabled: true }, + plain: 'value', + }, + headers: { 'x-trace-id': 'abc' }, + body: undefined, + }) + }) + }) + + describe('queryParsing', () => { + it('applies last, array, and json strategies to repeated params', async () => { + const codec = new OpenAPIHandlerCodec( + os.meta(openapi({ + method: 'GET', + path: '/{id}', + queryStyles: { + keyword: 'primitive', + tags: 'array', + meta: 'json', + }, + })).handler(vi.fn()), + ) + + const result = await codec.resolveProcedure(createRequest({ + method: 'GET', + url: '/42?keyword=first&keyword=last&tags=red&tags=blue&meta=%7B%22enabled%22%3Atrue%7D&plain=value', + }), options as any) + + expect(result).toBeDefined() + + await expect(result!.decodeInput()).resolves.toEqual({ + id: '42', + keyword: 'last', + tags: ['red', 'blue'], + meta: { enabled: true }, + plain: 'value', + }) + }) + + it('converts a bracket-notation root array to an object when strategies are defined', async () => { + const codec = new OpenAPIHandlerCodec( + os.meta(openapi({ method: 'GET', queryStyles: { tags: 'array' } })).handler(vi.fn()), + ) + + const result = await codec.resolveProcedure(createRequest({ + method: 'GET', + url: '/?0=zero&1=one', + }), options as any) + + expect(result).toBeDefined() + + await expect(result!.decodeInput()).resolves.toEqual({ + 0: 'zero', + 1: 'one', + tags: [], + }) + }) + + it('returns the deserialized result as-is when queryParsing is an empty object', async () => { + const codec = new OpenAPIHandlerCodec( + os.meta(openapi({ method: 'GET', queryStyles: { } })).handler(vi.fn()), + ) + + const result = await codec.resolveProcedure(createRequest({ + method: 'GET', + url: '/?0=zero&1=one', + }), options as any) + + expect(result).toBeDefined() + + await expect(result!.decodeInput()).resolves.toEqual({ 0: 'zero', 1: 'one' }) + }) + + it('falls back to the raw string value when json strategy cannot parse', async () => { + const codec = new OpenAPIHandlerCodec( + os.meta(openapi({ method: 'GET', queryStyles: { meta: 'json' } })).handler(vi.fn()), + ) + const result = await codec.resolveProcedure(createRequest({ + method: 'GET', + url: '/?meta=not-json', + }), options as any) + + expect(result).toBeDefined() + + await expect(result!.decodeInput()).resolves.toEqual({ + meta: 'not-json', + }) + }) + + it('applies all delimited parsing strategies using the last matching value', async () => { + const codec = new OpenAPIHandlerCodec( + os.meta(openapi({ + method: 'GET', + queryStyles: { + commaArray: 'comma-delimited-array', + commaObject: 'comma-delimited-object', + spaceArray: 'space-delimited-array', + spaceObject: 'space-delimited-object', + pipeArray: 'pipe-delimited-array', + pipeObject: 'pipe-delimited-object', + }, + })).handler(vi.fn()), + ) + + const result = await codec.resolveProcedure(createRequest({ + method: 'GET', + url: '/?commaArray=skip&commaArray=red,blue%20sky,green%2Ftea&commaObject=skip&commaObject=first,1,second,two%20words&spaceArray=skip&spaceArray=alpha beta gamma%2Cdeta&spaceObject=skip&spaceObject=left 10 right twenty%2Cone&pipeArray=skip&pipeArray=north|south%20east|west%2Fcoast&pipeObject=skip&pipeObject=primary|1|secondary|two%20words', + }), options as any) + + expect(result).toBeDefined() + + await expect(result!.decodeInput()).resolves.toEqual({ + commaArray: ['red', 'blue sky', 'green/tea'], + commaObject: { first: '1', second: 'two words' }, + spaceArray: ['alpha', 'beta', 'gamma,deta'], + spaceObject: { left: '10', right: 'twenty,one' }, + pipeArray: ['north', 'south east', 'west/coast'], + pipeObject: { primary: '1', secondary: 'two words' }, + }) + }) + + it('parsing as undefined for delimited parsing strategies if query is absent', async () => { + const codec = new OpenAPIHandlerCodec( + os.meta(openapi({ + method: 'GET', + queryStyles: { + commaArray: 'comma-delimited-array', + commaObject: 'comma-delimited-object', + spaceArray: 'space-delimited-array', + spaceObject: 'space-delimited-object', + pipeArray: 'pipe-delimited-array', + pipeObject: 'pipe-delimited-object', + }, + })).handler(vi.fn()), + ) + + const result = await codec.resolveProcedure(createRequest({ + method: 'GET', + url: '/', + }), options as any) + + expect(result).toBeDefined() + + await expect(result!.decodeInput()).resolves.toEqual({ + commaArray: undefined, + commaObject: undefined, + spaceArray: undefined, + spaceObject: undefined, + pipeArray: undefined, + pipeObject: undefined, + }) + }) + + it('keeps default bracket-notation decoding when a parsing hint is undefined', async () => { + const codec = new OpenAPIHandlerCodec( + os.meta(openapi({ + method: 'GET', + queryStyles: { + keep: undefined, + tags: 'array', + }, + })).handler(vi.fn()), + ) + + const result = await codec.resolveProcedure(createRequest({ + method: 'GET', + url: '/?keep[enabled]=true&tags=red&tags=blue', + }), options as any) + + expect(result).toBeDefined() + + await expect(result!.decodeInput()).resolves.toEqual({ + keep: { enabled: 'true' }, + tags: ['red', 'blue'], + }) + }) + }) + }) + + describe('.encodeOutput', () => { + describe('outputStructure=compact', () => { + it('uses default 200 success status if successStatus is not defined', () => { + const serializer = { + serialize: vi.fn().mockReturnValueOnce('__serialized__'), + deserialize: vi.fn(), + } + + const procedure = os.handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure, { serializer }) + + const response = codec.encodeOutput('__output__', procedure, [], options) + + expect(response).toEqual({ + status: 200, + headers: {}, + body: '__serialized__', + }) + + expect(serializer.serialize).toHaveBeenCalledOnce() + expect(serializer.serialize).toHaveBeenCalledWith('__output__') + }) + + it('uses successStatus if defined', () => { + const serializer = { + serialize: vi.fn().mockReturnValueOnce('__serialized__'), + deserialize: vi.fn(), + } + + const procedure = os.meta(openapi({ successStatus: 201 })).handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure, { serializer }) + + const response = codec.encodeOutput('__output__', procedure, [], options) + + expect(response).toEqual({ + status: 201, + headers: {}, + body: '__serialized__', + }) + + expect(serializer.serialize).toHaveBeenCalledOnce() + expect(serializer.serialize).toHaveBeenCalledWith('__output__') + }) + }) + + describe('outputStructure=detailed', () => { + it('uses default 200 success status meta when output not contain status', () => { + const serializer = { + serialize: vi.fn().mockReturnValueOnce('__serialized_body__'), + deserialize: vi.fn(), + } as any + + const procedure = os.meta(openapi({ outputStructure: 'detailed' })).handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure, { serializer }) + + const response = codec.encodeOutput({ + body: { ok: true }, + }, procedure, ['detailed'], options as any) + + expect(response).toEqual({ + status: 200, + headers: {}, + body: '__serialized_body__', + }) + + expect(serializer.serialize).toHaveBeenCalledOnce() + expect(serializer.serialize).toHaveBeenCalledWith({ ok: true }) + }) + + it('uses the successStatus meta when output not contain status', () => { + const serializer = { + serialize: vi.fn().mockReturnValueOnce('__serialized_body__'), + deserialize: vi.fn(), + } as any + + const procedure = os.meta(openapi({ outputStructure: 'detailed', successStatus: 201 })).handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure, { serializer }) + + const response = codec.encodeOutput({ + body: { ok: true }, + }, procedure, ['detailed'], options as any) + + expect(response).toEqual({ + status: 201, + headers: {}, + body: '__serialized_body__', + }) + + expect(serializer.serialize).toHaveBeenCalledOnce() + expect(serializer.serialize).toHaveBeenCalledWith({ ok: true }) + }) + + it('uses explicit status and headers from output', () => { + const serializer = { + serialize: vi.fn().mockReturnValueOnce('__serialized_body__'), + deserialize: vi.fn(), + } as any + + const procedure = os.meta(openapi({ outputStructure: 'detailed' })).handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure, { serializer }) + + const response = codec.encodeOutput({ + status: 202, + headers: { 'x-custom': 'value' }, + body: { ok: true }, + }, procedure, [], options as any) + + expect(response).toEqual({ + status: 202, + headers: { 'x-custom': 'value' }, + body: '__serialized_body__', + }) + }) + + it.each([ + ['non-object output', '__invalid__'], + ['status outside the allowed range', { status: 500 }], + ['extra keys', { body: 'ok', extra: true }], + ['invalid headers', { headers: { 'x-invalid': 123 } }], + ])('throws for invalid output: %s', (_, output) => { + const procedure = os.meta(openapi({ outputStructure: 'detailed' })).handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure) + + expect(() => codec.encodeOutput(output, procedure, [], options as any)).toThrow('Invalid "detailed" output structure') + }) + }) + }) + + describe('.encodeError', () => { + it('maps known error codes to HTTP status via COMMON_ERROR_STATUS_MAP', () => { + const serializer = { + serialize: vi.fn().mockReturnValueOnce('__serialized_error__'), + deserialize: vi.fn(), + } as any + + const procedure = os.handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure, { serializer }) + const error = new ORPCError('BAD_GATEWAY') + + const response = codec.encodeError(error, procedure, [], options as any) + + expect(response).toEqual({ + status: 502, + headers: {}, + body: '__serialized_error__', + }) + + expect(serializer.serialize).toHaveBeenCalledOnce() + expect(serializer.serialize).toHaveBeenCalledWith(error.toJSON()) + }) + + it('uses customErrorResponseBodyEncoder and falls back on null', () => { + let attempt = 1 + const customErrorResponseBodyEncoder = vi.fn(() => { + if (attempt++ === 2) { + return null + } + + return { + message: 'custom error body', + } + }) + + const serializer = { + serialize: vi.fn() + .mockReturnValueOnce('__serialized_custom__') + .mockReturnValueOnce('__serialized_default__'), + deserialize: vi.fn(), + } as any + + const procedure = os.handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure, { + serializer, + customErrorResponseBodyEncoder, + }) + + const firstError = new ORPCError('BAD_GATEWAY', { data: '__data1__' }) + const firstResponse = codec.encodeError(firstError, procedure, [], options as any) + + expect(firstResponse).toEqual({ + status: 502, + headers: {}, + body: '__serialized_custom__', + }) + + const secondError = new ORPCError('UNKNOWN_CODE' as any, { data: '__data2__' }) + const secondResponse = codec.encodeError(secondError, procedure, [], options as any) + + expect(secondResponse).toEqual({ + status: DEFAULT_ERROR_STATUS, + headers: {}, + body: '__serialized_default__', + }) + + expect(customErrorResponseBodyEncoder).toHaveBeenCalledTimes(2) + expect(customErrorResponseBodyEncoder).toHaveBeenNthCalledWith(1, firstError) + expect(customErrorResponseBodyEncoder).toHaveBeenNthCalledWith(2, secondError) + + expect(serializer.serialize).toHaveBeenCalledTimes(2) + expect(serializer.serialize).toHaveBeenNthCalledWith(1, { message: 'custom error body' }) + expect(serializer.serialize).toHaveBeenNthCalledWith(2, secondError.toJSON()) + }) + + it('can custom error status via errorStatuses option', () => { + const serializer = { + serialize: vi.fn().mockReturnValueOnce('__serialized_override__'), + deserialize: vi.fn(), + } as any + + const procedure = os.handler(vi.fn()) + const codec = new OpenAPIHandlerCodec({ procedure }, { + serializer, + errorStatusMap: { BAD_GATEWAY: 599 }, + }) + + const error = new ORPCError('BAD_GATEWAY') + const response = codec.encodeError(error, procedure, ['procedure'], options as any) + + expect(response).toEqual({ + status: 599, + headers: {}, + body: '__serialized_override__', + }) + }) + + it('fallback unknown error code to DEFAULT_ERROR_STATUS', () => { + const serializer = { + serialize: vi.fn() + .mockReturnValueOnce('__serialized_unknown__'), + deserialize: vi.fn(), + } as any + + const procedure = os.handler(vi.fn()) + const codec = new OpenAPIHandlerCodec({ procedure }, { + serializer, + errorStatusMap: {}, + }) + + const unknownError = new ORPCError('UNKNOWN_CODE' as any) + const unknownResponse = codec.encodeError(unknownError, procedure, [], options as any) + + expect(unknownResponse).toEqual({ + status: DEFAULT_ERROR_STATUS, + headers: {}, + body: '__serialized_unknown__', + }) + }) + }) +}) diff --git a/packages/openapi/src/adapters/standard/openapi-handler-codec.ts b/packages/openapi/src/adapters/standard/openapi-handler-codec.ts new file mode 100644 index 000000000..3e403ab2a --- /dev/null +++ b/packages/openapi/src/adapters/standard/openapi-handler-codec.ts @@ -0,0 +1,307 @@ +import type { AnyORPCError } from '@orpc/client' +import type { AnyProcedure, AnyRouter, Context } from '@orpc/server' +import type { StandardHandlerCodec, StandardHandlerCodecResolvedProcedure, StandardHandlerHandleOptions } from '@orpc/server/standard' +import type { Promisable } from '@orpc/shared' +import type { StandardHeaders, StandardLazyRequest, StandardResponse } from '@standardserver/core' +import type { OpenAPIMeta } from '../../meta' +import type { OpenAPIMatcherOptions } from './openapi-matcher' +import { COMMON_ERROR_STATUS_MAP } from '@orpc/client' +import { DEFAULT_ERROR_STATUS, DEFAULT_SUCCESS_STATUS } from '@orpc/server' +import { isPlainObject, isTypescriptObject, NullProtoObj, parseEmptyableJSON, stringifyJSON } from '@orpc/shared' +import { isStandardHeaders, parseStandardUrl } from '@standardserver/core' +import { + DEFAULT_OPENAPI_INPUT_STRUCTURE, + DEFAULT_OPENAPI_OUTPUT_STRUCTURE, +} from '../../constants' +import { getOpenAPIMeta } from '../../meta' +import { OpenAPISerializer } from '../../openapi-serializer' +import { OpenAPIMatcher } from './openapi-matcher' + +export class OpenAPIHandlerCodecError extends TypeError {} + +export interface OpenAPIHandlerCodecOptions<_T extends Context> extends OpenAPIMatcherOptions { + /** + * Override the default OpenAPI serializer. + */ + serializer?: Pick + + /** + * Mapping ORPCError Code -> HTTP Status Code + * + * @default COMMON_ERROR_STATUS_MAP, DEFAULT_ERROR_STATUS + */ + errorStatusMap?: Record | undefined + + /** + * Customize how an ORPC error is serialized into a response body. + * Use this if your API needs a different error output structure. + * + * @remarks + * - Return `null | undefined` to fallback to default behavior + * + * @default ((e) => e.toJSON()) + */ + customErrorResponseBodyEncoder?: (error: AnyORPCError) => unknown +} + +export class OpenAPIHandlerCodec implements StandardHandlerCodec { + private readonly matcher: OpenAPIMatcher + private readonly serializer: Pick + private readonly errorStatusMap: Exclude['errorStatusMap'], undefined> + private readonly customErrorResponseBodySerializer: OpenAPIHandlerCodecOptions['customErrorResponseBodyEncoder'] + + constructor(router: AnyRouter, options: OpenAPIHandlerCodecOptions = {}) { + this.matcher = new OpenAPIMatcher(router, options) + this.serializer = options.serializer ?? new OpenAPISerializer() + this.errorStatusMap = options.errorStatusMap ?? COMMON_ERROR_STATUS_MAP + this.customErrorResponseBodySerializer = options.customErrorResponseBodyEncoder + } + + async resolveProcedure(request: StandardLazyRequest, options: StandardHandlerHandleOptions): Promise { + const [pathname, search] = parseStandardUrl(request.url) + + const matched = await this.matcher.match(request.method, pathname, options.prefix) + + if (!matched) { + return undefined + } + + return { + procedure: matched.procedure, + path: matched.path, + decodeInput: async () => { + const meta = getOpenAPIMeta(matched.procedure) + const inputStructure = meta?.inputStructure ?? DEFAULT_OPENAPI_INPUT_STRUCTURE + const params = this.deserializeParams(matched.params, meta?.paramsStyles) + const query = this.deserializeQuery(search, meta?.queryStyles) + + if (inputStructure === 'compact') { + const data = request.method === 'GET' + ? query + : this.serializer.deserialize(await request.resolveBody(meta?.requestBodyHint)) + + if (data === undefined) { + return params + } + + if (!params || Object.keys(params).length < 1) { + return data + } + + if (isPlainObject(data)) { + return { + ...params, + ...data, + } + } + + // data can be Blob, Event Iterator, ReadableStream, ... + return data + } + + return { + params, + query, + headers: request.headers, + body: this.serializer.deserialize(await request.resolveBody(meta?.requestBodyHint)), + } + }, + } + } + + /** + * @throws {Error} If `outputStructure` is "detailed" and the output doesn't match the expected structure. + */ + encodeOutput(output: unknown, procedure: AnyProcedure, path: string[], _options: StandardHandlerHandleOptions): Promisable { + const meta = getOpenAPIMeta(procedure) + const successStatus = meta?.successStatus ?? DEFAULT_SUCCESS_STATUS + const outputStructure = meta?.outputStructure ?? DEFAULT_OPENAPI_OUTPUT_STRUCTURE + + if (outputStructure === 'compact') { + return { + status: successStatus, + headers: {}, + body: this.serializer.serialize(output), + } + } + + if (!isValidDetailedOutput(output)) { + throw new OpenAPIHandlerCodecError(` + Invalid "detailed" output structure returned by procedure (${path.join('.')}): + • Expected an object with optional properties: + - status (number 200-399) + - headers (Record) + - body (any) + • No extra keys allowed. + + Actual value: + ${stringifyJSON(output)} + `) + } + + return { + status: output.status ?? successStatus, + headers: output.headers ?? {}, + body: this.serializer.serialize(output.body), + } + } + + encodeError(error: AnyORPCError, _procedure: AnyProcedure, _path: string[], _options: StandardHandlerHandleOptions): Promisable { + const status = this.errorStatusMap[error.code] ?? DEFAULT_ERROR_STATUS + + return { + status, + headers: {}, + body: this.serializer.serialize(this.customErrorResponseBodySerializer?.(error) ?? error.toJSON()), + } + } + + private deserializeQuery( + search: `?${string}` | undefined, + styles: OpenAPIMeta['queryStyles'], + ): unknown { + const searchParams = new URLSearchParams(search) + const parsed = this.serializer.deserialize(searchParams) + + if (!styles || !isPlainObject(parsed)) { + return parsed + } + + Object.entries(styles).forEach(([key, hint]) => { + if (hint === undefined) { + return + } + + const values = searchParams.getAll(key) + let parsedValue: unknown + + if (hint === 'primitive') { + parsedValue = values.at(-1) + } + + else if (hint === 'array') { + parsedValue = values + } + + else if (hint === 'comma-delimited-array') { + parsedValue = decodeDelimitedArray(values.at(-1), ',') + } + + else if (hint === 'comma-delimited-object') { + parsedValue = decodeDelimitedObject(values.at(-1), ',') + } + + else if (hint === 'space-delimited-array') { + parsedValue = decodeDelimitedArray(values.at(-1), ' ') + } + + else if (hint === 'space-delimited-object') { + parsedValue = decodeDelimitedObject(values.at(-1), ' ') + } + + else if (hint === 'pipe-delimited-array') { + parsedValue = decodeDelimitedArray(values.at(-1), '|') + } + + else if (hint === 'pipe-delimited-object') { + parsedValue = decodeDelimitedObject(values.at(-1), '|') + } + + else { + const _expect: 'json' = hint + + const last = values.at(-1) + + try { + parsedValue = parseEmptyableJSON(last) + } + catch { + parsedValue = last + } + } + + parsed[key] = this.serializer.deserialize(parsedValue) + }) + + return parsed + } + + private deserializeParams( + params: Record | undefined, + styles: OpenAPIMeta['paramsStyles'], + ): Record | undefined { + if (!params || !styles) { + return params + } + + const parsed: Record = { ...params } + + Object.entries(styles).forEach(([key, hint]) => { + if (hint === undefined || hint === 'primitive') { + return + } + + const value = params[key] + + if (hint === 'comma-delimited-array') { + parsed[key] = decodeDelimitedArray(value, ',') + } + else { + const _expect: 'comma-delimited-object' = hint + + parsed[key] = decodeDelimitedObject(value, ',') + } + }) + + return parsed + } +} + +function isValidDetailedOutput(output: unknown): output is { status?: number, body?: unknown, headers?: StandardHeaders } { + if (!isTypescriptObject(output)) { + return false + } + + if (Object.keys(output).some(key => key !== 'status' && key !== 'headers' && key !== 'body')) { + return false + } + + if (output.status !== undefined && ( + typeof output.status !== 'number' + || !Number.isInteger(output.status) + || output.status < 200 + || output.status > 399 + )) { + return false + } + + if (output.headers !== undefined && !isStandardHeaders(output.headers)) { + return false + } + + return true +} + +function decodeDelimitedArray(value: string | undefined, delimiter: string): undefined | string[] { + if (value === undefined) { + return undefined + } + + return value.split(delimiter) +} + +function decodeDelimitedObject(value: string | undefined, delimiter: string): undefined | Record { + if (value === undefined) { + return undefined + } + + const obj = new NullProtoObj() // Prevent Prototype Pollution with NullProtoObj + const parts = value.split(delimiter) + + for (let i = 0; i < parts.length; i += 2) { + const key = parts[i]! + obj[key] = parts[i + 1] + } + + return obj +} diff --git a/packages/openapi/src/adapters/standard/openapi-handler.test.ts b/packages/openapi/src/adapters/standard/openapi-handler.test.ts deleted file mode 100644 index ee5c14f92..000000000 --- a/packages/openapi/src/adapters/standard/openapi-handler.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { os } from '@orpc/server' -import { StandardOpenAPIHandler } from './openapi-handler' - -describe('standardOpenAPIHandler', () => { - const handler = new StandardOpenAPIHandler(os.route({ method: 'GET', path: '/ping' }).handler(({ input }) => ({ output: input })), { - - }) - - it('works', async () => { - const { response } = await handler.handle({ - url: new URL('https://example.com/api/v1/ping?input=hello'), - body: () => Promise.resolve(undefined), - headers: {}, - method: 'GET', - signal: undefined, - }, { - prefix: '/api/v1', - context: {}, - }) - - expect(response!.body).toEqual({ output: { input: 'hello' } }) - }) -}) diff --git a/packages/openapi/src/adapters/standard/openapi-handler.ts b/packages/openapi/src/adapters/standard/openapi-handler.ts deleted file mode 100644 index 7a9676d53..000000000 --- a/packages/openapi/src/adapters/standard/openapi-handler.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { StandardBracketNotationSerializerOptions, StandardOpenAPIJsonSerializerOptions } from '@orpc/openapi-client/standard' -import type { Context, Router } from '@orpc/server' -import type { StandardHandlerOptions } from '@orpc/server/standard' -import type { StandardOpenAPICodecOptions } from './openapi-codec' -import type { StandardOpenAPIMatcherOptions } from './openapi-matcher' -import { StandardBracketNotationSerializer, StandardOpenAPIJsonSerializer, StandardOpenAPISerializer } from '@orpc/openapi-client/standard' -import { StandardHandler } from '@orpc/server/standard' -import { StandardOpenAPICodec } from './openapi-codec' -import { StandardOpenAPIMatcher } from './openapi-matcher' - -export interface StandardOpenAPIHandlerOptions - extends StandardHandlerOptions, StandardOpenAPIJsonSerializerOptions, - StandardBracketNotationSerializerOptions, StandardOpenAPIMatcherOptions, StandardOpenAPICodecOptions {} - -export class StandardOpenAPIHandler extends StandardHandler { - constructor(router: Router, options: NoInfer>) { - const jsonSerializer = new StandardOpenAPIJsonSerializer(options) - const bracketNotationSerializer = new StandardBracketNotationSerializer(options) - const serializer = new StandardOpenAPISerializer(jsonSerializer, bracketNotationSerializer) - const matcher = new StandardOpenAPIMatcher(options) - const codec = new StandardOpenAPICodec(serializer, options) - - super(router, matcher, codec, options) - } -} diff --git a/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts new file mode 100644 index 000000000..2b20e50ad --- /dev/null +++ b/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts @@ -0,0 +1,746 @@ +import { ORPCError } from '@orpc/client' +import { oc } from '@orpc/contract' +import { openapi } from '../../meta' +import { OpenAPISerializer } from '../../openapi-serializer' +import { OpenAPILinkCodec } from './openapi-link-codec' + +const serializer = new OpenAPISerializer() + +function expectORPCErrorResult( + result: any, + code: string, + options?: { + message?: string + data?: unknown + }, +) { + expect(result.kind).toBe('error') + expect(result.error).toBeInstanceOf(ORPCError) + expect(result.error.code).toBe(code) + + 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('openAPILinkCodec', () => { + describe('.encodeInput', () => { + describe('compact requests', () => { + it('builds a POST request with default method, path, and headers', async () => { + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({})), + }) + + const request = await codec.encodeInput('input', ['ping'], { context: {} }) + + expect(request).toEqual({ + method: 'POST', + url: '/ping', + headers: {}, + body: 'input', + signal: undefined, + }) + }) + + it('builds a GET request with a prefixed path and mixed query styles', async () => { + const codec = new OpenAPILinkCodec({ + item: oc.meta(openapi({ + method: 'GET', + prefix: '/v1', + path: '/items/{id}', + paramsStyles: { + id: 'primitive', + }, + queryStyles: { + keyword: 'primitive', + tags: 'array', + filters: 'comma-delimited-object', + meta: 'json', + }, + })), + }, { + url: '/api?existing=1#frag', + headers: { 'x-client': 'openapi' }, + serializer, + }) + + const request = await codec.encodeInput({ + id: '42', + keyword: 'latest', + tags: ['red', 'blue'], + filters: { size: 'large', brand: 'nike' }, + meta: { enabled: true }, + plain: { nested: true }, + file: new Blob(['file']), + }, ['item'], { context: {}, lastEventId: 'evt-1' }) + + expect(request.method).toBe('GET') + expect(request.body).toBeUndefined() + expect(request.headers).toEqual({ + 'x-client': 'openapi', + 'last-event-id': 'evt-1', + }) + + const url = new URL(request.url, 'http://localhost') + expect(url.hash).toBe('#frag') + expect(url.pathname).toBe('/api/v1/items/42') + expect(url.searchParams.get('existing')).toBe('1') + expect(url.searchParams.get('keyword')).toBe('latest') + expect(url.searchParams.getAll('tags')).toEqual(['red', 'blue']) + expect(url.searchParams.get('filters')).toBe('size,large,brand,nike') + expect(url.searchParams.get('meta')).toBe('{"enabled":true}') + expect(url.searchParams.get('plain[nested]')).toBe('true') + expect(url.searchParams.get('file')).toBe('[object File]') + }) + + it('rejects non-object input when dynamic path params must be resolved', async () => { + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({ path: '/items/{id}' })), + }, { serializer }) + + await expect(codec.encodeInput('invalid', ['ping'], { context: {} })).rejects.toThrow( + 'Input must be an object with "compact" input structure when the path has dynamic params (id) in call to procedure (ping).', + ) + }) + }) + + describe('detailed requests', () => { + it('builds a request with styled params, styled query, and merged headers', async () => { + const codec = new OpenAPILinkCodec({ + search: oc.meta(openapi({ + method: 'POST', + path: '/search/{tags}', + inputStructure: 'detailed', + paramsStyles: { + tags: 'comma-delimited-array', + }, + queryStyles: { + meta: 'json', + }, + })), + }, { + url: '/api', + headers: { 'x-base': '1' }, + serializer, + }) + + const request = await codec.encodeInput({ + params: { tags: ['alpha', 'beta'] }, + query: { meta: { enabled: true } }, + headers: { 'x-request': '2' }, + body: { title: 'Hello' }, + }, ['search'], { context: {} }) + + expect(request).toEqual({ + method: 'POST', + url: '/api/search/alpha,beta?meta=%7B%22enabled%22%3Atrue%7D', + headers: { 'x-request': '2', 'x-base': '1' }, + body: { title: 'Hello' }, + signal: undefined, + }) + }) + + it('uses base headers when detailed input omits the headers field', async () => { + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({ inputStructure: 'detailed' })), + }, { + url: '/api', + headers: { 'x-base': 'yes' }, + serializer, + }) + + const request = await codec.encodeInput({ body: 'data' }, ['ping'], { context: {} }) + + expect(request.headers).toEqual({ 'x-base': 'yes' }) + }) + + it('omits the body for GET requests while still serializing the query', async () => { + const codec = new OpenAPILinkCodec({ + search: oc.meta(openapi({ + method: 'GET', + inputStructure: 'detailed', + queryStyles: { q: 'primitive' }, + })), + }, { url: '/api', serializer }) + + const request = await codec.encodeInput({ + query: { q: 'hello' }, + body: 'should be omitted', + }, ['search'], { context: {} }) + + expect(request.method).toBe('GET') + expect(request.body).toBeUndefined() + expect(request.url).toBe('/api/search?q=hello') + }) + + it('rejects invalid detailed input shapes', async () => { + const codec = new OpenAPILinkCodec({ + search: oc.meta(openapi({ + method: 'GET', + path: '/users/{id}', + inputStructure: 'detailed', + })), + }, { serializer }) + + await expect(codec.encodeInput('invalid', ['search'], { context: {} })).rejects.toThrow( + 'Invalid "detailed" input structure in call to procedure (search):', + ) + + await expect(codec.encodeInput({ query: 'invalid' }, ['search'], { context: {} })).rejects.toThrow( + 'Invalid "detailed" input structure in call to procedure (search):', + ) + + await expect(codec.encodeInput({ headers: 'invalid' }, ['search'], { context: {} })).rejects.toThrow( + 'Invalid "detailed" input structure in call to procedure (search):', + ) + + await expect(codec.encodeInput({ params: 'invalid' }, ['search'], { context: {} })).rejects.toThrow( + 'Invalid "detailed" input structure in call to procedure (search):', + ) + }) + + it('requires params when the detailed path contains dynamic segments', async () => { + const codec = new OpenAPILinkCodec({ + item: oc.meta(openapi({ + path: '/items/{id}', + inputStructure: 'detailed', + })), + }, { serializer }) + + await expect(codec.encodeInput({}, ['item'], { context: {} })).rejects.toThrow( + 'The "params" property is required for "detailed" input when the path has dynamic params', + ) + + await expect(codec.encodeInput({ params: {} }, ['item'], { context: {} })).rejects.toThrow( + 'Path param "id" cannot be empty in call to procedure (item).', + ) + }) + }) + + describe('query serialization', () => { + it('serializes and preserves literal commas for comma-delimited styles', async () => { + const codec = new OpenAPILinkCodec({ + search: oc.meta(openapi({ + method: 'GET', + queryStyles: { + tags: 'comma-delimited-array', + filters: 'comma-delimited-object', + }, + })), + }, { + url: '/api', + serializer, + }) + + const request = await codec.encodeInput({ + tags: ['alpha/', 'beta'], + filters: { 'size/': 'large/', 'brand': 'nike' }, + }, ['search'], { context: {} }) + + expect(request.url).toBe('/api/search?tags=alpha%2F,beta&filters=size%2F,large%2F,brand,nike') + }) + + it('serializes space-delimited and pipe-delimited query styles', async () => { + const spaceDelimitedCodec = new OpenAPILinkCodec({ + search: oc.meta(openapi({ + method: 'GET', + queryStyles: { + tags: 'space-delimited-array', + filters: 'space-delimited-object', + }, + })), + }, { url: '/api', serializer }) + + const pipeDelimitedCodec = new OpenAPILinkCodec({ + search: oc.meta(openapi({ + method: 'GET', + queryStyles: { + tags: 'pipe-delimited-array', + filters: 'pipe-delimited-object', + }, + })), + }, { url: '/api', serializer }) + + const spaceDelimitedRequest = await spaceDelimitedCodec.encodeInput({ + tags: ['a/', 'b'], + filters: { 'x/': '1/', 'y': '2' }, + }, ['search'], { context: {} }) + + const pipeDelimitedRequest = await pipeDelimitedCodec.encodeInput({ + tags: ['a/', 'b'], + filters: { 'x/': '1/', 'y': '2' }, + }, ['search'], { context: {} }) + + const spaceDelimitedUrl = new URL(spaceDelimitedRequest.url, 'http://localhost') + const pipeDelimitedUrl = new URL(pipeDelimitedRequest.url, 'http://localhost') + + expect(spaceDelimitedUrl.searchParams.get('tags')).toBe('a/ b') + expect(spaceDelimitedUrl.searchParams.get('filters')).toBe('x/ 1/ y 2') + expect(pipeDelimitedUrl.searchParams.get('tags')).toBe('a/|b') + expect(pipeDelimitedUrl.searchParams.get('filters')).toBe('x/|1/|y|2') + }) + + it('treats a scalar as primitive input for array/object query styles', async () => { + const codec = new OpenAPILinkCodec({ + search: oc.meta(openapi({ + method: 'GET', + queryStyles: { + commaArray: 'comma-delimited-array', + commaObject: 'comma-delimited-object', + spaceArray: 'space-delimited-array', + spaceObject: 'space-delimited-object', + pipeArray: 'pipe-delimited-array', + pipeObject: 'pipe-delimited-object', + }, + })), + }, { url: '/api', serializer }) + + const request = await codec.encodeInput({ + commaArray: 'value1', + commaObject: 'value2', + spaceArray: 'value3', + spaceObject: 'value4', + pipeArray: 'value5', + pipeObject: 'value6', + }, ['search'], { context: {} }) + + const searchParams = new URL(request.url, 'http://localhost').searchParams + + expect(searchParams.get('commaArray')).toBe('value1') + expect(searchParams.get('commaObject')).toBe('value2') + expect(searchParams.get('spaceArray')).toBe('value3') + expect(searchParams.get('spaceObject')).toBe('value4') + expect(searchParams.get('pipeArray')).toBe('value5') + expect(searchParams.get('pipeObject')).toBe('value6') + }) + + it('omits undefined values for delimiter-based query styles', async () => { + const codec = new OpenAPILinkCodec({ + search: oc.meta(openapi({ + method: 'GET', + queryStyles: { + c: 'comma-delimited-array', + d: 'comma-delimited-object', + e: 'space-delimited-array', + f: 'space-delimited-object', + g: 'pipe-delimited-array', + h: 'pipe-delimited-object', + }, + })), + }, { url: '/api', serializer }) + + const request = await codec.encodeInput({ + c: undefined, + d: undefined, + e: undefined, + f: undefined, + g: undefined, + h: undefined, + }, ['search'], { context: {} }) + + expect(request.url).toBe('/api/search') + }) + + it('omits empty values for delimiter-based query styles', async () => { + const codec = new OpenAPILinkCodec({ + search: oc.meta(openapi({ + method: 'GET', + queryStyles: { + commaArray: 'comma-delimited-array', + commaObject: 'comma-delimited-object', + spaceArray: 'space-delimited-array', + spaceObject: 'space-delimited-object', + pipeArray: 'pipe-delimited-array', + pipeObject: 'pipe-delimited-object', + }, + })), + }, { url: '/api', serializer }) + + const request = await codec.encodeInput({ + commaArray: [], + commaObject: {}, + spaceArray: [], + spaceObject: {}, + pipeArray: [], + pipeObject: {}, + }, ['search'], { context: {} }) + + expect(request.url).toBe('/api/search') + }) + + it('serializes compact GET input as a query when no explicit query styles are defined', async () => { + const codec = new OpenAPILinkCodec({ + list: oc.meta(openapi({ method: 'GET' })), + }, { url: '/api', serializer }) + + const request = await codec.encodeInput({ page: '2' }, ['list'], { context: {} }) + + expect(request.method).toBe('GET') + expect(request.body).toBeUndefined() + expect(new URL(request.url, 'http://localhost').searchParams.get('page')).toBe('2') + }) + + it('keeps compact GET requests without input free of a query string', async () => { + const codec = new OpenAPILinkCodec({ + list: oc.meta(openapi({ method: 'GET' })), + }, { url: '/api', serializer }) + + const request = await codec.encodeInput(undefined, ['list'], { context: {} }) + + expect(request.url).toBe('/api/list') + expect(request.body).toBeUndefined() + }) + + it('filter null or undefined in styled query', async () => { + const codec = new OpenAPILinkCodec({ + search: oc.meta(openapi({ + method: 'GET', + queryStyles: { + passthrough: undefined, + optional: 'primitive', + list: 'array', + payload: 'json', + }, + })), + }, { url: '/api?existing=1', serializer }) + + const request = await codec.encodeInput({ + passthrough: 'keep', + optional: null, + list: [undefined, 'value'], + payload: undefined, + }, ['search'], { context: {} }) + + const url = new URL(request.url, 'http://localhost') + + expect(url.searchParams.get('existing')).toBe('1') + expect(url.searchParams.get('passthrough')).toBe('keep') + expect(url.searchParams.has('optional')).toBe(false) + expect(url.searchParams.getAll('list')).toEqual(['value']) + expect(url.searchParams.has('payload')).toBe(false) + }) + + it('preserves the base search when a compact GET request has no additional query', async () => { + const codec = new OpenAPILinkCodec({ + search: oc.meta(openapi({ method: 'GET' })), + }, { url: '/api?existing=1#frag', serializer }) + + const request = await codec.encodeInput(undefined, ['search'], { context: {} }) + + expect(request.url).toBe('/api/search?existing=1#frag') + }) + }) + + describe('path param serialization', () => { + it('serializes and preserves literal commas for comma-delimited array/object params in the path', async () => { + const codec = new OpenAPILinkCodec({ + item: oc.meta(openapi({ + path: '/items/{ids}/{filter}', + paramsStyles: { ids: 'comma-delimited-array', filter: 'comma-delimited-object' }, + })), + }, { url: '/api', serializer }) + + const request = await codec.encodeInput({ ids: ['a/', 'b', 'c'], filter: { 'color/': 'red/', 'size': 'xs' } }, ['item'], { context: {} }) + + expect(request.url).toBe('/api/items/a%2F,b,c/color%2F,red%2F,size,xs') + }) + + it('serializes mixed dynamic path params', async () => { + const codec = new OpenAPILinkCodec({ + item: oc.meta(openapi({ + path: '/items/{id}/{+rest}', + })), + }, { url: '/api', serializer }) + + const request = await codec.encodeInput({ + id: 'a/b', + rest: 'docs/v1/read me', + }, ['item'], { context: {} }) + + expect(request.url).toBe('/api/items/a%2Fb/docs/v1/read%20me') + }) + + it('treats scalar values as primitive for array/object params', async () => { + const codec = new OpenAPILinkCodec({ + item: oc.meta(openapi({ + path: '/items/{ids}/{filter}', + paramsStyles: { ids: 'comma-delimited-array', filter: 'comma-delimited-object' }, + })), + }, { url: '/api', serializer }) + + const request = await codec.encodeInput({ ids: 'single1', filter: 'single2' }, ['item'], { context: {} }) + + expect(request.url).toBe('/api/items/single1/single2') + }) + + it('throws when empty path params', async () => { + const primitiveCodec = new OpenAPILinkCodec({ + item: oc.meta(openapi({ + path: '/items/{id}', + })), + }, { url: '/api', serializer }) + + await expect(primitiveCodec.encodeInput({ id: '' }, ['item'], { context: {} })).rejects.toThrow( + 'Path param "id" cannot be empty in call to procedure (item).', + ) + + const arrayCodec = new OpenAPILinkCodec({ + item: oc.meta(openapi({ + path: '/items/{id}', + paramsStyles: { id: 'comma-delimited-array' }, + })), + }, { url: '/api', serializer }) + + await expect(arrayCodec.encodeInput({ id: [] }, ['item'], { context: {} })).rejects.toThrow( + 'Path param "id" cannot be empty in call to procedure (item).', + ) + await expect(arrayCodec.encodeInput({ id: '' }, ['item'], { context: {} })).rejects.toThrow( + 'Path param "id" cannot be empty in call to procedure (item).', + ) + + const objectCodec = new OpenAPILinkCodec({ + item: oc.meta(openapi({ + path: '/items/{filter}', + paramsStyles: { filter: 'comma-delimited-object' }, + })), + }, { url: '/api', serializer }) + + await expect(objectCodec.encodeInput({ filter: {} }, ['item'], { context: {} })).rejects.toThrow( + 'Path param "filter" cannot be empty in call to procedure (item).', + ) + await expect(objectCodec.encodeInput({ filter: '' }, ['item'], { context: {} })).rejects.toThrow( + 'Path param "filter" cannot be empty in call to procedure (item).', + ) + }) + }) + + describe('option handling', () => { + it('accepts Headers instances for base headers', async () => { + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({})), + }, { + headers: new Headers({ 'x-token': 'abc' }), + serializer, + }) + + const request = await codec.encodeInput('input', ['ping'], { context: {} }) + + expect(request.headers['x-token']).toBe('abc') + }) + + it('rejects unresolved procedure paths', async () => { + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({})), + }, { serializer }) + + await expect(codec.encodeInput('input', ['nonexistent'], { context: {} })).rejects.toThrow( + 'Expected a procedure or contract at path (nonexistent)', + ) + }) + }) + }) + + describe('.decodeResponse', () => { + it('returns compact output bodies directly', async () => { + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({ outputStructure: 'compact', responseBodyHint: 'json' })), + }, { serializer }) + + const resolveBody = vi.fn(async () => ({ ok: true })) + const result = await codec.decodeResponse({ + status: 201, + headers: { 'x-trace': '1' }, + resolveBody, + }, ['ping'], { context: {} }) + + expect(result).toEqual({ + kind: 'output', + output: { ok: true }, + }) + + expect(resolveBody).toHaveBeenCalledTimes(1) + expect(resolveBody).toHaveBeenCalledWith('json') + }) + + it('returns detailed output bodies with status and headers', async () => { + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({ outputStructure: 'detailed', responseBodyHint: 'json' })), + }, { serializer }) + + const resolveBody = vi.fn(async () => ({ ok: true })) + const result = await codec.decodeResponse({ + status: 202, + headers: { 'x-trace': '1' }, + resolveBody, + }, ['ping'], { context: {} }) + + expect(result).toEqual({ + kind: 'output', + output: { + status: 202, + headers: { 'x-trace': '1' }, + body: { ok: true }, + }, + }) + + expect(resolveBody).toHaveBeenCalledTimes(1) + expect(resolveBody).toHaveBeenCalledWith('json') + }) + + it('defaults successful responses to compact output when outputStructure is omitted', async () => { + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({})), + }, { serializer }) + + const resolveBody = vi.fn(async () => undefined) + const result = await codec.decodeResponse({ + status: 200, + headers: { 'x-trace': '1' }, + resolveBody, + }, ['ping'], { context: {} }) + + expect(result).toEqual({ + kind: 'output', + output: undefined, + }) + + expect(resolveBody).toHaveBeenCalledTimes(1) + expect(resolveBody).toHaveBeenCalledWith(undefined) + }) + + it('decodes ORPC error payloads from unsuccessful responses', async () => { + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({})), + }, { serializer }) + + const error = new ORPCError('NOT_FOUND', { + message: 'Missing', + data: { id: '42' }, + }) + + const result = await codec.decodeResponse({ + status: 404, + headers: {}, + resolveBody: async () => error.toJSON(), + }, ['ping'], { context: {} }) + + expectORPCErrorResult(result, 'NOT_FOUND', { + message: 'Missing', + data: { id: '42' }, + }) + }) + + it('uses a custom error decoder when it returns a value', async () => { + const customError = new ORPCError('BAD_GATEWAY', { data: 'custom' }) + const customErrorResponseBodyDecoder = vi.fn(() => customError) + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({})), + }, { + serializer, + customErrorResponseBodyDecoder, + }) + + const response = { + status: 502, + headers: { 'x-trace': '1' }, + resolveBody: async () => ({ detail: 'bad gateway' }), + } + + const result = await codec.decodeResponse(response, ['ping'], { context: {} }) + + expect(result).toEqual({ kind: 'error', error: customError }) + expect(customErrorResponseBodyDecoder).toHaveBeenCalledOnce() + expect(customErrorResponseBodyDecoder).toHaveBeenCalledWith({ detail: 'bad gateway' }, response) + }) + + it('falls back to standard ORPC error decoding when the custom decoder returns null', async () => { + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({})), + }, { + serializer, + customErrorResponseBodyDecoder: () => null, + }) + + const error = new ORPCError('NOT_FOUND', { message: 'Not found' }) + + const result = await codec.decodeResponse({ + status: 404, + headers: {}, + resolveBody: async () => error.toJSON(), + }, ['ping'], { context: {} }) + + expectORPCErrorResult(result, 'NOT_FOUND') + }) + + it('wraps unknown error payloads in a generic MALFORMED_ORPC_ERROR_RESPONSE ORPCError', async () => { + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({})), + }, { serializer }) + + const result = await codec.decodeResponse({ + status: 503, + headers: {}, + resolveBody: async () => ({ detail: 'service unavailable' }), + }, ['ping'], { context: {} }) + + expectORPCErrorResult(result, 'MALFORMED_ORPC_ERROR_RESPONSE', { data: { status: 503, headers: {}, body: { detail: 'service unavailable' } } }) + }) + + it('throws when the response body cannot be read', async () => { + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({})), + }, { serializer }) + + await expect(codec.decodeResponse({ + status: 200, + headers: {}, + resolveBody: async () => { + throw new Error('network error') + }, + }, ['ping'], { context: {} })).rejects.toThrow('Cannot parse response body') + }) + + it('throws when the deserialized response body has an invalid format', async () => { + const badSerializer: any = { + ...serializer, + deserialize: () => { + throw new Error('bad format') + }, + } + + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({})), + }, { + serializer: badSerializer, + }) + + await expect(codec.decodeResponse({ + status: 200, + headers: {}, + resolveBody: async () => 'raw', + }, ['ping'], { context: {} })).rejects.toThrow('Invalid OpenAPI response format') + }) + + it('rejects unresolved procedure paths', async () => { + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({})), + }, { serializer }) + + await expect(codec.decodeResponse({ + status: 200, + headers: {}, + resolveBody: async () => 'raw', + }, ['not-exists'], { context: {} })).rejects.toThrow( + 'Expected a procedure or contract at path (not-exists)', + ) + }) + }) +}) diff --git a/packages/openapi/src/adapters/standard/openapi-link-codec.ts b/packages/openapi/src/adapters/standard/openapi-link-codec.ts new file mode 100644 index 000000000..61aac7ab3 --- /dev/null +++ b/packages/openapi/src/adapters/standard/openapi-link-codec.ts @@ -0,0 +1,534 @@ +import type { AnyORPCError, ClientContext, ClientOptions } from '@orpc/client' +import type { StandardLinkCodec, StandardLinkCodecDecodedResponse } from '@orpc/client/standard' +import type { AnyProcedureContract, RouterContract } from '@orpc/contract' +import type { Promisable, Value } from '@orpc/shared' +import type { StandardHeaders, StandardLazyResponse, StandardRequest, StandardResponse, StandardUrl } from '@standardserver/core' +import type { OpenAPIMeta } from '../../meta' +import { createORPCErrorFromJson, isORPCErrorJson, ORPCError } from '@orpc/client' +import { getRouterContract, ProcedureContract } from '@orpc/contract' +import { unlazy } from '@orpc/server' +import { isTypescriptObject, mergeHttpPath, pathToHttpPath, stringifyJSON, value } from '@orpc/shared' +import { isStandardHeaders, mergeStandardHeaders, parseStandardUrl } from '@standardserver/core' +import { toStandardHeaders } from '@standardserver/fetch' +import { + DEFAULT_OPENAPI_INPUT_STRUCTURE, + DEFAULT_OPENAPI_METHOD, + DEFAULT_OPENAPI_OUTPUT_STRUCTURE, +} from '../../constants' +import { getOpenAPIMeta } from '../../meta' +import { OpenAPISerializer } from '../../openapi-serializer' +import { getDynamicPathParams } from '../../utils' + +export class OpenAPILinkCodecError extends TypeError {} + +export interface OpenAPILinkCodecOptions { + /** + * Base URL for all requests, without origin. Should match the OpenAPI handler mount path. + * + * @example '/api' + * @default '/' + */ + url?: Value, [options: ClientOptions, path: string[], input: unknown]> + + /** + * Inject headers into the request. + */ + headers?: Value, [options: ClientOptions, path: string[], input: unknown]> + + /** + * Override the default OpenAPI serializer. + */ + serializer?: Pick + + /** + * Customize how an error response body is converted into an ORPC error. + * Return `null` or `undefined` to fall back to the default decoding behavior. + */ + customErrorResponseBodyDecoder?: ( + deserializedBody: unknown, + response: StandardLazyResponse, + ) => AnyORPCError | null | undefined +} + +const END_SLASH_REGEX = /\/$/ + +export class OpenAPILinkCodec implements StandardLinkCodec { + private readonly baseUrl: Exclude['url'], undefined> + private readonly headers: Exclude['headers'], undefined> + private readonly serializer: Exclude['serializer'], undefined> + private readonly customErrorResponseBodyDecoder: OpenAPILinkCodecOptions['customErrorResponseBodyDecoder'] + + constructor( + private readonly router: RouterContract, + options: OpenAPILinkCodecOptions = {}, + ) { + this.baseUrl = options.url ?? '/' + this.headers = options.headers ?? {} + this.serializer = options.serializer ?? new OpenAPISerializer() + this.customErrorResponseBodyDecoder = options.customErrorResponseBodyDecoder + } + + async encodeInput(input: unknown, path: string[], options: ClientOptions): Promise { + let headers = toResolvedStandardHeaders(await value(this.headers, options, path, input)) + if (options.lastEventId !== undefined) { + headers = mergeStandardHeaders(headers, { 'last-event-id': options.lastEventId }) + } + + const baseUrl = await value(this.baseUrl, options, path, input) + const procedure = await this.resolveProcedure(path) + const meta = getOpenAPIMeta(procedure) + + const method = meta?.method ?? DEFAULT_OPENAPI_METHOD + const inputStructure = meta?.inputStructure ?? DEFAULT_OPENAPI_INPUT_STRUCTURE + let pathname = meta?.path ?? pathToHttpPath(path) + if (meta?.prefix) { + pathname = mergeHttpPath(meta.prefix, pathname) + } + + const [basePathname, baseSearch, baseHash] = parseStandardUrl(baseUrl) + const dynamicParams = getDynamicPathParams(pathname) + + if (inputStructure === 'compact') { + let data = input + + if (dynamicParams?.length) { + if (!isTypescriptObject(input)) { + throw new OpenAPILinkCodecError( + `Input must be an object with "compact" input structure when the path has dynamic params (${dynamicParams.map(p => p.parameterName).join(', ')}) in call to procedure (${path.join('.')}).`, + ) + } + + const remaining = { ...input } + + for (let i = dynamicParams.length - 1; i >= 0; i--) { + const param = dynamicParams[i]! + const encoded = this.encodePathParam(input[param.parameterName], param, meta?.paramsStyles?.[param.parameterName], path) + pathname = `${pathname.slice(0, param.startIndex)}${encoded}${pathname.slice(param.startIndex + param.segment.length)}` as `/${string}` + delete remaining[param.parameterName] + } + + data = Object.keys(remaining).length > 0 ? remaining : undefined + } + + pathname = `${basePathname.replace(END_SLASH_REGEX, '')}${pathname}` as `/${string}` + + if (method === 'GET') { + const queryString = this.serializeQueryString(data, meta?.queryStyles) + const search = combineSearch(baseSearch, queryString) + const url = `${pathname}${search ?? ''}${baseHash ?? ''}` as StandardUrl + + return { + body: undefined, + method, + headers, + url, + signal: options.signal, + } + } + + const url = `${pathname}${baseSearch ?? ''}${baseHash ?? ''}` as StandardUrl + + return { + url, + method, + headers, + body: this.serializer.serialize(data), + signal: options.signal, + } + } + + if (!isValidDetailedInput(input)) { + throw new OpenAPILinkCodecError(` + Invalid "detailed" input structure in call to procedure (${path.join('.')}): + • Expected an object or undefined with optional properties: + - params (object, required when the path has dynamic params) + - query (object) + - headers (Record) + - body (any) + + Actual value: + ${stringifyJSON(input)} + `) + } + + if (dynamicParams?.length) { + if (!input?.params) { + throw new OpenAPILinkCodecError( + `The "params" property is required for "detailed" input when the path has dynamic params (${dynamicParams.map(p => p.parameterName).join(', ')}) in call to procedure (${path.join('.')}).`, + ) + } + + for (let i = dynamicParams.length - 1; i >= 0; i--) { + const param = dynamicParams[i]! + const val = input.params[param.parameterName] + const encoded = this.encodePathParam(val, param, meta?.paramsStyles?.[param.parameterName], path) + pathname = `${pathname.slice(0, param.startIndex)}${encoded}${pathname.slice(param.startIndex + param.segment.length)}` as `/${string}` + } + } + + if (input?.headers) { + headers = mergeStandardHeaders(headers, input.headers) + } + + pathname = `${basePathname.replace(END_SLASH_REGEX, '')}${pathname}` as `/${string}` + const queryString = this.serializeQueryString(input?.query, meta?.queryStyles) + const search = combineSearch(baseSearch, queryString) + const url = `${pathname}${search ?? ''}${baseHash ?? ''}` as StandardUrl + + if (method === 'GET') { + return { + body: undefined, + method, + headers, + url, + signal: options.signal, + } + } + + return { + url, + method, + headers, + body: this.serializer.serialize(input?.body), + signal: options.signal, + } + } + + private encodePathParam( + val: unknown, + param: { parameterName: string, allowsSlash: boolean, segment: string }, + style: Exclude[string], + path: string[], + ): string { + let encoded: string | undefined + + if (style === 'comma-delimited-array' && Array.isArray(val)) { + encoded = val + .map(val => this.serializer.serialize(val)) + .filter(val => val !== undefined && val !== null) + .map(val => encodeURIComponent(String(val))) + .join(',') + } + else if (style === 'comma-delimited-object' && isTypescriptObject(val)) { + encoded = Object.entries(val) + .map(([key, val]) => [key, this.serializer.serialize(val)]) + .filter(([, val]) => val !== undefined && val !== null) + .map(([key, val]) => `${encodeURIComponent(String(key))},${encodeURIComponent(String(val))}`) + .join(',') + } + else { + const serialized = this.serializer.serialize(val) + + if (serialized !== undefined && serialized !== null) { + if (param.allowsSlash) { + encoded = String(serialized).split('/').map(encodeURIComponent).join('/') + } + else { + encoded = encodeURIComponent(String(serialized)) + } + } + } + + if (!encoded) { + throw new OpenAPILinkCodecError(`Path param "${param.parameterName}" cannot be empty in call to procedure (${path.join('.')}).`) + } + + return encoded + } + + private serializeQueryString(data: unknown, queryStyles: OpenAPIMeta['queryStyles']): string | undefined { + if (!queryStyles || !isTypescriptObject(data)) { + return toURLSearchParams( + this.serializer.serialize(data, { asFormData: true }) as FormData, + ).toString() + } + + const remaining = { ...data } + let query = '' + + Object.entries(queryStyles).forEach(([key, style]) => { + if (style === undefined) { + return + } + + const value = remaining[key] + delete remaining[key] + + if (style === 'primitive') { + const serialized = this.serializer.serialize(value) + if (serialized !== undefined && serialized !== null) { + query += `&${encodeURLSearchParamComponent(key)}=${encodeURLSearchParamComponent(String(serialized))}` + } + } + + else if (style === 'array' && Array.isArray(value)) { + const encodedKey = encodeURLSearchParamComponent(key) + + value.forEach((v) => { + const s = this.serializer.serialize(v) + if (s !== undefined && s !== null) { + query += `&${encodedKey}=${encodeURLSearchParamComponent(String(s))}` + } + }) + } + + else if (style === 'json') { + const serialized = this.serializer.serialize(value) + + if (serialized !== undefined) { + query += `&${encodeURLSearchParamComponent(key)}=${encodeURLSearchParamComponent(stringifyJSON(serialized))}` + } + } + + else if (style === 'comma-delimited-array' && Array.isArray(value)) { + const encodedValue = encodeDelimitedArray( + value.map(v => this.serializer.serialize(v)), + ',', + ) + + if (encodedValue !== undefined) { + query += `&${encodeURLSearchParamComponent(key)}=${encodedValue}` + } + } + + else if (style === 'comma-delimited-object' && isTypescriptObject(value)) { + const encodedValue = encodeDelimitedObject( + Object.entries(value).map(([key, value]) => [key, this.serializer.serialize(value)]), + ',', + ) + + if (encodedValue !== undefined) { + query += `&${encodeURLSearchParamComponent(key)}=${encodedValue}` + } + } + + else if (style === 'pipe-delimited-array' && Array.isArray(value)) { + const encodedValue = encodeDelimitedArray( + value.map(v => this.serializer.serialize(v)), + '%7C' /* '/' */, + ) + + if (encodedValue !== undefined) { + query += `&${encodeURLSearchParamComponent(key)}=${encodedValue}` + } + } + + else if (style === 'pipe-delimited-object' && isTypescriptObject(value)) { + const encodedValue = encodeDelimitedObject( + Object.entries(value).map(([key, value]) => [key, this.serializer.serialize(value)]), + '%7C' /* '/' */, + ) + + if (encodedValue !== undefined) { + query += `&${encodeURLSearchParamComponent(key)}=${encodedValue}` + } + } + + else if (style === 'space-delimited-array' && Array.isArray(value)) { + const encodedValue = encodeDelimitedArray( + value.map(v => this.serializer.serialize(v)), + '%20' /* ' ' */, + ) + + if (encodedValue !== undefined) { + query += `&${encodeURLSearchParamComponent(key)}=${encodedValue}` + } + } + + else if (style === 'space-delimited-object' && isTypescriptObject(value)) { + const encodedValue = encodeDelimitedObject( + Object.entries(value).map(([key, value]) => [key, this.serializer.serialize(value)]), + '%20' /* ' ' */, + ) + + if (encodedValue !== undefined) { + query += `&${encodeURLSearchParamComponent(key)}=${encodedValue}` + } + } + + else { + const serialized = this.serializer.serialize(value) + if (serialized !== undefined && serialized !== null) { + query += `&${encodeURLSearchParamComponent(key)}=${encodeURLSearchParamComponent(String(serialized))}` + } + } + }) + + const form = this.serializer.serialize(remaining, { asFormData: true }) as FormData + query = `${toURLSearchParams(form).toString()}${query}` + + if (query.startsWith('&')) { + query = query.slice(1) + } + + return query || undefined + } + + async decodeResponse( + response: StandardLazyResponse, + path: string[], + _options: ClientOptions, + ): Promise { + const isOk = response.status >= 200 && response.status < 400 + const procedure = await this.resolveProcedure(path) + const meta = getOpenAPIMeta(procedure) + + const deserialized = await (async () => { + let isBodyOk = false + + try { + const body = await response.resolveBody(meta?.responseBodyHint) + + isBodyOk = true + + return this.serializer.deserialize(body) + } + catch (error) { + if (!isBodyOk) { + throw new Error('Cannot parse response body, please check the response body and content-type.', { + cause: error, + }) + } + + throw new Error('Invalid OpenAPI response format.', { + cause: error, + }) + } + })() + + if (!isOk) { + const customError = this.customErrorResponseBodyDecoder?.(deserialized, response) + + if (customError !== undefined && customError !== null) { + return { kind: 'error', error: customError } + } + + if (isORPCErrorJson(deserialized)) { + return { kind: 'error', error: createORPCErrorFromJson(deserialized) } + } + + return { + kind: 'error', + error: new ORPCError<'MALFORMED_ORPC_ERROR_RESPONSE', StandardResponse>('MALFORMED_ORPC_ERROR_RESPONSE', { + data: { headers: response.headers, status: response.status, body: deserialized }, + }), + } + } + + const outputStructure = meta?.outputStructure ?? DEFAULT_OPENAPI_OUTPUT_STRUCTURE + + return outputStructure === 'compact' + ? { kind: 'output', output: deserialized } + : { + kind: 'output', + output: { + status: response.status, + headers: response.headers, + body: deserialized, + }, + } + } + + private async resolveProcedure(path: string[]): Promise { + const { default: maybeProcedure } = await unlazy(getRouterContract(this.router, path)) + + if (!(maybeProcedure instanceof ProcedureContract)) { + throw new OpenAPILinkCodecError(`Expected a procedure or contract at path (${path.join('.')})`) + } + + return maybeProcedure + } +} + +function combineSearch(baseSearch: `?${string}` | undefined, additionalSearch: string | undefined): `?${string}` | undefined { + if (!baseSearch && !additionalSearch) { + return undefined + } + + if (!additionalSearch) { + return baseSearch + } + + if (!baseSearch) { + return `?${additionalSearch}` as `?${string}` + } + + return `${baseSearch}&${additionalSearch}` as `?${string}` +} + +function toResolvedStandardHeaders(headers: Headers | StandardHeaders): StandardHeaders { + /** + * Headers class might not be available in some environments, + * so we check for the existence of `forEach` and `get` + * methods to determine if it's a Headers instance. + */ + if (typeof headers.forEach === 'function') { + return toStandardHeaders(headers as Headers) + } + + return headers as StandardHeaders +} + +function isValidDetailedInput( + input: unknown, +): input is undefined | { params?: Record, query?: Record, headers?: StandardHeaders, body?: unknown } { + if (!isTypescriptObject(input)) { + return input === undefined + } + + if (input.params !== undefined && !isTypescriptObject(input.params)) { + return false + } + + if (input.query !== undefined && !isTypescriptObject(input.query)) { + return false + } + + if (input.headers !== undefined && !isStandardHeaders(input.headers)) { + return false + } + + return true +} + +/** + * Encode a query parameter value using URLSearchParams semantics. + * Prefer this over encodeURIComponent for query-string values. + */ +function encodeURLSearchParamComponent(value: string): string { + return new URLSearchParams({ '': value }).toString().slice(1) +} + +function toURLSearchParams(form: FormData): URLSearchParams { + const params = new URLSearchParams() + for (const [key, value] of form) { + params.append(key, String(value)) + } + return params +} + +function encodeDelimitedArray(serializedValues: unknown[], encodedDelimiter: string): string | undefined { + const strings = serializedValues.filter(v => v !== null && v !== undefined).map(String) + + if (!strings.length) { + return undefined + } + + return strings.map(encodeURLSearchParamComponent).join(encodedDelimiter) +} + +function encodeDelimitedObject(entries: [string, unknown][], encodedDelimiter: string): string | undefined { + const strings = entries + .filter(([v]) => v !== null && v !== undefined) + .map(([k, v]) => [k, String(v)]) as [string, string][] + + if (!strings.length) { + return undefined + } + + return strings + .map( + ([key, value]) => `${encodeURLSearchParamComponent(key)}${encodedDelimiter}${encodeURLSearchParamComponent(value)}`, + ) + .join(encodedDelimiter) +} diff --git a/packages/openapi/src/adapters/standard/openapi-matcher.test.ts b/packages/openapi/src/adapters/standard/openapi-matcher.test.ts index cdaefb0a1..f7b866527 100644 --- a/packages/openapi/src/adapters/standard/openapi-matcher.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-matcher.test.ts @@ -1,262 +1,307 @@ -import { implement, lazy, os, Procedure, unlazy } from '@orpc/server' -import { router as contract } from '../../../../contract/tests/shared' -import { ping, pong } from '../../../../server/tests/shared' -import { StandardOpenAPIMatcher } from './openapi-matcher' - -const routedPing = new Procedure({ - ...ping['~orpc'], - route: { - method: 'DELETE', - path: '/ping/{ping}', - }, -}) +import { oc } from '@orpc/contract' +import { os, withHiddenRouterContract } from '@orpc/server' +import { getOpenAPIMeta, openapi } from '../../meta' +import { OpenAPIMatcher } from './openapi-matcher' -const routedPong = new Procedure({ - ...pong['~orpc'], - route: { - method: 'GET', - path: '/pong/{pong}', - }, +beforeEach(() => { + vi.clearAllMocks() }) -const router = { - ping, - pong: lazy(() => Promise.resolve({ default: routedPong })), - nested: lazy(() => Promise.resolve({ - default: { - ping: routedPing, - pong: lazy(() => Promise.resolve({ default: pong })), - }, - })), -} - -describe('standardOpenAPIMatcher', () => { - it('with router', async () => { - const rpcMatcher = new StandardOpenAPIMatcher() - rpcMatcher.init(router) - - expect(await rpcMatcher.match('POST', '/base')).toEqual({ - path: ['ping'], - procedure: ping, +describe('openAPIMatcher', () => { + describe('direct routes', () => { + it('matches generated and explicit OpenAPI routes after pathname normalization', async () => { + const ping = os.handler(() => 'pong') + const echo = os + .meta(openapi({ method: 'GET', path: '/nested/echo/{value}' })) + .handler(() => 'echo') + + const matcher = new OpenAPIMatcher({ + ping, + nested: { echo }, + }) + + await expect(matcher.match('POST', '/ping', undefined)).resolves.toEqual({ + path: ['ping'], + procedure: ping, + params: undefined, + }) + + await expect(matcher.match('GET', '/nested/%65cho/unnoq%2F', undefined)).resolves.toEqual({ + path: ['nested', 'echo'], + procedure: echo, + params: { value: 'unnoq/' }, + }) + + await expect(matcher.match('POST', '/nested/echo/unnoq%2F', undefined)).resolves.toBeUndefined() }) - expect(await rpcMatcher.match('DELETE', '/ping/name')).toEqual({ - path: ['nested', 'ping'], - procedure: routedPing, - params: { ping: 'name' }, + it('normalizes trailing slashes in request paths and OpenAPI route definitions', async () => { + const ping = os.handler(() => 'pong') + const echo = os + .meta(openapi({ method: 'GET', path: '/echo/' })) + .handler(() => 'echo') + + const matcher = new OpenAPIMatcher({ ping, echo }) + + await expect(matcher.match('POST', '/ping/', undefined)).resolves.toEqual({ + path: ['ping'], + procedure: ping, + params: undefined, + }) + + await expect(matcher.match('GET', '/echo', undefined)).resolves.toEqual({ + path: ['echo'], + procedure: echo, + params: undefined, + }) + + await expect(matcher.match('GET', '/echo/', undefined)).resolves.toEqual({ + path: ['echo'], + procedure: echo, + params: undefined, + }) }) - expect(await rpcMatcher.match('GET', '/pong/something')).toEqual({ - path: ['pong'], - procedure: routedPong, - params: { pong: 'something' }, - }) + it('decodes catch-all params and trims trailing slashes', async () => { + const files = os + .meta(openapi({ method: 'GET', path: '/files/{+path}' })) + .handler(() => 'ok') + + const matcher = new OpenAPIMatcher({ files }) - expect(await rpcMatcher.match('POST', '/nested/pong')).toEqual({ - path: ['nested', 'pong'], - procedure: pong, + await expect(matcher.match('GET', '/files/a/b/c%2Fd/', undefined)).resolves.toEqual({ + path: ['files'], + procedure: files, + params: { path: 'a/b/c/d' }, + }) }) - expect(await rpcMatcher.match('POST', '/')).toEqual(undefined) - expect(await rpcMatcher.match('POST', '/not_found')).toEqual(undefined) - }) + it('applies OpenAPI prefixes to generated and explicit routes', async () => { + const ping = os + .meta(openapi({ prefix: '/api' })) + .handler(() => 'pong') - it('with implemented router', async () => { - const rpcMatcher = new StandardOpenAPIMatcher() - rpcMatcher.init(implement(contract).$context().router({ - ...router, - pong: new Procedure({ - ...pong['~orpc'], - errorMap: { - SOMETHING_THAT_VIOLATES_THE_CONTRACT: {}, - }, - meta: { - SOMETHING_THAT_VIOLATES_THE_CONTRACT: {}, - }, - route: { - path: '/SOMETHING_THAT_VIOLATES_THE_CONTRACT', - }, - }), - })) + const echo = os + .meta(openapi({ method: 'GET', prefix: '/api/v1', path: '/echo/{value}' })) + .handler(() => 'echo') - expect(await rpcMatcher.match('POST', '/base')).toEqual({ - path: ['ping'], - procedure: ping, - }) + const matcher = new OpenAPIMatcher({ ping, echo }) - expect(await rpcMatcher.match('POST', '/pong')).toEqual({ - path: ['pong'], - procedure: pong, // this mean the contract is applied to the procedure - }) + await expect(matcher.match('POST', '/api/ping', undefined)).resolves.toEqual({ + path: ['ping'], + procedure: ping, + params: undefined, + }) - expect(await rpcMatcher.match('POST', '/nested/pong')).toEqual({ - path: ['nested', 'pong'], - procedure: pong, + await expect(matcher.match('GET', '/api/v1/echo/world', undefined)).resolves.toEqual({ + path: ['echo'], + procedure: echo, + params: { value: 'world' }, + }) + + await expect(matcher.match('POST', '/ping', undefined)).resolves.toBeUndefined() + await expect(matcher.match('GET', '/echo/world', undefined)).resolves.toBeUndefined() }) - expect(await rpcMatcher.match('POST', '/')).toEqual(undefined) - expect(await rpcMatcher.match('POST', '/not_found')).toEqual(undefined) - }) + it('supports filtering procedures during indexing', async () => { + const ping = os.handler(() => 'pong') + const secret = os.handler(() => 'hidden') + const filter = vi.fn((_procedure: unknown, path: string[]) => !path.includes('secret')) - it('with missing implementation', async () => { - const rpcMatcher = new StandardOpenAPIMatcher() - rpcMatcher.init(implement(contract).$context().router({ - ...router, - pong: undefined as any, // missing here - })) - - // still work normally with other implementation - expect(await rpcMatcher.match('POST', '/base')).toEqual({ - path: ['ping'], - procedure: ping, - }) + const matcher = new OpenAPIMatcher({ + ping, + internal: { secret }, + }, { + filter, + }) - expect(rpcMatcher.match('POST', '/pong')).rejects.toThrowError() + await expect(matcher.match('POST', '/ping', undefined)).resolves.toEqual({ + path: ['ping'], + procedure: ping, + params: undefined, + }) - expect(await rpcMatcher.match('POST', '/nested/pong')).toEqual({ - path: ['nested', 'pong'], - procedure: pong, - }) + await expect(matcher.match('POST', '/internal/secret', undefined)).resolves.toBeUndefined() - expect(await rpcMatcher.match('POST', '/')).toEqual(undefined) - expect(await rpcMatcher.match('POST', '/not_found')).toEqual(undefined) + expect(filter.mock.calls).toContainEqual([ping, ['ping']]) + expect(filter.mock.calls).toContainEqual([secret, ['internal', 'secret']]) + }) }) - it('lazy load lazy router', async () => { - const pingLoader = vi.fn(() => Promise.resolve({ default: ping })) - const pongLoader = vi.fn(() => Promise.resolve({ default: pong })) + describe('runtime prefix stripping', () => { + it('strips prefixes before route matching, including trailing slash prefixes', async () => { + const ping = os.handler(() => 'pong') + const pong = os.meta(openapi({ path: '/' })).handler(() => 'pong') + const matcher = new OpenAPIMatcher({ ping, pong }) + + await expect(matcher.match('POST', '/api/v1/ping', '/api/v1')).resolves.toEqual({ + path: ['ping'], + procedure: ping, + params: undefined, + }) + + await expect(matcher.match('POST', '/api/ping', '/api/')).resolves.toEqual({ + path: ['ping'], + procedure: ping, + params: undefined, + }) + + await expect(matcher.match('POST', '/api', '/api')).resolves.toEqual({ + path: ['pong'], + procedure: pong, + params: undefined, + }) + }) - const rpcMatcher = new StandardOpenAPIMatcher() + it('mismatch when the runtime prefix is missing or not a full path segment', async () => { + const ping = os.handler(() => 'pong') + const matcher = new OpenAPIMatcher({ ping }) - const base = os.$context() + await expect(matcher.match('POST', '/other/ping', '/api')).resolves.toBeUndefined() + await expect(matcher.match('POST', '/apiping', '/api')).resolves.toBeUndefined() + }) + }) - const router = base.router({ - ping: base.prefix('/prefix1').lazy(pingLoader), - pong: base.prefix('/prefix2').lazy(pongLoader), - nested: base.prefix('/prefix3').router({ - ping: base.lazy(pingLoader), - pong: base.lazy(pongLoader), - }), + describe('lazy routers', () => { + it('resolves unprefixed lazy routers once and reuses indexed routes', async () => { + const info = os + .meta(openapi({ method: 'GET', path: '/info' })) + .handler(() => 'info') + + const loader = vi.fn(async () => ({ + default: { info }, + })) + + const matcher = new OpenAPIMatcher({ + lazy: os.lazy(loader), + }) + + await expect(matcher.match('GET', '/info', undefined)).resolves.toEqual({ + path: ['lazy', 'info'], + procedure: info, + params: undefined, + }) + + await expect(matcher.match('GET', '/info', undefined)).resolves.toEqual({ + path: ['lazy', 'info'], + procedure: info, + params: undefined, + }) + + expect(loader).toHaveBeenCalledTimes(1) }) - rpcMatcher.init(router) + it('resolves prefixed lazy routers only when the pathname matches the prefix pattern', async () => { + const info = os + .meta(openapi({ method: 'GET', path: '/info/{tab}' })) + .handler(() => 'info') - expect(await rpcMatcher.match('POST', '/prefix1/base')).toEqual({ - path: ['ping'], - procedure: (await unlazy(router.ping)).default, - }) + const loader = vi.fn(async () => ({ + default: { info }, + })) - expect(pingLoader).toHaveBeenCalledTimes(2) - expect(pongLoader).toHaveBeenCalledTimes(0) + const matcher = new OpenAPIMatcher({ + user: os.meta(openapi({ prefix: '/users/{userId}' })).lazy(loader), + }) - // mean the result is cached - expect(await rpcMatcher.match('POST', '/prefix1/base')).not.toBeUndefined() - expect(pingLoader).toHaveBeenCalledTimes(2) - expect(pongLoader).toHaveBeenCalledTimes(0) + await expect(matcher.match('GET', '/projects/42/info/general', undefined)).resolves.toBeUndefined() + expect(loader).toHaveBeenCalledTimes(0) - expect(await rpcMatcher.match('POST', '/pong')).toEqual({ - path: ['pong'], - procedure: (await unlazy(router.pong)).default, - }) + const firstResult = await matcher.match('GET', '/users/din/info/settings', undefined) - expect(pingLoader).toHaveBeenCalledTimes(2) - expect(pongLoader).toHaveBeenCalledTimes(2) + expect(firstResult).toBeDefined() + expect(firstResult!.path).toEqual(['user', 'info']) + expect(firstResult!.params).toEqual({ userId: 'din', tab: 'settings' }) + expect(getOpenAPIMeta(firstResult!.procedure)).toMatchObject({ + method: 'GET', + path: '/info/{tab}', + prefix: '/users/{userId}', + }) - expect(await rpcMatcher.match('POST', '/prefix3/base')).toEqual({ - path: ['nested', 'ping'], - procedure: (await unlazy(router.nested.ping)).default, + await expect(matcher.match('GET', '/users/din/info/settings', undefined)).resolves.toEqual(firstResult) + + expect(loader).toHaveBeenCalledTimes(1) }) - expect(pingLoader).toHaveBeenCalledTimes(4) - expect(pongLoader).toHaveBeenCalledTimes(3) + it('resolves nested lazy routers added during the same match', async () => { + const summary = os + .meta(openapi({ method: 'GET', path: '/summary' })) + .handler(() => 'summary') - expect(await rpcMatcher.match('POST', '/nested/pong')).toEqual({ - path: ['nested', 'pong'], - procedure: (await unlazy(router.nested.pong)).default, - }) + const projectLoader = vi.fn(async () => ({ + default: { summary }, + })) - expect(pingLoader).toHaveBeenCalledTimes(4) - expect(pongLoader).toHaveBeenCalledTimes(4) + const outerLoader = vi.fn(async () => ({ + default: { + project: os.meta(openapi({ prefix: '/projects/{projectId}' })).lazy(projectLoader), + }, + })) - expect(await rpcMatcher.match('POST', '/')).toEqual(undefined) - expect(await rpcMatcher.match('POST', '/not_found')).toEqual(undefined) + const matcher = new OpenAPIMatcher({ + lazy: os.lazy(outerLoader), + }) - expect(pingLoader).toHaveBeenCalledTimes(4) - expect(pongLoader).toHaveBeenCalledTimes(4) - }) + const firstResult = await matcher.match('GET', '/projects/42/summary', undefined) - it('/ in path', async () => { - const ping1 = new Procedure({ - ...ping['~orpc'], - route: { + expect(firstResult).toBeDefined() + expect(firstResult!.path).toEqual(['lazy', 'project', 'summary']) + expect(firstResult!.params).toEqual({ projectId: '42' }) + expect(getOpenAPIMeta(firstResult!.procedure)).toMatchObject({ method: 'GET', - path: '/ping/{+ping}', - }, - }) + path: '/summary', + prefix: '/projects/{projectId}', + }) - const ping2 = new Procedure({ - ...ping['~orpc'], - route: { - method: 'GET', - path: '/ping/{ping}', - }, - }) + await expect(matcher.match('GET', '/projects/42/summary', undefined)).resolves.toEqual(firstResult) - const ping3 = new Procedure({ - ...ping['~orpc'], - route: { - method: 'GET', - path: '/ping/pong', - }, + expect(outerLoader).toHaveBeenCalledTimes(1) + expect(projectLoader).toHaveBeenCalledTimes(1) }) + }) - const rpcMatcher = new StandardOpenAPIMatcher() - rpcMatcher.init({ ping1, ping2, ping3 }) + describe('contract-first routers', () => { + it('wraps implementations with contract metadata and caches wrapped procedures', async () => { + const implementation = { + ping: os + .meta(openapi({ method: 'GET', path: '/implementation' })) + .handler(() => 'pong'), + } - expect(await rpcMatcher.match('GET', '/ping/name%2F')).toEqual({ - path: ['ping2'], - procedure: ping2, - params: { ping: 'name/' }, - }) + const contract = { + ping: oc.meta(openapi({ method: 'DELETE', path: '/contract/{id}' })), + } - expect(await rpcMatcher.match('GET', '/ping/name/')).toEqual({ - path: ['ping2'], - procedure: ping2, - params: { ping: 'name' }, - }) + const matcher = new OpenAPIMatcher(withHiddenRouterContract(implementation, contract)) + const firstResult = await matcher.match('DELETE', '/contract/42', undefined) - expect(await rpcMatcher.match('GET', '/ping/name/2/3/4%2F5')).toEqual({ - path: ['ping1'], - procedure: ping1, - params: { ping: 'name/2/3/4/5' }, - }) + expect(firstResult).toBeDefined() + expect(firstResult!.path).toEqual(['ping']) + expect(firstResult!.params).toEqual({ id: '42' }) + expect(firstResult!.procedure).not.toBe(implementation.ping) + expect(getOpenAPIMeta(firstResult!.procedure)).toMatchObject({ + method: 'DELETE', + path: '/contract/{id}', + }) - expect(await rpcMatcher.match('GET', '/ping/pong')).toEqual({ - path: ['ping3'], - procedure: ping3, - params: undefined, - }) - }) + const secondResult = await matcher.match('DELETE', '/contract/42', undefined) - it('filter procedures', async () => { - const rpcMatcher = new StandardOpenAPIMatcher({ - filter: (options) => { - if (options.path.includes('ping')) { - return false - } + expect(secondResult).toEqual(firstResult) + expect(secondResult!.procedure).toBe(firstResult!.procedure) // ensure cache - return true - }, + await expect(matcher.match('GET', '/implementation', undefined)).resolves.toBeUndefined() }) - rpcMatcher.init(router) - expect(await rpcMatcher.match('POST', '/base')).toEqual(undefined) - expect(await rpcMatcher.match('DELETE', '/ping/name')).toEqual(undefined) + it('throws when a contract-first implementation is missing', async () => { + const matcher = new OpenAPIMatcher(withHiddenRouterContract({ + ping: os.handler(() => 'pong'), + }, { + missing: oc.meta(openapi({ method: 'GET', path: '/missing' })), + })) - expect(await rpcMatcher.match('GET', '/pong/something')).toEqual({ - path: ['pong'], - procedure: routedPong, - params: { pong: 'something' }, + await expect(matcher.match('GET', '/missing', undefined)).rejects.toThrowError( + '[Contract-First] Missing or invalid implementation for procedure at path: "missing"', + ) }) }) }) diff --git a/packages/openapi/src/adapters/standard/openapi-matcher.ts b/packages/openapi/src/adapters/standard/openapi-matcher.ts index 98ebac696..a651170bd 100644 --- a/packages/openapi/src/adapters/standard/openapi-matcher.ts +++ b/packages/openapi/src/adapters/standard/openapi-matcher.ts @@ -1,120 +1,203 @@ -import type { HTTPPath } from '@orpc/client' -import type { AnyContractProcedure } from '@orpc/contract' -import type { AnyProcedure, AnyRouter, LazyTraverseContractProceduresOptions, TraverseContractProcedureCallbackOptions } from '@orpc/server' -import type { StandardMatcher, StandardMatchResult } from '@orpc/server/standard' +import type { AnyProcedureContract } from '@orpc/contract' +import type { AnyProcedure, AnyRouter, WalkProcedureContractsLazyResult } from '@orpc/server' import type { Value } from '@orpc/shared' -import { toHttpPath } from '@orpc/client/standard' -import { fallbackContractConfig } from '@orpc/contract' -import { createContractedProcedure, getLazyMeta, getRouter, isProcedure, traverseContractProcedures, unlazy } from '@orpc/server' -import { value } from '@orpc/shared' -import { addRoute, createRouter, findRoute } from 'rou3' -import { decodeParams, toRou3Pattern } from './utils' - -export interface StandardOpenAPIMatcherOptions { +import { createContractProcedure, getRouter, Procedure, unlazy, walkProcedureContractsSync } from '@orpc/server' +import { mergeHttpPath, normalizeHttpPath, pathToHttpPath, tryDecodeURIComponent, value } from '@orpc/shared' +import { addRoute, createRouter, findRoute, routeToRegExp } from 'rou3' +import { DEFAULT_OPENAPI_METHOD } from '../../constants' +import { getOpenAPIMeta } from '../../meta' +import { getDynamicPathParams } from '../../utils' + +export interface OpenAPIMatcherOptions { /** - * Filter procedures. Return `false` to exclude a procedure from matching. + * Filter which procedures are exposed for matching. Return `false` to exclude. * * @default true */ - filter?: Value + filter?: Value } -export class StandardOpenAPIMatcher implements StandardMatcher { - private readonly filter: Exclude +interface TreeEntry { + path: string[] + contract: AnyProcedureContract + procedure?: AnyProcedure | undefined +} + +interface PendingLazyRouter extends WalkProcedureContractsLazyResult { + matcher?: RegExp +} - private readonly tree = createRouter<{ - path: readonly string[] - contract: AnyContractProcedure - procedure: AnyProcedure | undefined - router: AnyRouter - }>() +export class OpenAPIMatcher { + private readonly filter: Exclude + private readonly rootRouter: AnyRouter - private pendingRouters: (LazyTraverseContractProceduresOptions & { httpPathPrefix: HTTPPath, laziedPrefix: string | undefined }) [] = [] + private readonly tree = createRouter() - constructor(options: StandardOpenAPIMatcherOptions = {}) { + private pendingLazyRouters: PendingLazyRouter[] = [] + + constructor(router: AnyRouter, options: OpenAPIMatcherOptions = {}) { this.filter = options.filter ?? true + this.rootRouter = router + this.index(router) } - init(router: AnyRouter, path: readonly string[] = []): void { - const laziedOptions = traverseContractProcedures({ router, path }, (traverseOptions) => { - if (!value(this.filter, traverseOptions)) { + private index(router: AnyRouter, path: string[] = []): void { + const lazyResults = walkProcedureContractsSync(router, (contract, path) => { + if (!value(this.filter, contract, path)) { return } - const { path, contract } = traverseOptions + const meta = getOpenAPIMeta(contract) + const method = meta?.method ?? DEFAULT_OPENAPI_METHOD + const postHttpPath = meta?.path ?? pathToHttpPath(path) + const openapiPath = meta?.prefix ? mergeHttpPath(meta.prefix, postHttpPath) : postHttpPath + const rou3Path = toRou3Pattern(openapiPath) + + addRoute(this.tree, method, rou3Path, { + path, + contract, + procedure: contract instanceof Procedure ? contract : undefined, + }) + }, path) + + this.pendingLazyRouters.push(...lazyResults.map((result) => { + const prefix = getOpenAPIMeta(result.router)?.prefix + + return { + ...result, + matcher: prefix ? toRou3PrefixMatcher(prefix) : undefined, + } + })) + } + + async match( + method: string, + pathname: `/${string}`, + prefix: `/${string}` | undefined, + ): Promise<{ path: string[], procedure: AnyProcedure, params?: Record | undefined } | undefined> { + // rou3 handles trailing slash removal automatically + // if (pathname.length > 1 && pathname.endsWith('/')) { + // pathname = pathname.slice(0, -1) as `/${string}` + // } + + if (prefix) { + if (!pathname.startsWith(prefix)) { + return undefined + } - const method = fallbackContractConfig('defaultMethod', contract['~orpc'].route.method) - const httpPath = toRou3Pattern(contract['~orpc'].route.path ?? toHttpPath(path)) + const charAfterPrefix = pathname[prefix.length] - if (isProcedure(contract)) { - addRoute(this.tree, method, httpPath, { - path, - contract, - procedure: contract, // this mean dev not used contract-first so we can used contract as procedure directly - router, - }) + if (charAfterPrefix === '/') { + pathname = pathname.slice(prefix.length) as `/${string}` + } + else if (charAfterPrefix === undefined) { + pathname = '/' + } + else if (prefix[prefix.length - 1] === '/') { + pathname = pathname.slice(prefix.length - 1) as `/${string}` } else { - addRoute(this.tree, method, httpPath, { - path, - contract, - procedure: undefined, - router, - }) + return undefined } - }) + } - this.pendingRouters.push(...laziedOptions.map(option => ({ - ...option, - httpPathPrefix: toHttpPath(option.path), - laziedPrefix: getLazyMeta(option.router).prefix, - }))) - } + const result = await this.matchPathname(method, pathname) - async match(method: string, pathname: HTTPPath): Promise { - if (this.pendingRouters.length) { - const newPendingRouters: typeof this.pendingRouters = [] - - for (const pendingRouter of this.pendingRouters) { - if ( - !pendingRouter.laziedPrefix - || pathname.startsWith(pendingRouter.laziedPrefix) - || pathname.startsWith(pendingRouter.httpPathPrefix) - ) { - const { default: router } = await unlazy(pendingRouter.router) - this.init(router, pendingRouter.path) - } - else { - newPendingRouters.push(pendingRouter) - } - } + if (!result && pathname.includes('%')) { + // Retry with a normalized path: users may percent-encode characters that + // we store unencoded (e.g. "a%62c" vs "abc"), so normalization lets us + // handle those requests without storing duplicate entries. - this.pendingRouters = newPendingRouters + return this.matchPathname(method, normalizeHttpPath(pathname)) } + return result + } + + private async matchPathname( + method: string, + pathname: `/${string}`, + ): Promise<{ path: string[], procedure: AnyProcedure, params?: Record | undefined } | undefined> { + await this.resolvePendingLazyRouters(pathname) + const match = findRoute(this.tree, method, pathname) if (!match) { return undefined } - if (!match.data.procedure) { - const { default: maybeProcedure } = await unlazy(getRouter(match.data.router, match.data.path)) + const procedure = await this.resolveProcedure(match.data) + + return { + path: match.data.path, + procedure, + params: match.params ? decodeParams(match.params) : undefined, + } + } + + private async resolvePendingLazyRouters(pathname: `/${string}`): Promise { + if (!this.pendingLazyRouters.length) { + return + } - if (!isProcedure(maybeProcedure)) { - throw new Error(` - [Contract-First] Missing or invalid implementation for procedure at path: ${toHttpPath(match.data.path)}. - Ensure that the procedure is correctly defined and matches the expected contract. - `) + const stillPending: typeof this.pendingLazyRouters = [] + + // We need to loop over this.pendingLazyRouters because this.index can still append new lazy routers + // that might need to be resolved + for (const pending of this.pendingLazyRouters) { + if (!pending.matcher || pending.matcher.test(pathname)) { + const { default: router } = await unlazy(pending.router) + this.index(router, pending.path) + } + else { + stillPending.push(pending) } + } + + this.pendingLazyRouters = stillPending + } - match.data.procedure = createContractedProcedure(maybeProcedure, match.data.contract) + private async resolveProcedure(entry: TreeEntry): Promise { + if (entry.procedure) { + return entry.procedure } - return { - path: match.data.path, - procedure: match.data.procedure, - params: match.params ? decodeParams(match.params) : undefined, + const { default: maybeProcedure } = await unlazy(getRouter(this.rootRouter, entry.path)) + + if (!(maybeProcedure instanceof Procedure)) { + throw new TypeError( + `[Contract-First] Missing or invalid implementation for procedure at path: "${entry.path.join('.')}". ` + + `Ensure the procedure is correctly implemented and matches its contract.`, + ) } + + entry.procedure = createContractProcedure(maybeProcedure, entry.contract) + + return entry.procedure } } + +function toRou3Pattern(path: `/${string}`): `/${string}` { + const params = getDynamicPathParams(path) + + if (!params?.length) { + return path + } + + for (let i = params.length - 1; i >= 0; i--) { + const param = params[i]! + const pattern = param.allowsSlash ? `**:${param.parameterName}` : `:${param.parameterName}` + path = path.slice(0, param.startIndex) + pattern + path.slice(param.startIndex + param.segment.length) + } + + return path +} + +function toRou3PrefixMatcher(path: `/${string}`): RegExp { + const pattern = toRou3Pattern(path) + return routeToRegExp(pattern === '/' ? '/**' : `${pattern}/**`) +} + +function decodeParams(params: Record): Record { + return Object.fromEntries(Object.entries(params).map(([key, val]) => [key, tryDecodeURIComponent(val)])) +} diff --git a/packages/openapi/src/adapters/standard/utils.test.ts b/packages/openapi/src/adapters/standard/utils.test.ts deleted file mode 100644 index 87edb3d65..000000000 --- a/packages/openapi/src/adapters/standard/utils.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { decodeParams, toRou3Pattern } from './utils' - -it('toRou3Pattern', () => { - expect(toRou3Pattern('/api/v1/users/{id}')).toBe('/api/v1/users/:id') - expect(toRou3Pattern('/api/v1/users/{+id}')).toBe('/api/v1/users/**:id') - expect(toRou3Pattern('/api/v1/users/name')).toBe('/api/v1/users/name') - expect(toRou3Pattern('/api/v1/users/name{id}')).toBe('/api/v1/users/name{id}') -}) - -it('decodeParams', () => { - expect(decodeParams({ id: '1' })).toEqual({ id: '1' }) - expect(decodeParams({ id: '1%2B1' })).toEqual({ id: '1+1' }) -}) diff --git a/packages/openapi/src/adapters/standard/utils.ts b/packages/openapi/src/adapters/standard/utils.ts deleted file mode 100644 index 1eb21b25c..000000000 --- a/packages/openapi/src/adapters/standard/utils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { HTTPPath } from '@orpc/client' -import { standardizeHTTPPath } from '@orpc/openapi-client/standard' -import { tryDecodeURIComponent } from '@orpc/shared' - -/** - * {@link https://github.com/unjs/rou3} - * - * @internal - */ -export function toRou3Pattern(path: HTTPPath): string { - return standardizeHTTPPath(path).replace(/\/\{\+([^}]+)\}/g, '/**:$1').replace(/\/\{([^}]+)\}/g, '/:$1') -} - -/** - * @internal - */ -export function decodeParams(params: Record): Record { - return Object.fromEntries(Object.entries(params).map(([key, value]) => [key, tryDecodeURIComponent(value)])) -} diff --git a/packages/openapi/src/bracket-notation.test.ts b/packages/openapi/src/bracket-notation.test.ts new file mode 100644 index 000000000..cc93dd59e --- /dev/null +++ b/packages/openapi/src/bracket-notation.test.ts @@ -0,0 +1,364 @@ +import { BracketNotationSerializer } from './bracket-notation' + +describe('bracket notation serializer', () => { + const serializer = new BracketNotationSerializer() + + it('.stringifyPath', () => { + expect(serializer.stringifyPath([])).toBe('') + expect(serializer.stringifyPath([1])).toBe('1') + expect(serializer.stringifyPath(['a', 'b', 'c', 1, 2, 3])).toBe('a[b][c][1][2][3]') + }) + + it('.parsePath', () => { + expect(serializer.parsePath('')).toEqual(['']) + expect(serializer.parsePath('a[b][c][1][2][3]')).toEqual(['a', 'b', 'c', '1', '2', '3']) + expect(serializer.parsePath('a[b]c[d]')).toEqual(['a', 'b]c[d']) + expect(serializer.parsePath('a[b]c[d')).toEqual(['a[b]c[d']) + expect(serializer.parsePath('a[[b]]')).toEqual(['a', '[b]']) + expect(serializer.parsePath('abc[]')).toEqual(['abc', '']) + + expect(serializer.parsePath('abc[def')).toEqual(['abc[def']) + expect(serializer.parsePath('abc[d]ef')).toEqual(['abc[d]ef']) + expect(serializer.parsePath('abc[d][ef')).toEqual(['abc[d][ef']) + expect(serializer.parsePath('abc[d][')).toEqual(['abc[d][']) + expect(serializer.parsePath('abc[')).toEqual(['abc[']) + expect(serializer.parsePath('abc]')).toEqual(['abc]']) + }) + + it.each([ + [['a', 'b', 'c']], + [['\\a', 'b', '\\c']], + [['', '', '']], + ])('.stringifyPath + .parsePath', (segments) => { + expect(serializer.parsePath(serializer.stringifyPath(segments))).toEqual(segments) + }) + + describe('.serialize', () => { + it('can serialize primitive values', () => { + expect(serializer.serialize(1)).toEqual([ + ['', 1], + ]) + }) + + it('can serialize objects', () => { + expect(serializer.serialize({ a: 1, b: 2, c: 3 })).toEqual([ + ['a', 1], + ['b', 2], + ['c', 3], + ]) + }) + + it('can serialize arrays', () => { + expect(serializer.serialize([1, 2, 3])).toEqual([ + ['0', 1], + ['1', 2], + ['2', 3], + ]) + }) + + it('can serialize nested objects', () => { + expect(serializer.serialize({ a: { b: { c: 1, d: 2 }, e: 3, f: 4 } })).toEqual([ + ['a[b][c]', 1], + ['a[b][d]', 2], + ['a[e]', 3], + ['a[f]', 4], + ]) + }) + + it('can serialize nested arrays', () => { + expect(serializer.serialize({ a: [[1, 2], 3, 4] })).toEqual([ + ['a[0][0]', 1], + ['a[0][1]', 2], + ['a[1]', 3], + ['a[2]', 4], + ]) + }) + + it('can serialize mixed nested structures', () => { + expect(serializer.serialize({ a: { b: 1, c: [2, { d: 3, f: 4 }] } })).toEqual([ + ['a[b]', 1], + ['a[c][0]', 2], + ['a[c][1][d]', 3], + ['a[c][1][f]', 4], + ]) + }) + }) + + describe('.deserialize', () => { + it('can deserialize empty objects', () => { + expect(serializer.deserialize([])).toEqual({}) + }) + + it('can deserialize arrays', () => { + expect(serializer.deserialize([ + ['a[]', 1], + ['a[]', 2], + ['a[]', 3], + ])).toEqual({ a: [1, 2, 3] }) + + expect(serializer.deserialize([ + ['a[0]', 1], + ['a[1]', 2], + ['a[2]', 3], + ])).toEqual({ a: [1, 2, 3] }) + }) + + it('can deserialize array missing items', () => { + expect(serializer.deserialize([ + ['a[0]', 1], + ['a[2]', 3], + ])).toEqual({ a: [1, undefined, 3] }) + }) + + it('deserializes root-level array notation as objects', () => { + expect(serializer.deserialize([ + ['', 1], + ['', 2], + ['', 3], + ])).toEqual({ '': [1, 2, 3] }) + + expect(serializer.deserialize([ + ['0', 1], + ['1', 2], + ['2', 3], + ])).toEqual({ 0: 1, 1: 2, 2: 3 }) + }) + + it('deserializes sparse root-level array notation as objects', () => { + expect(serializer.deserialize([ + ['0', 1], + ['2', 2], + ])).toEqual({ 0: 1, 2: 2 }) + }) + + it('can deserialize objects', () => { + expect(serializer.deserialize([ + ['a', 1], + ['b', 2], + ['c', 3], + ])).toEqual({ a: 1, b: 2, c: 3 }) + }) + + it('can deserialize number-key objects', () => { + expect(serializer.deserialize([ + ['0', 1], + ['1', 2], + ['a', 3], + ])).toEqual({ 0: 1, 1: 2, a: 3 }) + + expect(serializer.deserialize([ + ['a', 3], + ['0', 1], + ['1', 2], + ])).toEqual({ 0: 1, 1: 2, a: 3 }) + }) + + it('can deserialize empty-key objects', () => { + expect(serializer.deserialize([ + ['', 1], + ['a', 3], + ])).toEqual({ '': 1, 'a': 3 }) + + expect(serializer.deserialize([ + ['a', 3], + ['', 1], + ])).toEqual({ '': 1, 'a': 3 }) + + expect(serializer.deserialize([ + ['[a]', 1], + ['[b]', 3], + ])).toEqual({ '': { a: 1, b: 3 } }) + }) + + it('can deserialize objects when both number-key and empty-key appear', () => { + expect(serializer.deserialize([ + ['0', 1], + ['', 2], + ])).toEqual({ '0': 1, '': 2 }) + expect(serializer.deserialize([ + ['', 2], + ['0', 1], + ])).toEqual({ '0': 1, '': 2 }) + }) + + it('should be an array if conflict keys', () => { + expect(serializer.deserialize([ + ['a', 1], + ['a', 2], + ])).toEqual({ a: [1, 2] }) + + expect(serializer.deserialize([ + ['0', 1], + ['0', 2], + ])).toEqual({ 0: [1, 2] }) + + expect(serializer.deserialize([ + ['a', 1], + ['a', 2], + ['a[2]', 3], + ])).toEqual({ a: [1, 2, 3] }) + + expect(serializer.deserialize([ + ['0', 1], + ['0', 2], + ['0[user]', 3], + ])).toEqual({ + 0: { + 0: 1, + 1: 2, + user: 3, + }, + }) + }) + + it('should be an array if [] conflict keys', () => { + expect(serializer.deserialize([ + ['users[]', 1], + ['users[]', 2], + ['users[name]', 3], + ])).toEqual({ + users: { + '': [1, 2], + 'name': 3, + }, + }) + + expect(serializer.deserialize([ + ['users[]', 1], + ['users[]', 2], + ['users[name][]', 3], + ['users[name][]', 4], + ['users[]', 5], + ['users[name][]', 6], + ])).toEqual({ + users: { + '': [1, 2, 5], + 'name': [3, 4, 6], + }, + }) + + expect(serializer.deserialize([ + ['a[]', 1], + ['a[b][]', 2], + ['a[b][c][]', 3], + ['a[]', 4], + ['a[b][]', 5], + ['a[b][c][]', 6], + ])).toEqual({ + a: { + '': [1, 4], + 'b': { + '': [2, 5], + 'c': [3, 6], + }, + }, + }) + }) + + it('can deserialize mixed nested structures', () => { + expect(serializer.deserialize([ + ['a[b]', 1], + ['a[c][0]', 2], + ['a[c][1][d]', 3], + ['a[c][1][f]', 4], + ])).toEqual({ a: { b: 1, c: [2, { d: 3, f: 4 }] } }) + }) + + it('fallback to object when explicit array index exceeds maxExplicitDeserializingArrayIndex (default 999)', () => { + expect(serializer.deserialize([ + ['arr[1]', 1], + ['arr[999]', 2], + ['arr[1000]', 3], + ])).toEqual({ arr: { 1: 1, 999: 2, 1000: 3 } }) + + expect(serializer.deserialize([ + ['arr[999]', 3], + ])).toEqual({ arr: (() => { + const arr = [] + arr[999] = 3 + return arr + })() }) + + expect(serializer.deserialize([ + ['arr[1000]', 3], + ])).toEqual({ arr: { 1000: 3 } }) + + // the limit not apply to push array syntax + expect(serializer.deserialize([ + ['arr[999]', 3], + ['arr', 4], + ])).toEqual({ + arr: (() => { + const arr = [] + arr[999] = 3 + arr[1000] = 4 + return arr + })(), + }) + + const customSerializer = new BracketNotationSerializer({ maxExplicitDeserializingArrayIndex: 499 }) + + expect(customSerializer.deserialize([ + ['arr[1]', 1], + ['arr[499]', 2], + ['arr[500]', 3], + ])).toEqual({ arr: { 1: 1, 499: 2, 500: 3 } }) + + expect(customSerializer.deserialize([ + ['arr[499]', 2], + ])).toEqual({ arr: (() => { + const arr = [] + arr[499] = 2 + return arr + })() }) + }) + + it('can prevent prototype pollution attack', () => { + /* eslint-disable no-proto, no-restricted-properties */ + const result = serializer.deserialize([ + ['__proto__[polluted]', '1'], + ['constructor[polluted]', '2'], + ['nested[__proto__][polluted]', '3'], + ['nested[constructor][polluted]', '4'], + ['arr[]', '5'], + ['arr[__proto__][polluted]', '6'], + ['arr[constructor][polluted]', '7'], + ]) as any + + // dangerous keys are stored as plain data on NullProtoObj, not as real prototype links + expect(result.__proto__).toEqual({ polluted: '1' }) + expect(result.constructor).toEqual({ polluted: '2' }) + expect(result.nested.__proto__).toEqual({ polluted: '3' }) + expect(result.nested.constructor).toEqual({ polluted: '4' }) + expect(result.arr['']).toEqual('5') + expect(result.arr.__proto__).toEqual({ polluted: '6' }) + expect(result.arr.constructor).toEqual({ polluted: '7' }) + + // stored keys must not be reachable via normal property lookup (would indicate real prototype mutation) + expect(result.polluted).toBeUndefined() + expect(result.nested.polluted).toBeUndefined() + expect(result.arr.polluted).toBeUndefined() + + // global Object prototype must be completely unaffected + expect(({} as any).__proto__.polluted).toBeUndefined() + expect(({} as any).constructor.polluted).toBeUndefined() + expect(({} as any).polluted).toBeUndefined() + /* eslint-enable no-proto, no-restricted-properties */ + }) + }) + + it.each([ + [{ }], + [{ a: 1, b: 2, c: [1, 2, { a: 1, b: 2 }, new Date(), new Blob([]), new Set([1, 2]), new Map([[1, 2]])] }], + ])('.serialize + .deserialize', (value) => { + expect(serializer.deserialize(serializer.serialize(value))).toEqual(value) + }) + + it('does not round-trip root-level arrays', () => { + expect(serializer.deserialize(serializer.serialize([1, 2, 3]))).toEqual({ + 0: 1, + 1: 2, + 2: 3, + }) + }) +}) diff --git a/packages/openapi/src/bracket-notation.ts b/packages/openapi/src/bracket-notation.ts new file mode 100644 index 000000000..8a8b48f10 --- /dev/null +++ b/packages/openapi/src/bracket-notation.ts @@ -0,0 +1,194 @@ +import type { Segment } from '@orpc/shared' +import { isPlainObject, NullProtoObj } from '@orpc/shared' + +export type BracketNotationSerializeResult = [string, unknown][] + +export interface BracketNotationSerializerOptions { + /** + * Maximum explicit array index allowed during deserialization (e.g., `arr[0]`, `arr[999]`). + * If the index exceeds this limit, the array is deserialized as an object instead. + * + * This guards against memory exhaustion attacks where malicious input uses extremely large + * indices (e.g., `?arr[4294967296]=value`). Although orpc uses sparse arrays handle large indices + * efficiently, downstream code may inadvertently densify them - creating millions of + * undefined slots and exhausting memory. + * + * NOTE: Does not apply to append-style notation (e.g., `arr[]`). + * + * @default 999 (array with 1,000 elements) + */ + maxExplicitDeserializingArrayIndex?: number +} + +export class BracketNotationSerializer { + private readonly maxExplicitDeserializingArrayIndex: number + + constructor(options: BracketNotationSerializerOptions = {}) { + this.maxExplicitDeserializingArrayIndex = options.maxExplicitDeserializingArrayIndex ?? 999 + } + + serialize(data: unknown): BracketNotationSerializeResult { + return this.internalSerialize(data, [], []) + } + + private internalSerialize(data: unknown, segments: Segment[], result: BracketNotationSerializeResult): BracketNotationSerializeResult { + if (Array.isArray(data)) { + data.forEach((item, i) => { + this.internalSerialize(item, [...segments, i], result) + }) + } + + else if (isPlainObject(data)) { + for (const key in data) { + this.internalSerialize(data[key], [...segments, key], result) + } + } + + else { + result.push([this.stringifyPath(segments), data]) + } + + return result + } + + deserialize(serialized: BracketNotationSerializeResult): Record { + if (serialized.length === 0) { + return new NullProtoObj() // Prevent Prototype Pollution with NullProtoObj + } + + const arrayPushStyles = new WeakSet() + const ref: { value: Record } = { value: new NullProtoObj() } // Prevent Prototype Pollution with NullProtoObj + + for (const [path, value] of serialized) { + const segments = this.parsePath(path) + + let currentRef: any = ref + let nextSegment: string = 'value' + + segments.forEach((segment, i) => { + if (!Array.isArray(currentRef[nextSegment]) && !isPlainObject(currentRef[nextSegment])) { + currentRef[nextSegment] = [] + } + + if (i !== segments.length - 1) { + if (Array.isArray(currentRef[nextSegment]) && !internalIsValidArrayIndex(segment, this.maxExplicitDeserializingArrayIndex)) { + if (arrayPushStyles.has(currentRef[nextSegment])) { + arrayPushStyles.delete(currentRef[nextSegment]) + currentRef[nextSegment] = internalPushStyleArrayToObject(currentRef[nextSegment]) + } + else { + currentRef[nextSegment] = internalArrayToObject(currentRef[nextSegment]) + } + } + } + else { + if (Array.isArray(currentRef[nextSegment])) { + if (segment === '') { + if (currentRef[nextSegment].length && !arrayPushStyles.has(currentRef[nextSegment])) { + currentRef[nextSegment] = internalArrayToObject(currentRef[nextSegment]) + } + } + else { + if (arrayPushStyles.has(currentRef[nextSegment])) { + arrayPushStyles.delete(currentRef[nextSegment]) + currentRef[nextSegment] = internalPushStyleArrayToObject(currentRef[nextSegment]) + } + + else if (!internalIsValidArrayIndex(segment, this.maxExplicitDeserializingArrayIndex)) { + currentRef[nextSegment] = internalArrayToObject(currentRef[nextSegment]) + } + } + } + } + + currentRef = currentRef[nextSegment] + nextSegment = segment + }) + + if (Array.isArray(currentRef) && nextSegment === '') { + arrayPushStyles.add(currentRef) + currentRef.push(value) + } + else if (nextSegment in currentRef) { + if (Array.isArray(currentRef[nextSegment])) { + currentRef[nextSegment].push(value) + } + else { + currentRef[nextSegment] = [currentRef[nextSegment], value] + } + } + else { + currentRef[nextSegment] = value + } + } + + return ref.value + } + + stringifyPath(segments: readonly Segment[]): string { + return segments + .reduce((result, segment, i) => { + if (i === 0) { + return segment.toString() + } + + return `${result}[${segment}]` + }, '') + } + + parsePath(path: string): string[] { + const segments: string[] = [] + + let inBrackets = false + let currentSegment = '' + + for (let i = 0; i < path.length; i++) { + const char = path[i]! + const nextChar = path[i + 1] + + if (inBrackets && char === ']' && (nextChar === undefined || nextChar === '[')) { + if (nextChar === undefined) { + inBrackets = false + } + + segments.push(currentSegment) + currentSegment = '' + i++ + } + + else if (segments.length === 0 && char === '[') { + inBrackets = true + segments.push(currentSegment) + currentSegment = '' + } + + else { + currentSegment += char + } + } + + return inBrackets || segments.length === 0 ? [path] : segments + } +} + +function internalIsValidArrayIndex(value: string, maxIndex: number): boolean { + return /^0$|^[1-9]\d*$/.test(value) && Number(value) <= maxIndex +} + +function internalArrayToObject(array: readonly unknown[]): Record { + const obj = new NullProtoObj() // Prevent Prototype Pollution with NullProtoObj + + array.forEach((item, i) => { + obj[i] = item + }) + + return obj +} + +function internalPushStyleArrayToObject(array: readonly unknown[]): Record { + const obj = new NullProtoObj() + + obj[''] = array.length === 1 ? array[0] : array + + return obj +} diff --git a/packages/openapi/src/caller.test-d.ts b/packages/openapi/src/caller.test-d.ts new file mode 100644 index 000000000..9b74724ae --- /dev/null +++ b/packages/openapi/src/caller.test-d.ts @@ -0,0 +1,104 @@ +import type { ClientLink, ORPCError } from '@orpc/client' +import type { PromiseWithError } from '@orpc/shared' +import { oc, type } from '@orpc/contract' +import { createContractJsonifiedCaller } from './caller' + +const contract = { + ping: oc, + nested: { + pong: oc + .errors({ BAD_GATEWAY: { data: type(vi.fn()) } }) + .input(type(vi.fn())) + .output(type(vi.fn())), + }, +} + +describe('createContractJsonifiedCaller', () => { + const link = {} as ClientLink<{ cache?: boolean }> + + it('infers interceptor input, output, errors types', () => { + createContractJsonifiedCaller(link, { + contractRef: contract, + interceptors: [ + async ({ context, next, input }) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ cache?: boolean }>() + + const result = next() + + expectTypeOf(result).toEqualTypeOf< + PromiseWithError + >() + + return result + }, + ], + scoped: { + ping: { + interceptors: [ + async ({ context, next, input }) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ cache?: boolean }>() + + const result = next() + + expectTypeOf(result).toEqualTypeOf< + PromiseWithError> + >() + + return result + }, + ], + }, + nested: { + pong: { + interceptors: [ + async ({ context, next, input }) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf<{ cache?: boolean }>() + + const result = next() + + expectTypeOf(result).toEqualTypeOf< + PromiseWithError> + >() + + return result + }, + ], + }, + }, + }, + }) + }) + + it('infers procedure return types', () => { + const caller = createContractJsonifiedCaller(link) + + expectTypeOf( + caller(contract.ping), + ).toEqualTypeOf< + PromiseWithError + >() + + expectTypeOf( + caller(contract.nested.pong, 'string', { context: { cache: true } }), + ).toEqualTypeOf< + PromiseWithError> + >() + }) + + it('rejects invalid input', () => { + const caller = createContractJsonifiedCaller(link) + + // @ts-expect-error - invalid input + caller(contract.nested.pong, 123) + }) + + it('rejects invalid context', () => { + const caller = createContractJsonifiedCaller(link) + + // @ts-expect-error - invalid context + caller(contract.nested.pong, 'string', { context: { cache: 'invalid' } }) + }) +}) diff --git a/packages/openapi/src/caller.test.ts b/packages/openapi/src/caller.test.ts new file mode 100644 index 000000000..393245a05 --- /dev/null +++ b/packages/openapi/src/caller.test.ts @@ -0,0 +1,30 @@ +import type { ClientContext, ClientLink } from '@orpc/client' +import { createContractCaller } from '@orpc/contract' +import { createContractJsonifiedCaller } from './caller' + +vi.mock('@orpc/contract', () => ({ + createContractCaller: vi.fn(), +})) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('createContractJsonifiedCaller', () => { + const mockedLink: ClientLink = { + call: vi.fn(), + } + + it('delegates to createContractCaller and returns its result', () => { + const delegatedCaller = vi.fn() + const options = { interceptors: [vi.fn()], routerRef: {}, options: {} } + + vi.mocked(createContractCaller).mockReturnValue(delegatedCaller as any) + + const result = createContractJsonifiedCaller(mockedLink, options as any) + + expect(createContractCaller).toHaveBeenCalledTimes(1) + expect(createContractCaller).toHaveBeenCalledWith(mockedLink, options) + expect(result).toBe(delegatedCaller) + }) +}) diff --git a/packages/openapi/src/caller.ts b/packages/openapi/src/caller.ts new file mode 100644 index 000000000..c25da851a --- /dev/null +++ b/packages/openapi/src/caller.ts @@ -0,0 +1,41 @@ +import type { ClientContext, ClientLink, ClientRest, ORPCClientOptions, ThrowableError } from '@orpc/client' +import type { AnySchema, ErrorMap, InferSchemaInput, InferSchemaOutput, ORPCErrorFromErrorMap, ProcedureContract, RouterContract, RouterContractClient } from '@orpc/contract' +import type { PromiseWithError } from '@orpc/shared' +import type { JsonifiedClient, JsonifiedClientError, JsonifiedValue } from './types' +import { createContractCaller } from '@orpc/contract' + +export interface ContractJsonifiedCaller< + TClientContext extends ClientContext, +> { + < + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + >( + procedure: ProcedureContract, + ...rest: ClientRest> + ): PromiseWithError< + JsonifiedValue>, + JsonifiedClientError | ThrowableError> + > +} + +export interface ContractJsonifiedCallerOptions< + TClientContext extends ClientContext, +> extends Pick>>, 'interceptors' | 'scoped'> { + /** + * An optional reference to the root router-contract. + * When provided, the caller will automatically register the called procedure-contract + * into the router at the path defined by `meta.path`. + */ + contractRef?: undefined | RouterContract +} + +export function createContractJsonifiedCaller< + TClientContext extends ClientContext, +>( + link: ClientLink, + options: ContractJsonifiedCallerOptions = {}, +): ContractJsonifiedCaller { + return createContractCaller(link, options) as ContractJsonifiedCaller +} diff --git a/packages/openapi/src/constants.test.ts b/packages/openapi/src/constants.test.ts new file mode 100644 index 000000000..9b6cd7f25 --- /dev/null +++ b/packages/openapi/src/constants.test.ts @@ -0,0 +1,8 @@ +it('exports', async () => { + await expect(import('./constants')).resolves.toMatchObject({ + DEFAULT_OPENAPI_METHOD: expect.any(String), + DEFAULT_OPENAPI_SUCCESS_DESCRIPTION: expect.any(String), + DEFAULT_OPENAPI_INPUT_STRUCTURE: expect.any(String), + DEFAULT_OPENAPI_OUTPUT_STRUCTURE: expect.any(String), + }) +}) diff --git a/packages/openapi/src/constants.ts b/packages/openapi/src/constants.ts new file mode 100644 index 000000000..a497a1e19 --- /dev/null +++ b/packages/openapi/src/constants.ts @@ -0,0 +1,4 @@ +export const DEFAULT_OPENAPI_METHOD = 'POST' +export const DEFAULT_OPENAPI_SUCCESS_DESCRIPTION = 'OK' +export const DEFAULT_OPENAPI_INPUT_STRUCTURE = 'compact' +export const DEFAULT_OPENAPI_OUTPUT_STRUCTURE = 'compact' diff --git a/packages/openapi/src/extensions/route.test.ts b/packages/openapi/src/extensions/route.test.ts new file mode 100644 index 000000000..3099a2867 --- /dev/null +++ b/packages/openapi/src/extensions/route.test.ts @@ -0,0 +1,33 @@ +import { oc, ProcedureContract } from '@orpc/contract' +import { DecoratedProcedure, os } from '@orpc/server' +import z from 'zod' +import { getOpenAPIMeta } from '../meta' +import './route' + +it('adds .route metadata through contract builder variants', () => { + const procedure = oc + .route({ tags: ['1'] }) + .input(z.object({})) + .route({ tags: ['2'] }) + .output(z.object()) + .route({ tags: ['3'] }) + + expect(procedure).toBeInstanceOf(ProcedureContract) + expect(getOpenAPIMeta(procedure)?.tags).toEqual(['1', '2', '3']) +}) + +it('adds .route metadata through server builder variants and decorated procedure', () => { + const procedure = os + .route({ tags: ['1'] }) + .use(({ next }) => next()) + .route({ tags: ['2'] }) + .input(z.object({})) + .route({ tags: ['3'] }) + .output(z.object()) + .route({ tags: ['4'] }) + .handler(() => ({})) + .route({ tags: ['5'] }) + + expect(procedure).toBeInstanceOf(DecoratedProcedure) + expect(getOpenAPIMeta(procedure)?.tags).toEqual(['1', '2', '3', '4', '5']) +}) diff --git a/packages/openapi/src/extensions/route.ts b/packages/openapi/src/extensions/route.ts new file mode 100644 index 000000000..d9c1b0a60 --- /dev/null +++ b/packages/openapi/src/extensions/route.ts @@ -0,0 +1,105 @@ +import type { AnyORPCError } from '@orpc/client' +import type { AnySchema, ErrorMap } from '@orpc/contract' +import type { Context } from '@orpc/server' +import type { OpenAPIMeta } from '../meta' +import { ContractBuilder } from '@orpc/contract' +import { Builder, DecoratedProcedure } from '@orpc/server' +import { openapi } from '../meta' + +declare module '@orpc/contract' { + interface ContractBuilder< + TErrorMap extends ErrorMap, + > { + route(meta: OpenAPIMeta): ContractBuilder + } + + interface ProcedureContractBuilderWithInput< + TInputSchema extends AnySchema, + TErrorMap extends ErrorMap, + > { + route(meta: OpenAPIMeta): ProcedureContractBuilderWithInput + } + + interface ProcedureContractBuilderWithOutput< + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + > { + route(meta: OpenAPIMeta): ProcedureContractBuilderWithOutput + } + + interface ProcedureContractBuilderWithInputOutput< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + > { + route(meta: OpenAPIMeta): ProcedureContractBuilderWithInputOutput + } +} + +ContractBuilder.prototype.route = function route(meta) { + return this.meta(openapi(meta)) +} + +declare module '@orpc/server' { + interface Builder< + TInitialContext extends Context, + TErrorMap extends ErrorMap, + > { + route(meta: OpenAPIMeta): Builder + } + + interface BuilderWithMiddlewares< + TInitialContext extends Context, + TInjectedContext extends Context, + TErrorMap extends ErrorMap, + > { + route(meta: OpenAPIMeta): BuilderWithMiddlewares + } + + interface BuilderWithInput< + TInitialContext extends Context, + TInjectedContext extends Context, + TInputSchema extends AnySchema, + TErrorMap extends ErrorMap, + > { + route(meta: OpenAPIMeta): BuilderWithInput + } + + interface BuilderWithOutput< + TInitialContext extends Context, + TInjectedContext extends Context, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + > { + route(meta: OpenAPIMeta): BuilderWithOutput + } + + interface BuilderWithInputOutput< + TInitialContext extends Context, + TInjectedContext extends Context, + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + > { + route(meta: OpenAPIMeta): BuilderWithInputOutput + } + + interface DecoratedProcedure< + TInitialContext extends Context, + TInjectedContext extends Context, + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, + TReturnedError extends AnyORPCError, + > { + route(meta: OpenAPIMeta): DecoratedProcedure + } +} + +Builder.prototype.route = function route(meta) { + return this.meta(openapi(meta)) +} + +DecoratedProcedure.prototype.route = function route(meta) { + return this.meta(openapi(meta)) +} diff --git a/packages/openapi/src/helpers/form-data.test.ts b/packages/openapi/src/helpers/form-data.test.ts new file mode 100644 index 000000000..c57543808 --- /dev/null +++ b/packages/openapi/src/helpers/form-data.test.ts @@ -0,0 +1,50 @@ +import { getIssueMessage, parseFormData } from './form-data' + +it('parseFormData', () => { + expect(parseFormData(new FormData())).toEqual({}) + + const form = new FormData() + form.append('a', '1') + form.append('user[name]', 'John') + form.append('user[age]', '20') + form.append('user[friends][]', 'Bob') + form.append('user[friends][]', 'Alice') + form.append('user[friends][]', 'Charlie') + form.append('thumb', new Blob(['hello']), 'thumb.png') + + expect(parseFormData(form)).toEqual({ + a: '1', + user: { + name: 'John', + age: '20', + friends: ['Bob', 'Alice', 'Charlie'], + }, + thumb: form.get('thumb'), + }) +}) + +it('getIssueMessage', () => { + expect(getIssueMessage(undefined, 'user[name]')).toBeUndefined() + expect(getIssueMessage({}, 'user[name]')).toBeUndefined() + expect(getIssueMessage({ data: {} }, 'user[name]')).toBeUndefined() + expect(getIssueMessage({ data: { issues: {} } }, 'user[name]')).toBeUndefined() + expect(getIssueMessage({ data: { issues: [] } }, 'user[name]')).toBeUndefined() + expect(getIssueMessage({ data: { issues: [{}] } }, 'user[name]')).toBeUndefined() + + expect(getIssueMessage({ data: { issues: [{ message: 'hi' }] } }, '')).toBe('hi') + expect(getIssueMessage({ data: { issues: [{ message: 'hi' }] } }, 'user[name]')).toBeUndefined() + + expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: ['user', 'name'] }] } }, 'user[name]')).toBe('hi') + expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: ['user', 'name'] }] } }, 'user[age]')).toBeUndefined() + + expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: [{ key: 'user' }, { key: 'name' }] }] } }, 'user[name]')).toBe('hi') + expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: [{ key: 'user' }, { key: 'name' }] }] } }, 'user[age]')).toBeUndefined() + + expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: ['users', '0'] }] } }, 'users[0]')).toBe('hi') + expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: ['users', '0'] }] } }, 'users[]')).toBe('hi') + expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: ['users', '0'] }] } }, 'users[1]')).toBeUndefined() + + expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: [{ key: '0' }] }] } }, '0')).toBe('hi') + expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: [{ key: '0' }] }] } }, '')).toBe('hi') + expect(getIssueMessage({ data: { issues: [{ message: 'hi', path: [{ key: '0' }] }] } }, '1')).toBeUndefined() +}) diff --git a/packages/openapi/src/helpers/form-data.ts b/packages/openapi/src/helpers/form-data.ts new file mode 100644 index 000000000..8b027782b --- /dev/null +++ b/packages/openapi/src/helpers/form-data.ts @@ -0,0 +1,93 @@ +import { isSchemaIssue } from '@orpc/contract' +import { isTypescriptObject } from '@orpc/shared' +import { BracketNotationSerializer } from '../bracket-notation' + +/** + * Parse a form data with [bracket notation](https://orpc.dev/docs/openapi/bracket-notation) syntax + * + * @example + * ```ts + * const form = new FormData() + * form.append('a', '1') + * form.append('user[name]', 'John') + * form.append('user[age]', '20') + * form.append('user[friends][]', 'Bob') + * form.append('user[friends][]', 'Alice') + * form.append('user[friends][]', 'Charlie') + * form.append('thumb', new Blob(['hello']), 'thumb.png') + * + * parseFormData(form) + * // { + * // a: '1', + * // user: { + * // name: 'John', + * // age: '20', + * // friends: ['Bob', 'Alice', 'Charlie'], + * // }, + * // thumb: form.get('thumb'), + * // } + * ``` + */ +export function parseFormData(form: FormData): any { + const serializer = new BracketNotationSerializer() + return serializer.deserialize(Array.from(form.entries())) as any +} + +/** + * Get the issue message from the error. + * + * @example + * ```tsx + * const { error, data, execute } = useServerAction(someAction) + * + * return
execute(parseFormData(form))}> + * + *

{getIssueMessage(error, 'user[name]')}

+ * + * + *

{getIssueMessage(error, 'user[age]')}

+ * + * + *

{getIssueMessage(error, 'images[]')}

+ *
+ * + * @param error - The error (can be anything) can contain `data.issues` (standard schema issues) + * @param path - The path of the field that has the issue follow [bracket notation](https://orpc.dev/docs/openapi/bracket-notation) + */ +export function getIssueMessage(error: unknown, path: string): string | undefined { + if (!isTypescriptObject(error) || !isTypescriptObject(error.data) || !Array.isArray(error.data.issues)) { + return undefined + } + + const serializer = new BracketNotationSerializer() + + for (const issue of error.data.issues) { + if (!isSchemaIssue(issue)) { + continue + } + + if (issue.path === undefined) { + if (path === '') { + return issue.message + } + + continue + } + + const issuePath = serializer.stringifyPath( + issue.path.map(segment => typeof segment === 'object' ? segment.key.toString() : segment.toString()), + ) + + if (issuePath === path) { + return issue.message + } + + if (path.endsWith('[]') && issuePath.replace(/\[(?:0|[1-9]\d*)\]$/, '[]') === path) { + return issue.message + } + + if (path === '' && /(?:0|[1-9]\d*)$/.test(issuePath)) { + return issue.message + } + } +} diff --git a/packages/openapi/src/helpers/index.test.ts b/packages/openapi/src/helpers/index.test.ts new file mode 100644 index 000000000..d39e54de2 --- /dev/null +++ b/packages/openapi/src/helpers/index.test.ts @@ -0,0 +1,5 @@ +it('exports helpers', async () => { + await expect(import('.')).resolves.toMatchObject({ + parseFormData: expect.any(Function), + }) +}) diff --git a/packages/openapi/src/helpers/index.ts b/packages/openapi/src/helpers/index.ts new file mode 100644 index 000000000..2a918a319 --- /dev/null +++ b/packages/openapi/src/helpers/index.ts @@ -0,0 +1 @@ +export * from './form-data' diff --git a/packages/openapi/src/index.test.ts b/packages/openapi/src/index.test.ts new file mode 100644 index 000000000..c4c7f8558 --- /dev/null +++ b/packages/openapi/src/index.test.ts @@ -0,0 +1,9 @@ +it('exports openapi meta, OpenAPIGenerator, OpenAPISerializer, OpenAPIJsonSerializer, BracketNotationSerializer', async () => { + await expect(import('.')).resolves.toMatchObject({ + openapi: expect.any(Function), + OpenAPIGenerator: 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 2e46433b1..4f6441520 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -1,15 +1,11 @@ -import { customOpenAPIOperation } from './openapi-custom' - -export * from './openapi-custom' +export * from './bracket-notation' +export * from './caller' +export * from './meta' export * from './openapi-generator' -export * from './openapi-utils' -export * from './router-client' -export * from './schema' -export * from './schema-converter' -export * from './schema-utils' - -export type { OpenAPI } from '@orpc/contract' +export * from './openapi-json-serializer' +export * from './openapi-serializer' +export * from './types' +export * from './types' +export * from './utils' -export const oo = { - spec: customOpenAPIOperation, -} +export { COMMON_ERROR_STATUS_MAP } from '@orpc/client' diff --git a/packages/openapi/src/meta.test.ts b/packages/openapi/src/meta.test.ts new file mode 100644 index 000000000..b93a7268d --- /dev/null +++ b/packages/openapi/src/meta.test.ts @@ -0,0 +1,314 @@ +import { oc } from '@orpc/contract' +import { getOpenAPIMeta, openapi } from './meta' + +describe('openapi meta', () => { + describe('openapi() function', () => { + it('returns a plugin with name ~openapi', () => { + const plugin = openapi({ method: 'GET', path: '/users' }) + expect(plugin.name).toBe('~openapi') + }) + + it('init meta on first time use', () => { + const meta = { method: 'GET', path: '/users', summary: 'List users' } as const + const procedure = oc.meta(openapi(meta)) + expect(getOpenAPIMeta(procedure)).toEqual(meta) + }) + + it('merges with existing ~openapi meta', () => { + const procedure = oc + .meta(openapi({ path: '/users', tags: ['users'] })) + .meta(openapi({ method: 'POST', summary: 'Create user' })) + expect(getOpenAPIMeta(procedure)).toMatchObject({ + method: 'POST', + path: '/users', + summary: 'Create user', + tags: ['users'], + }) + }) + + it('prioritize later call', () => { + const procedure = oc + .meta(openapi({ method: 'GET', summary: 'First' })) + .meta(openapi({ method: 'POST', summary: 'Second' })) + expect(getOpenAPIMeta(procedure)).toMatchObject({ + method: 'POST', + summary: 'Second', + }) + }) + + describe('tags merging', () => { + it('concatenates tags from multiple calls', () => { + const procedure = oc + .meta(openapi({ tags: ['existing-tag'] })) + .meta(openapi({ tags: ['new-tag'] })) + expect(getOpenAPIMeta(procedure)?.tags).toEqual(['existing-tag', 'new-tag']) + }) + + it('uses only incoming tags when no existing tags', () => { + const procedure = oc.meta(openapi({ tags: ['tag1'] })) + expect(getOpenAPIMeta(procedure)?.tags).toEqual(['tag1']) + }) + + it('uses only existing tags when no incoming tags', () => { + const procedure = oc + .meta(openapi({ tags: ['existing'] })) + .meta(openapi({})) + expect(getOpenAPIMeta(procedure)?.tags).toEqual(['existing']) + }) + }) + + describe('queryStyles merging', () => { + it('merges query styles from multiple calls', () => { + const procedure = oc + .meta(openapi({ queryStyles: { keyword: 'primitive' } })) + .meta(openapi({ queryStyles: { tags: 'array' } })) + + expect(getOpenAPIMeta(procedure)?.queryStyles).toEqual({ + keyword: 'primitive', + tags: 'array', + }) + }) + + it('prioritizes later query styles for the same key', () => { + const procedure = oc + .meta(openapi({ queryStyles: { tags: 'primitive' } })) + .meta(openapi({ queryStyles: { tags: 'array' } })) + + expect(getOpenAPIMeta(procedure)?.queryStyles).toEqual({ + tags: 'array', + }) + }) + + it('keeps existing query styles when later call omits them', () => { + const procedure = oc + .meta(openapi({ queryStyles: { tags: 'array' } })) + .meta(openapi({})) + + expect(getOpenAPIMeta(procedure)?.queryStyles).toEqual({ + tags: 'array', + }) + }) + }) + + describe('paramsStyles merging', () => { + it('merges param styles from multiple calls', () => { + const procedure = oc + .meta(openapi({ paramsStyles: { id: 'primitive' } })) + .meta(openapi({ paramsStyles: { tags: 'comma-delimited-array' } })) + + expect(getOpenAPIMeta(procedure)?.paramsStyles).toEqual({ + id: 'primitive', + tags: 'comma-delimited-array', + }) + }) + + it('prioritizes later param styles for the same key', () => { + const procedure = oc + .meta(openapi({ paramsStyles: { id: 'primitive' } })) + .meta(openapi({ paramsStyles: { id: 'comma-delimited-object' } })) + + expect(getOpenAPIMeta(procedure)?.paramsStyles).toEqual({ + id: 'comma-delimited-object', + }) + }) + + it('keeps existing param styles when later call omits them', () => { + const procedure = oc + .meta(openapi({ paramsStyles: { tags: 'comma-delimited-array' } })) + .meta(openapi({})) + + expect(getOpenAPIMeta(procedure)?.paramsStyles).toEqual({ + tags: 'comma-delimited-array', + }) + }) + }) + + describe('spec merging', () => { + it('uses incoming object spec when no existing spec', () => { + const spec = { operationId: 'listUsers' } + const procedure = oc.meta(openapi({ spec })) + expect(getOpenAPIMeta(procedure)?.spec).toBe(spec) + }) + + it('uses existing object spec when no incoming spec', () => { + const existingSpec = { operationId: 'listUsers' } + const procedure = oc + .meta(openapi({ spec: existingSpec })) + .meta(openapi({})) + expect(getOpenAPIMeta(procedure)?.spec).toBe(existingSpec) + }) + + it('composes two function specs: existing applied first, then incoming', () => { + const procedure = oc + .meta(openapi({ spec: current => ({ ...current, a: 1, order: 1 }) })) + .meta(openapi({ spec: current => ({ ...current, b: 2, order: 2 }) })) + const spec = getOpenAPIMeta(procedure)?.spec as any + expect(spec({ c: 3 })).toEqual({ a: 1, b: 2, c: 3, order: 2 }) + }) + + it('resolves existing function spec with incoming object spec eagerly', () => { + const procedure = oc + .meta(openapi({ spec: current => ({ ...current, fromExisting: true }) })) + .meta(openapi({ spec: { operationId: 'override' } })) + expect(getOpenAPIMeta(procedure)?.spec).toEqual({ + operationId: 'override', + fromExisting: true, + }) + }) + + it('applies incoming function spec to existing object spec eagerly', () => { + const procedure = oc + .meta(openapi({ spec: { operationId: 'base' } })) + .meta(openapi({ spec: current => ({ ...current, extra: true }) })) + expect(getOpenAPIMeta(procedure)?.spec).toEqual({ + operationId: 'base', + extra: true, + }) + }) + }) + + describe('prefix merging', () => { + it('concatenates prefixes from multiple calls', () => { + const procedure = oc + .meta(openapi({ prefix: '/api' })) + .meta(openapi({ prefix: '/v1' })) + expect(getOpenAPIMeta(procedure)?.prefix).toBe('/api/v1') + }) + + it('uses only incoming prefix when no existing prefix', () => { + const procedure = oc.meta(openapi({ prefix: '/api' })) + expect(getOpenAPIMeta(procedure)?.prefix).toBe('/api') + }) + + it('uses only existing prefix when no incoming prefix', () => { + const procedure = oc + .meta(openapi({ prefix: '/api' })) + .meta(openapi({})) + expect(getOpenAPIMeta(procedure)?.prefix).toBe('/api') + }) + + it('normalizes prefixes when merging', () => { + const procedure = oc + .meta(openapi({ prefix: '/api/' })) + .meta(openapi({ prefix: '/v1/' })) + expect(getOpenAPIMeta(procedure)?.prefix).toBe('/api/v1/') + }) + + it('handles multiple prefix merges', () => { + const procedure = oc + .meta(openapi({ prefix: '/api' })) + .meta(openapi({ prefix: '/v1' })) + .meta(openapi({ prefix: '/users' })) + expect(getOpenAPIMeta(procedure)?.prefix).toBe('/api/v1/users') + }) + }) + + it('metadata resets to its default behavior when set to `undefined` in subsequent calls', () => { + const procedure = oc + .meta(openapi({ + method: 'GET', + path: '/users', + summary: 'List users', + deprecated: true, + description: 'des', + inputStructure: 'detailed', + operationId: 'id', + outputStructure: 'detailed', + paramsStyles: { id: 'comma-delimited-array' }, + prefix: '/api', + queryStyles: { id: 'comma-delimited-object' }, + requestBodyHint: 'file', + responseBodyHint: 'file', + spec: () => ({}), + successDescription: 'success', + successStatus: 201, + tags: ['a', 'b'], + })) + .meta(openapi({ + method: undefined, + path: undefined, + summary: undefined, + deprecated: undefined, + description: undefined, + inputStructure: undefined, + operationId: undefined, + outputStructure: undefined, + paramsStyles: undefined, + prefix: undefined, + queryStyles: undefined, + requestBodyHint: undefined, + responseBodyHint: undefined, + spec: undefined, + successDescription: undefined, + successStatus: undefined, + tags: undefined, + })) + + expect(getOpenAPIMeta(procedure)).toEqual({}) + }) + }) + + describe('openapi.method', () => { + it('returns a plugin with name ~openapi/method', () => { + expect(openapi.method('GET').name).toBe('~openapi/method') + }) + + it('sets the openapi method', () => { + const procedure = oc.meta(openapi.method('POST')) + expect(getOpenAPIMeta(procedure)?.method).toBe('POST') + }) + }) + + describe('openapi.path', () => { + it('returns a plugin with name ~openapi/path', () => { + expect(openapi.path('/users').name).toBe('~openapi/path') + }) + + it('sets the openapi path', () => { + const procedure = oc.meta(openapi.path('/users')) + expect(getOpenAPIMeta(procedure)?.path).toBe('/users') + }) + }) + + describe('openapi.spec', () => { + it('returns a plugin with name ~openapi/spec', () => { + expect(openapi.spec({ operationId: 'test' }).name).toBe('~openapi/spec') + }) + + it('sets an openapi object spec', () => { + const spec = { operationId: 'listUsers', tags: ['users'] } + const procedure = oc.meta(openapi.spec(spec)) + expect(getOpenAPIMeta(procedure)?.spec).toBe(spec) + }) + + it('sets a openapi function spec', () => { + const specFn = (current: any) => ({ ...current, modified: true }) + const procedure = oc.meta(openapi.spec(specFn)) + expect(getOpenAPIMeta(procedure)?.spec).toBe(specFn) + }) + }) + + describe('openapi.prefix', () => { + it('returns a plugin with name ~openapi/prefix', () => { + expect(openapi.prefix('/api').name).toBe('~openapi/prefix') + }) + + it('sets the openapi prefix', () => { + const procedure = oc.meta(openapi.prefix('/api')) + expect(getOpenAPIMeta(procedure)?.prefix).toBe('/api') + }) + }) + + describe('getOpenAPIMeta', () => { + it('returns undefined when no openapi meta has been applied', () => { + const procedure = oc + expect(getOpenAPIMeta(procedure)).toBeUndefined() + }) + + it('returns the ~openapi meta from a ProcedureContract', () => { + const openAPIMeta = { method: 'GET' as const, path: '/users' as `/${string}` } + const procedure = oc.meta(openapi(openAPIMeta)) + expect(getOpenAPIMeta(procedure)).toMatchObject(openAPIMeta) + }) + }) +}) diff --git a/packages/openapi/src/meta.ts b/packages/openapi/src/meta.ts new file mode 100644 index 000000000..ba33e68d5 --- /dev/null +++ b/packages/openapi/src/meta.ts @@ -0,0 +1,420 @@ +import type { AnyProcedureContract, AnySchema, ErrorMap, MetaPlugin } from '@orpc/contract' +import type { Lazy } from '@orpc/server' +import type { Value } from '@orpc/shared' +import type { StandardBodyHint } from '@standardserver/core' +import type { OpenAPIOperationObject } from './types' +import { mergeHttpPath } from '@orpc/shared' + +export interface OpenAPIMeta { + /** + * HTTP method accepted by this procedure. + * + * @default 'POST' + */ + method?: 'HEAD' | 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | undefined + + /** + * URL path for this procedure. Supports dynamic segments via `${}` syntax. + * + * @example `/users`, `/users/${id}` + * @default Router segments joined by `'/` + */ + path?: `/${string}` | undefined + + /** + * Unique identifier for this operation in the OpenAPI spec. + * + * @default Router segments joined by `.` + */ + operationId?: string | undefined + + /** + * Short summary of the procedure, used as the operation summary in the generated spec. + */ + summary?: string | undefined + + /** + * Detailed description of the procedure, used as the operation description in the generated spec. + */ + description?: string | undefined + + /** + * Marks the procedure as deprecated in the generated spec. + */ + deprecated?: boolean | undefined + + /** + * Tags associated with this procedure. + * + * **Note**: Tags are merged when defined multiple times. + */ + tags?: string[] | undefined + + /** + * HTTP status code returned on success. Must be in the 200–399 range. + * + * @default 200 + */ + successStatus?: number | undefined + + /** + * Description of the successful response. + * + * @default 'OK' + */ + successDescription?: string | undefined + + /** + * Controls how individual path parameters are decoded. + * + * **Note**: Param styles are merged when defined multiple times. + * + * Each key maps a path parameter name to one of the following strategies: + * + * | Strategy | Encoded path segment | Decoded | OpenAPI Parameter Style | + * |--------------------------|----------------------|------------------------------|----------------------------------| + * | `primitive` *(default)* | `/users/42` | `{ id: '42' }` | `simple` | + * | `comma-delimited-array` | `/users/a,b,c` | `{ id: ['a', 'b', 'c'] }` | `simple` | + * | `comma-delimited-object` | `/users/a,1,b,2` | `{ id: { a: '1', b: '2' } }` | `simple` | + * + * **Strategy details:** + * + * - **`primitive`**: Keeps the decoded path segment as a string. + * - **`comma-delimited-array`**: Splits the decoded path segment on `,` into an array. + * - **`comma-delimited-object`**: Splits the decoded path segment on `,` into alternating key-value pairs. + * + * **Note**: `*-delimited-*` strategies do not support keys or values containing the delimiter character. + * + * @example + * ```ts + * // openapi.path = '/users/{id}/{tags}/{filters}' + * // GET /users/42/red,blue/size,large,brand,nike + * + * const paramsStyles = { + * id: 'primitive', + * tags: 'comma-delimited-array', + * filters: 'comma-delimited-object', + * } + * + * const inputSchema = z.object({ + * id: z.string(), + * tags: z.array(z.string()), + * filters: z.object({ + * size: z.string(), + * brand: z.string(), + * }), + * }) + * ``` + * + * @default `primitive` for all parameters + */ + paramsStyles?: Record< + string, + 'primitive' | 'comma-delimited-array' | 'comma-delimited-object' | undefined + > | undefined + + /** + * Controls how individual query parameters are encoding/decoding. + * + * **Note**: Query styles are merged when defined multiple times. + * + * Each key maps a query parameter name to one of the following strategies: + * + * | Strategy | Encoded | Decoded | OpenAPI Parameter Style | + * |--------------------------|---------------------------|--------------------------------|------------------------------------| + * | `primitive` | `?a=1&a=2` | `{ a: '2' }` | `form` / `explode: false` | + * | `array` | `?a=1&a=2` | `{ a: ['1', '2'] }` | `form` / `explode: true` | + * | `comma-delimited-array` | `?a=1,2,3` | `{ a: ['1', '2', '3'] }` | `form` / `explode: false` | + * | `comma-delimited-object` | `?a=A,1,B,2` | `{ a: { A: '1', B: '2' } }` | `form` / `explode: false` | + * | `space-delimited-array` | `?a=1 2 3` | `{ a: ['1', '2', '3'] }` | `spaceDelimited` / `explode: false`| + * | `space-delimited-object` | `?a=A 1 B 2` | `{ a: { A: '1', B: '2' } }` | `spaceDelimited` / `explode: false`| + * | `pipe-delimited-array` | `?a=1\|2\|3` | `{ a: ['1', '2', '3'] }` | `pipeDelimited` / `explode: false` | + * | `pipe-delimited-object` | `?a=A\|1\|B\|2` | `{ a: { A: '1', B: '2' } }` | `pipeDelimited` / `explode: false` | + * | `json` | `?meta={"key":"value"}` | `{ meta: { key: 'value' } }` | `content: application/json` | + * | _default_ | `?a[]=1&a[]=2&b=3&c[d]=4` | `{a:['1', '2'], b:3, c:{d:4}}` | `deepObject` / `explode: true` | + * + * **Strategy details:** + * + * - **`primitive`**: Takes the last occurrence of a repeated parameter. + * - **`array`**: Always produces an array, even for a single occurrence. + * - **`*-delimited-array`**: Splits the last value on the delimiter (`,`, space, or `|`) into an array. + * - **`*-delimited-object`**: Splits the last value on the delimiter into alternating key–value pairs. + * - **`json`**: Parses the last value as JSON; falls back to the raw string if parsing fails. + * - **`undefined`**: Standard bracket-notation decoding This is the default. + * + * **Note**: `*-delimited-*` strategies do not support keys or values containing the delimiter character. + * + * @example + * ```ts + * // GET /search?keyword=abc&tags=a,b,c&meta={"key":"value"} + * const queryParsing = { + * keyword: 'primitive', + * tags: 'comma-delimited-array', + * meta: 'json', + * } + * + * const inputSchema = z.object({ + * keyword: z.string(), + * tags: z.array(z.string()), + * meta: z.object({ key: z.string() }), + * }) + * ``` + * + * @default `undefined` for all parameters (bracket-notation decoding) + */ + queryStyles?: Record< + string, + 'primitive' | 'array' | 'comma-delimited-array' | 'comma-delimited-object' | 'space-delimited-array' | 'space-delimited-object' | 'pipe-delimited-array' | 'pipe-delimited-object' | 'json' | undefined + > | undefined + + /** + * Hint for how to parse the incoming request body. + * + * Note: The `standard-server` `Content-Type` header takes priority over this option. + * `form-data` and `url-search-params` are decoded using bracket notation, + * so the resulting value will be an object or array. + * + * @default Inferred from `Content-Type`, `Content-Disposition`, and `Content-Length` + */ + requestBodyHint?: StandardBodyHint | undefined + + /** + * Hint for how to parse the response body. + * + * Note: The `standard-server` `Content-Type` header takes priority over this option. + * `form-data` and `url-search-params` are decoded using bracket notation, + * so the resulting value will be an object or array. + * + * @default Inferred from `Content-Type`, `Content-Disposition`, and `Content-Length` + */ + responseBodyHint?: StandardBodyHint | undefined + + /** + * Determines how the input should be structured + * based on params, query, headers, and body. + * + * - `compact` — Merges params with either query or body + * (depending on the HTTP method) into a single flat object. + * Use this when you don't need access to headers and your + * param/query/body keys don't conflict. + * + * ```ts + * // GET /users/42?search=hello + * const inputValue = { id: 42, search: 'hello' } + * + * const inputSchema = z.object({ + * id: z.coerce.number(), // from params + * search: z.string(), // from query + * }) + * ``` + * + * - `detailed` — Keeps each part of the request as a + * separate nested field. Use this when you need access + * to headers, or when params and query/body keys + * might conflict. + * + * ```ts + * const inputValue = { + * params: { id: 1 }, + * query: { search: 'hello' }, + * headers: { 'content-type': 'application/json' }, + * body: 'body value', + * } + * + * const inputSchema = z.object({ + * params: z.object({ id: z.coerce.number() }), + * query: z.object({ search: z.string() }), + * headers: z.object({ 'content-type': z.string() }), + * body: z.string(), + * }) + * ``` + * + * @default 'compact' + */ + inputStructure?: 'compact' | 'detailed' | undefined + + /** + * Determines how the output should be structured + * into the HTTP response. + * + * - `compact` — The return value is sent directly as the + * response body. Status code comes from successStatus. + * + * ```ts + * const outputValue = { id: 1, name: 'Alice' } + * + * const outputSchema = z.object({ + * id: z.number(), + * name: z.string(), + * }) + * ``` + * + * - `detailed` — Return an object with optional properties: + * - status: HTTP status code (200–399). Defaults to + * successStatus if omitted. Use a literal type + * (e.g. z.literal(201)) so the generated spec can reflects + * the exact code. + * - headers: Custom headers to merge into the response + * (Record). + * - body: The response body. + * + * ```ts + * const outputValue = { + * status: 201, + * headers: { 'x-custom-header': 'value' }, + * body: 'body value', + * } + * + * const outputSchema = z.object({ + * status: z.literal(201).meta({ description: 'Record Created' }), + * headers: z.object({ 'x-custom-header': z.string() }), + * body: z.string(), + * }) + * ``` + * + * @default 'compact' + */ + outputStructure?: 'compact' | 'detailed' | undefined + + /** + * Override or extend the generated OpenAPI operation object for this procedure. + * + * Pass a plain object to replace entire operation object, or a function that receives the current + * operation object and returns the modified version. + * + * **Note**: Spec is merged when defined multiple times. + */ + spec?: Value + + /** + * Prefix for the path. Useful when you want to apply a common path prefix across multiple procedures. + * + * **Note**: Prefixes are merged when defined multiple times. + */ + prefix?: `/${string}` | undefined +} + +export interface OpenAPIMetaPlugin< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, +> extends MetaPlugin { + name: '~openapi' +} + +export interface OpenAPIMethodMetaPlugin< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, +> extends MetaPlugin { + name: '~openapi/method' +} + +export interface OpenAPIPathMetaPlugin< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, +> extends MetaPlugin { + name: '~openapi/path' +} + +export interface OpenAPISpecMetaPlugin< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, +> extends MetaPlugin { + name: '~openapi/spec' +} + +export interface OpenAPIPrefixMetaPlugin< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TErrorMap extends ErrorMap, +> extends MetaPlugin { + name: '~openapi/prefix' +} + +export interface OpenAPIFunction { + (meta: OpenAPIMeta): OpenAPIMetaPlugin + method(method: OpenAPIMeta['method']): OpenAPIMethodMetaPlugin + path(method: OpenAPIMeta['path']): OpenAPIPathMetaPlugin + spec(method: OpenAPIMeta['spec']): OpenAPISpecMetaPlugin + prefix(method: OpenAPIMeta['prefix']): OpenAPIPrefixMetaPlugin +} + +export const openapi: OpenAPIFunction = incoming => ({ + name: '~openapi', + init(meta) { + const existing = meta['~openapi'] as undefined | OpenAPIMeta + + const tags = existing?.tags && incoming.tags + ? [...existing.tags, ...incoming.tags] + : 'tags' in incoming ? incoming.tags : existing?.tags + + const queryStyles = existing?.queryStyles && incoming.queryStyles + ? { ...existing.queryStyles, ...incoming.queryStyles } + : 'queryStyles' in incoming ? incoming.queryStyles : existing?.queryStyles + + const paramsStyles = existing?.paramsStyles && incoming.paramsStyles + ? { ...existing.paramsStyles, ...incoming.paramsStyles } + : 'paramsStyles' in incoming ? incoming.paramsStyles : existing?.paramsStyles + + const existingSpec = existing?.spec + const incomingSpec = incoming.spec + + // AVOID a function spec, it requires the current spec as an argument, triggering an extra auto-generation step. + const spec: OpenAPIMeta['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 + + const prefix = existing?.prefix && incoming.prefix + ? mergeHttpPath(existing.prefix, incoming.prefix) + : 'prefix' in incoming ? incoming.prefix : existing?.prefix + + // TODO: throw if incoming.path missing dynamic from existing.path + + const merged: OpenAPIMeta = { + ...existing, + ...incoming, + tags, + queryStyles, + paramsStyles, + spec, + prefix, + } + + return { + ...meta, + '~openapi': merged, + } + }, +}) + +openapi.method = method => ({ + ...openapi({ method }), + name: '~openapi/method', +}) + +openapi.path = path => ({ + ...openapi({ path }), + name: '~openapi/path', +}) + +openapi.spec = spec => ({ + ...openapi({ spec }), + name: '~openapi/spec', +}) + +openapi.prefix = prefix => ({ + ...openapi({ prefix }), + name: '~openapi/prefix', +}) + +export function getOpenAPIMeta(procedureOrLazy: AnyProcedureContract | Lazy): OpenAPIMeta | undefined { + return procedureOrLazy['~orpc'].meta['~openapi'] as OpenAPIMeta | undefined +} diff --git a/packages/openapi/src/openapi-custom.test.ts b/packages/openapi/src/openapi-custom.test.ts deleted file mode 100644 index e2521e51f..000000000 --- a/packages/openapi/src/openapi-custom.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { OpenAPI } from '@orpc/contract' -import { oc } from '@orpc/contract' -import { os } from '@orpc/server' -import { applyCustomOpenAPIOperation, customOpenAPIOperation, getCustomOpenAPIOperation } from './openapi-custom' - -it('customOpenAPIOperation & getCustomOpenAPIOperation', () => { - const customed = customOpenAPIOperation({ value: 123 }, { security: [{ bearerAuth: [] }] }) - - expect(customed).toEqual({ value: 123 }) - expect(getCustomOpenAPIOperation(customed)).toEqual({ security: [{ bearerAuth: [] }] }) -}) - -describe('applyCustomOpenAPIOperation', () => { - it('no custom operation', () => { - const procedure = os.handler(() => {}) - - const operation: OpenAPI.OperationObject = { - parameters: [{ - name: 'id', - in: 'path', - required: true, - schema: { - type: 'string', - }, - }], - } - - expect(applyCustomOpenAPIOperation(operation, procedure)).toBe(operation) - }) - - it('custom at errors', () => { - const contract = oc.errors({ - AUTHENTICATION_FAILED: customOpenAPIOperation({}, { - security: [{ bearerAuth: [] }], - }), - TEST: undefined, // ensure check undefinable error map item - }) - - const operation: OpenAPI.OperationObject = { - parameters: [{ - name: 'id', - in: 'path', - required: true, - schema: { - type: 'string', - }, - }], - } - - expect(applyCustomOpenAPIOperation(operation, contract)).toEqual({ - ...operation, - security: [{ bearerAuth: [] }], - }) - }) - - it('custom at middlewares', () => { - const requiredAuth = os.middleware(({ next }) => next()) - const procedure = os - .use(customOpenAPIOperation(requiredAuth, { - security: [{ bearerAuth: [] }], - })) - .handler(() => {}) - - const operation: OpenAPI.OperationObject = { - parameters: [{ - name: 'id', - in: 'path', - required: true, - schema: { - type: 'string', - }, - }], - } - - expect(applyCustomOpenAPIOperation(operation, procedure)).toEqual({ - ...operation, - security: [{ bearerAuth: [] }], - }) - }) - - it('callback override', () => { - const requiredAuth = os.middleware(({ next }) => next()) - const callback = vi.fn() - const procedure = os - .use(customOpenAPIOperation(requiredAuth, callback)) - .handler(() => { }) - - const operation: OpenAPI.OperationObject = { - parameters: [{ - name: 'id', - in: 'path', - required: true, - schema: { - type: 'string', - }, - }], - } - - callback.mockReturnValue('__mocked__') - - expect(applyCustomOpenAPIOperation(operation, procedure)).toEqual('__mocked__') - - expect(callback).toBeCalledTimes(1) - expect(callback).toBeCalledWith(operation, procedure) - }) -}) diff --git a/packages/openapi/src/openapi-custom.ts b/packages/openapi/src/openapi-custom.ts deleted file mode 100644 index 43d4faf62..000000000 --- a/packages/openapi/src/openapi-custom.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { AnyContractProcedure, ErrorMap, OpenAPI } from '@orpc/contract' -import { isProcedure } from '@orpc/server' - -const OPERATION_EXTENDER_SYMBOL = Symbol('ORPC_OPERATION_EXTENDER') - -export type OverrideOperationValue - = | Partial - | ((current: OpenAPI.OperationObject, procedure: AnyContractProcedure) => OpenAPI.OperationObject) - -/** - * Customize The Operation Object by proxy an error map item or a middleware. - * - * @see {@link https://orpc.dev/docs/openapi/openapi-specification#customizing-operation-objects Customizing Operation Objects Docs} - */ -export function customOpenAPIOperation(o: T, extend: OverrideOperationValue): T { - return new Proxy(o, { - get(target, prop, receiver) { - if (prop === OPERATION_EXTENDER_SYMBOL) { - return extend - } - - return Reflect.get(target, prop, receiver) - }, - }) -} - -export function getCustomOpenAPIOperation(o: object): OverrideOperationValue | undefined { - return (o as any)[OPERATION_EXTENDER_SYMBOL] as OverrideOperationValue | undefined -} - -export function applyCustomOpenAPIOperation(operation: OpenAPI.OperationObject, contract: AnyContractProcedure): OpenAPI.OperationObject { - const operationCustoms: OverrideOperationValue[] = [] - - for (const errorItem of Object.values(contract['~orpc'].errorMap) as ErrorMap[keyof ErrorMap][]) { - const maybeExtender = errorItem ? getCustomOpenAPIOperation(errorItem) : undefined - - if (maybeExtender) { - operationCustoms.push(maybeExtender) - } - } - - if (isProcedure(contract)) { - for (const middleware of contract['~orpc'].middlewares) { - const maybeExtender = getCustomOpenAPIOperation(middleware) - - if (maybeExtender) { - operationCustoms.push(maybeExtender) - } - } - } - - let currentOperation = operation - - for (const custom of operationCustoms) { - if (typeof custom === 'function') { - currentOperation = custom(currentOperation, contract) - } - else { - currentOperation = { - ...currentOperation, - ...custom, - } - } - } - - return currentOperation -} diff --git a/packages/openapi/src/openapi-generator.test.ts b/packages/openapi/src/openapi-generator.test.ts index 665307715..247470ac9 100644 --- a/packages/openapi/src/openapi-generator.test.ts +++ b/packages/openapi/src/openapi-generator.test.ts @@ -1,280 +1,1899 @@ -import type { AnyContractProcedure } from '@orpc/contract' +import type { JsonSchemaConverter } from '@orpc/json-schema' import { eventIterator, oc } from '@orpc/contract' -import * as z from 'zod' -import { ZodToJsonSchemaConverter } from '../../zod/src/zod4' -import { customOpenAPIOperation } from './openapi-custom' +import * as arktype from 'arktype' +import z from 'zod' +import { openapi } from './meta' import { OpenAPIGenerator } from './openapi-generator' -type TestCase = { - name: string - contract: AnyContractProcedure - expected: any - error?: undefined -} | { - name: string - contract: AnyContractProcedure - expected?: undefined - error: string -} - -const routeTests: TestCase[] = [ - { - name: 'default', - contract: oc, - expected: { - '/': { - post: expect.any(Object), - }, - }, - }, - { - name: 'path + method', - contract: oc.route({ path: '/planets', method: 'GET' }), - expected: { - '/planets': { - get: expect.any(Object), - }, - }, - }, - { - name: 'dynamic params + method', - contract: oc.route({ path: '/planets/{id}', method: 'DELETE' }).input(z.object({ id: z.string() })), - expected: { - '/planets/{id}': { - delete: expect.any(Object), - }, - }, - }, - { - name: 'rest params + method', - contract: oc.route({ path: '/planets/{+path}', method: 'DELETE' }).input(z.object({ path: z.string() })), - expected: { - '/planets/{path}': { - delete: expect.any(Object), - }, +describe('openAPIGenerator', () => { + const zodJsonSchemaConverter: JsonSchemaConverter = { + condition: schema => schema?.['~standard'].vendor === 'zod', + async convert(schema, direction) { + const jsonSchema = z.toJSONSchema(schema as any, { io: direction }) + const output = await schema?.['~standard'].validate(undefined) + return [jsonSchema as any, !output?.issues] }, - }, - { - name: 'metadata', - contract: oc.route({ - operationId: 'customOperationId', - tags: ['planets'], - summary: 'the summary', - description: 'the description', - successStatus: 203, - successDescription: 'the success description', - deprecated: true, - }), - expected: { - '/': { + } + + const generator = new OpenAPIGenerator({ converters: [zodJsonSchemaConverter] }) + + describe('basic & options', () => { + it('starts from the default base document', async () => { + await expect(generator.generate({})).resolves.toEqual({ + openapi: '3.1.2', + info: { + title: 'API Reference', + version: '0.0.0', + }, + }) + }) + + it('merges the provided base document and serialize the result', async () => { + const serializer = { + serialize: vi.fn(document => document), + deserialize: vi.fn(document => document), + } + + const generator = new OpenAPIGenerator({ serializer, converters: [zodJsonSchemaConverter] }) + + const doc = await generator.generate({}, { + base: { + info: { + title: 'Planet API', + version: '1.2.3', + }, + servers: [{ url: 'https://api.example.com' }], + }, + }) + + expect(serializer.serialize).toHaveBeenCalledWith({ + openapi: '3.1.2', + info: { + title: 'Planet API', + version: '1.2.3', + }, + servers: [{ url: 'https://api.example.com' }], + }, { + asFormData: false, + useFormDataForBlobFields: false, + }) + + expect(doc).toEqual({ + openapi: '3.1.2', + info: { + title: 'Planet API', + version: '1.2.3', + }, + servers: [{ url: 'https://api.example.com' }], + }) + }) + + it('invokes filter with the walked path and excludes filtered procedures', async () => { + const publicProcedure = oc.meta(openapi({ method: 'GET' })) + const privateProcedure = oc.meta(openapi({ method: 'GET' })) + + 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.paths).toEqual({ + '/public': { + get: expect.any(Object), + }, + }) + }) + + it('fallback to StandardJsonSchemaConverter', async () => { + const condition = vi.fn(() => false) + const generator = new OpenAPIGenerator({ converters: [{ condition, convert: vi.fn() }] }) + + const procedure = oc.input(arktype.type({ name: 'string' })) + + const spec = await generator.generate(procedure) + + // ensure it prioritizes the provided converters + expect(condition).toHaveBeenCalledTimes(2) + expect(spec.paths?.['/']).toEqual({ post: expect.objectContaining({ - operationId: 'customOperationId', + requestBody: expect.objectContaining({ + content: { + 'application/json': expect.objectContaining({ + schema: { + properties: { + name: { + type: 'string', + }, + }, + required: ['name'], + type: 'object', + }, + }), + }, + }), + }), + }) + }) + }) + + describe('route', () => { + it('derives the default path, method, and operationId from router segments', async () => { + const doc = await generator.generate({ + admin: { + listUsers: oc + .input(z.object({ page: z.number().optional() })) + .output(z.object({ users: z.array(z.string()) })), + }, + }) + + expect(doc.paths).toEqual({ + '/admin/listUsers': { + post: expect.objectContaining({ + operationId: 'admin.listUsers', + responses: { + 200: expect.any(Object), + }, + }), + }, + }) + }) + + it('applies explicit metadata and prefixes', async () => { + const doc = await generator.generate({ + getPlanet: oc + .meta(openapi({ + method: 'GET', + prefix: '/api/v2', + path: '/planets/{id}', + operationId: 'getPlanetById', + tags: ['planets'], + summary: 'Get a planet', + description: 'Returns a single planet.', + deprecated: true, + successStatus: 206, + successDescription: 'Planet payload', + })) + .input(z.object({ id: z.string() })), + }) + + expect(doc.paths?.['/api/v2/planets/{id}']).toEqual({ + get: expect.objectContaining({ + operationId: 'getPlanetById', tags: ['planets'], - summary: 'the summary', - description: 'the description', + summary: 'Get a planet', + description: 'Returns a single planet.', deprecated: true, + parameters: [ + expect.objectContaining({ + name: 'id', + in: 'path', + required: true, + }), + ], responses: { - 203: expect.objectContaining({ - description: 'the success description', + 206: expect.objectContaining({ + description: 'Planet payload', }), }, }), - }, - }, - }, -] - -const inputTests: TestCase[] = [ - { - name: 'invalid input', - contract: oc.route({ path: '/planets/{id}' }), - error: 'When input structure is "compact", and path has dynamic params, input schema must be an object with all dynamic params as required.', - }, - { - name: 'invalid input', - contract: oc.route({ path: '/planets/{id}' }).input(z.string()), - error: 'When input structure is "compact", and path has dynamic params, input schema must be an object with all dynamic params as required.', - }, - { - name: 'params must be required', - contract: oc.route({ path: '/planets/{id}' }).input(z.object({ id: z.string().optional(), value: z.string().optional() })), - error: 'When input structure is "compact", and path has dynamic params, input schema must be an object with all dynamic params as required.', - }, - { - name: 'dynamic params + body', - contract: oc.route({ path: '/planets/{id}' }).input(z.object({ id: z.string(), value: z.string() })), - expected: { - '/planets/{id}': { + }) + }) + + it('can extends spec with openapi.spec function', async () => { + const doc = await generator.generate({ + getPlanet: oc + .meta(openapi({ + method: 'GET', + path: '/planets/{id}', + spec: current => ({ + ...current, + 'security': [{ bearerAuth: [] }], + 'x-orpc-kind': 'planet-read', + }), + })) + .input(z.object({ id: z.string() })), + }) + + expect(doc.paths?.['/planets/{id}']).toEqual({ + get: expect.objectContaining({ + 'operationId': 'getPlanet', + 'security': [{ bearerAuth: [] }], + 'x-orpc-kind': 'planet-read', + 'parameters': [ + expect.objectContaining({ + name: 'id', + in: 'path', + required: true, + }), + ], + 'responses': { + 200: expect.any(Object), + }, + }), + }) + }) + + it('can override spec with openapi.spec object', async () => { + const doc = await generator.generate({ + getPlanet: oc + .meta(openapi({ + method: 'GET', + path: '/planets/{id}', + operationId: 'getPlanetById', + spec: { + 'operationId': 'custom.getPlanet', + 'security': [{ bearerAuth: [] }], + 'x-orpc-kind': 'planet-read', + }, + })) + .input(z.object({ id: z.string() })), + }) + + expect(doc.paths?.['/planets/{id}']).toEqual({ + get: { + 'operationId': 'custom.getPlanet', + 'security': [{ bearerAuth: [] }], + 'x-orpc-kind': 'planet-read', + }, + }) + }) + }) + + describe('request params', () => { + it('maps compact path params', async () => { + const doc = await generator.generate({ + search: oc + .meta(openapi({ method: 'GET', path: '/planets/{id}/{+rest}', prefix: '/{workspaceId}' })) + .input(z.object({ + workspaceId: z.string(), + id: z.string(), + rest: z.string(), + filter: z.string(), + })), + }) + + expect(doc.paths?.['/{workspaceId}/planets/{id}/{rest}']).toEqual({ + get: expect.objectContaining({ + parameters: expect.arrayContaining([ + { + name: 'workspaceId', + in: 'path', + required: true, + schema: expect.objectContaining({ type: 'string' }), + }, + { + name: 'id', + in: 'path', + required: true, + schema: expect.objectContaining({ type: 'string' }), + }, + { + name: 'rest', + in: 'path', + required: true, + schema: expect.objectContaining({ type: 'string' }), + }, + ]), + }), + }) + }) + + it('maps detailed path params', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets/{id}/{+rest}', + inputStructure: 'detailed', + prefix: '/{workspaceId}', + })) + .input(z.object({ + params: z.object({ workspaceId: z.string(), id: z.string(), rest: z.string() }), + })), + }) + + expect(doc.paths?.['/{workspaceId}/planets/{id}/{rest}']).toEqual({ post: expect.objectContaining({ - parameters: [ + parameters: expect.arrayContaining([ + { + name: 'workspaceId', + in: 'path', + required: true, + schema: expect.objectContaining({ type: 'string' }), + }, { name: 'id', in: 'path', required: true, - schema: { - type: 'string', - }, + schema: expect.objectContaining({ type: 'string' }), }, - ], - requestBody: { - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - value: { - type: 'string', - }, - }, - required: ['value'], - }, - }, + { + name: 'rest', + in: 'path', + required: true, + schema: expect.objectContaining({ type: 'string' }), }, - required: true, - }, + ]), }), - }, - }, - }, - { - name: 'dynamic params only (no body)', - contract: oc.route({ method: 'POST', path: '/planets/{id}' }).input(z.object({ id: z.string() })), - expected: { - '/planets/{id}': { - post: expect.toSatisfy((v: any) => v.parameters?.length === 1 && !v.requestBody), - }, - }, - }, - { - name: 'query + params', - contract: oc.route({ path: '/planets/{id}', method: 'GET' }).input( - z.object({ - id: z.string(), - query1: z.string(), - query2: z.number().optional(), - query3: z.object({ - a: z.string(), - }).optional(), - query4: z.array(z.number()).or(z.number()), - }), - ), - expected: { - '/planets/{id}': { + }) + }) + + it('maps all supported params styles in compact input structure mode', async () => { + const doc = await generator.generate({ + readPlanet: oc + .meta(openapi({ + method: 'GET', + path: '/planets/{id}/{tags}/{filters}', + paramsStyles: { + id: 'primitive', + tags: 'comma-delimited-array', + filters: 'comma-delimited-object', + }, + })) + .input(z.object({ + id: z.string(), + tags: z.array(z.string()), + filters: z.object({ brand: z.string(), size: z.string() }), + })) + .output(z.object({ ok: z.boolean() })), + }) + + expect(doc.paths?.['/planets/{id}/{tags}/{filters}']).toEqual({ get: expect.objectContaining({ - parameters: [ + parameters: expect.arrayContaining([ { name: 'id', in: 'path', required: true, - schema: { - type: 'string', - }, + schema: expect.objectContaining({ type: 'string' }), + }, + { + name: 'tags', + in: 'path', + required: true, + schema: expect.objectContaining({ type: 'array' }), + }, + { + name: 'filters', + in: 'path', + required: true, + schema: expect.objectContaining({ type: 'object' }), + }, + ]), + }), + }) + }) + + it('maps all supported params styles in detailed input structure mode', async () => { + const doc = await generator.generate({ + readPlanet: oc + .meta(openapi({ + method: 'GET', + path: '/planets/{id}/{tags}/{filters}', + inputStructure: 'detailed', + paramsStyles: { + id: 'primitive', + tags: 'comma-delimited-array', + filters: 'comma-delimited-object', + }, + })) + .input(z.object({ + params: z.object({ + id: z.string(), + tags: z.array(z.string()), + filters: z.object({ brand: z.string(), size: z.string() }), + }), + })), + }) + + expect(doc.paths?.['/planets/{id}/{tags}/{filters}']).toEqual({ + get: expect.objectContaining({ + parameters: expect.arrayContaining([ + { + name: 'id', + in: 'path', + required: true, + schema: expect.objectContaining({ type: 'string' }), + }, + { + name: 'tags', + in: 'path', + required: true, + schema: expect.objectContaining({ type: 'array' }), + }, + { + name: 'filters', + in: 'path', + required: true, + schema: expect.objectContaining({ type: 'object' }), + }, + ]), + }), + }) + }) + + it.each([ + { + name: 'compact input with dynamic params but no input schema', + procedure: oc.meta(openapi({ path: '/planets/{id}' })), + message: 'Procedure at path "test" has dynamic path params (id) but its input schema is not an object.', + }, + { + name: 'compact input with dynamic params but non-object input schema', + procedure: oc.meta(openapi({ path: '/planets/{id}' })).input(z.string()), + message: 'Procedure at path "test" has dynamic path params (id) but its input schema is not an object.', + }, + { + name: 'compact input with optional dynamic params', + procedure: oc.meta(openapi({ path: '/planets/{id}' })).input(z.object({ + id: z.string().optional(), + value: z.string().optional(), + })), + message: 'Procedure at path "test" has dynamic param "id" marked as optional in its input schema, but path params must always be required in OpenAPI', + }, + { + name: 'detailed input with optional dynamic params', + procedure: oc.meta(openapi({ inputStructure: 'detailed', path: '/{id}' })).input(z.object({ + params: z.object({ id: z.string().optional() }), + })), + message: 'Procedure at path "test" has dynamic param "id" marked as optional in its input schema, but path params must always be required in OpenAPI', + }, + ])('throws when $name', async ({ procedure, message }) => { + await expect(generator.generate({ test: procedure })).rejects.toThrow(message) + }) + }) + + describe('request query', () => { + it('maps compact GET query parameters', async () => { + const doc = await generator.generate({ + search: oc + .meta(openapi({ + method: 'GET', + path: '/planets/{id}', + queryStyles: { + filter: 'primitive', + tags: 'array', }, + })) + .input(z.object({ + id: z.string(), + filter: z.string(), + tags: z.array(z.string()), + meta: z.object({ published: z.boolean() }).optional(), + })), + }) + + expect(doc.paths?.['/planets/{id}']).toEqual({ + get: expect.objectContaining({ + parameters: expect.arrayContaining([ { - name: 'query1', + name: 'filter', in: 'query', required: true, - schema: { - type: 'string', - }, allowEmptyValue: true, allowReserved: true, + schema: expect.objectContaining({ type: 'string' }), }, { - name: 'query2', + name: 'tags', in: 'query', - required: false, - schema: { - type: 'number', - }, + required: true, allowEmptyValue: true, allowReserved: true, + schema: expect.objectContaining({ type: 'array' }), }, { - name: 'query3', + name: 'meta', in: 'query', - required: false, - schema: { - type: 'object', - properties: { - a: { - type: 'string', - }, - }, - required: ['a'], - }, style: 'deepObject', explode: true, allowEmptyValue: true, allowReserved: true, + schema: expect.objectContaining({ type: 'object' }), }, + ]), + }), + }) + }) + + it('maps detailed query parameters', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets/{id}', + inputStructure: 'detailed', + })) + .input(z.object({ + params: z.object({ id: z.string() }), + query: z.object({ expand: z.boolean().optional() }), + })) + .output(z.object({ ok: z.boolean() })), + }) + + expect(doc.paths?.['/planets/{id}']).toEqual({ + post: expect.objectContaining({ + parameters: expect.arrayContaining([ { - name: 'query4', + name: 'expand', in: 'query', - required: true, - schema: { - anyOf: [ - { - type: 'array', - items: { - type: 'number', - }, - }, - { - type: 'number', - }, - ], - }, allowEmptyValue: true, allowReserved: true, + schema: expect.objectContaining({ type: 'boolean' }), }, - ], + ]), }), - }, - }, - }, - { - name: 'not throw in GET + any input', - contract: oc.route({ method: 'GET' }).input(z.any()), - expected: { - '/': { + }) + }) + + it('maps default query styles as primitive/array and fallback to deepObject in compact input structure mode', async () => { + const doc = await generator.generate({ + search: oc + .meta(openapi({ + method: 'GET', + })) + .input(z.object({ + primitive: z.string(), + arrayable: z.array(z.string()).or(z.string()), + array: z.array(z.string()), + object: z.object({ nested: z.string() }), + })) + .output(z.object({ ok: z.boolean() })), + }) + + expect(doc.paths?.['/search']).toEqual({ get: expect.objectContaining({ - }), - }, - }, - }, - { - name: 'GET + non-object input', - contract: oc.route({ method: 'GET' }).input(z.string()), - error: 'When method is "GET", input schema must satisfy: object | any | unknown', - }, - { - name: 'file', - contract: oc.input(z.file().mime(['image/png'])), - expected: { - '/': { - post: expect.objectContaining({ - requestBody: { - content: { - 'image/png': { - schema: { - type: 'string', - contentMediaType: 'image/png', - }, - }, + parameters: expect.arrayContaining([ + { + name: 'primitive', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'string' }), }, - required: true, - }, - }), + { + name: 'arrayable', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ anyOf: expect.any(Array) }), + }, + { + name: 'array', + in: 'query', + required: true, + style: 'deepObject', + explode: true, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'array' }), + }, + { + name: 'object', + in: 'query', + required: true, + style: 'deepObject', + explode: true, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'object' }), + }, + ]), + }), + }) + }) + + it('maps default query styles as primitive and fallback to deepObject in detailed input structure mode', async () => { + const doc = await generator.generate({ + search: oc + .meta(openapi({ + method: 'GET', + inputStructure: 'detailed', + })) + .input(z.object({ + query: z.object({ + primitive: z.string(), + array: z.array(z.string()), + object: z.object({ nested: z.string() }), + }), + })) + .output(z.object({ ok: z.boolean() })), + }) + + expect(doc.paths?.['/search']).toEqual({ + get: expect.objectContaining({ + parameters: expect.arrayContaining([ + { + name: 'primitive', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'string' }), + }, + { + name: 'array', + in: 'query', + required: true, + style: 'deepObject', + explode: true, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'array' }), + }, + { + name: 'object', + in: 'query', + required: true, + style: 'deepObject', + explode: true, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'object' }), + }, + ]), + }), + }) + }) + + it('maps all supported query styles in compact input structure mode', async () => { + const doc = await generator.generate({ + search: oc + .meta(openapi({ + method: 'GET', + queryStyles: { + primitive: 'primitive', + array: 'array', + commaArray: 'comma-delimited-array', + commaObject: 'comma-delimited-object', + spaceArray: 'space-delimited-array', + spaceObject: 'space-delimited-object', + pipeArray: 'pipe-delimited-array', + pipeObject: 'pipe-delimited-object', + json: 'json', + bracketObject: undefined, + }, + })) + .input(z.object({ + primitive: z.string(), + array: z.array(z.string()), + commaArray: z.array(z.string()), + commaObject: z.object({ a: z.string(), b: z.string() }), + spaceArray: z.array(z.string()), + spaceObject: z.object({ a: z.string(), b: z.string() }), + pipeArray: z.array(z.string()), + pipeObject: z.object({ a: z.string(), b: z.string() }), + json: z.object({ enabled: z.boolean() }), + bracketObject: z.object({ nested: z.string() }), + })) + .output(z.object({ ok: z.boolean() })), + }) + + expect(doc.paths?.['/search']).toEqual({ + get: expect.objectContaining({ + parameters: expect.arrayContaining([ + { + name: 'primitive', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'string' }), + }, + { + name: 'array', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'array' }), + }, + { + name: 'commaArray', + in: 'query', + required: true, + explode: false, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'array' }), + }, + { + name: 'commaObject', + in: 'query', + required: true, + explode: false, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'object' }), + }, + { + name: 'spaceArray', + in: 'query', + required: true, + style: 'spaceDelimited', + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'array' }), + }, + { + name: 'spaceObject', + in: 'query', + required: true, + style: 'spaceDelimited', + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'object' }), + }, + { + name: 'pipeArray', + in: 'query', + required: true, + style: 'pipeDelimited', + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'array' }), + }, + { + name: 'pipeObject', + in: 'query', + required: true, + style: 'pipeDelimited', + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'object' }), + }, + { + name: 'json', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + content: { + 'application/json': { + schema: expect.objectContaining({ type: 'object' }), + }, + }, + }, + { + name: 'bracketObject', + in: 'query', + required: true, + style: 'deepObject', + explode: true, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'object' }), + }, + ]), + }), + }) + }) + + it('maps all supported query styles in detailed input structure mode', async () => { + const doc = await generator.generate({ + search: oc + .meta(openapi({ + method: 'GET', + inputStructure: 'detailed', + queryStyles: { + primitive: 'primitive', + array: 'array', + commaArray: 'comma-delimited-array', + commaObject: 'comma-delimited-object', + spaceArray: 'space-delimited-array', + spaceObject: 'space-delimited-object', + pipeArray: 'pipe-delimited-array', + pipeObject: 'pipe-delimited-object', + json: 'json', + bracketObject: undefined, + }, + })) + .input(z.object({ + query: z.object({ + primitive: z.string(), + array: z.array(z.string()), + commaArray: z.array(z.string()), + commaObject: z.object({ a: z.string(), b: z.string() }), + spaceArray: z.array(z.string()), + spaceObject: z.object({ a: z.string(), b: z.string() }), + pipeArray: z.array(z.string()), + pipeObject: z.object({ a: z.string(), b: z.string() }), + json: z.object({ enabled: z.boolean() }), + bracketObject: z.object({ nested: z.string() }), + }), + })) + .output(z.object({ ok: z.boolean() })), + }) + + expect(doc.paths?.['/search']).toEqual({ + get: expect.objectContaining({ + parameters: expect.arrayContaining([ + { + name: 'primitive', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'string' }), + }, + { + name: 'array', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'array' }), + }, + { + name: 'commaArray', + in: 'query', + required: true, + explode: false, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'array' }), + }, + { + name: 'commaObject', + in: 'query', + required: true, + explode: false, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'object' }), + }, + { + name: 'spaceArray', + in: 'query', + required: true, + style: 'spaceDelimited', + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'array' }), + }, + { + name: 'spaceObject', + in: 'query', + required: true, + style: 'spaceDelimited', + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'object' }), + }, + { + name: 'pipeArray', + in: 'query', + required: true, + style: 'pipeDelimited', + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'array' }), + }, + { + name: 'pipeObject', + in: 'query', + required: true, + style: 'pipeDelimited', + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'object' }), + }, + { + name: 'json', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + content: { + 'application/json': { + schema: expect.objectContaining({ type: 'object' }), + }, + }, + }, + { + name: 'bracketObject', + in: 'query', + required: true, + style: 'deepObject', + explode: true, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'object' }), + }, + ]), + }), + }) + }) + + it.each([ + { + name: 'GET input with a non-object schema', + procedure: oc.meta(openapi({ method: 'GET' })).input(z.string()), + message: 'Procedure at path "test" uses method "GET" but its input schema is not an object.', + }, + ])('throws when $name', async ({ procedure, message }) => { + await expect(generator.generate({ test: procedure })).rejects.toThrow(message) + }) + }) + + describe('request headers', () => { + it('maps detailed request headers', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets/{id}', + inputStructure: 'detailed', + })) + .input(z.object({ + params: z.object({ id: z.string() }), + headers: z.object({ 'x-trace-id': z.string() }), + })) + .output(z.object({ ok: z.boolean() })), + }) + + expect(doc.paths?.['/planets/{id}']).toEqual({ + post: expect.objectContaining({ + parameters: expect.arrayContaining([ + { + name: 'x-trace-id', + in: 'header', + required: true, + schema: expect.objectContaining({ type: 'string' }), + }, + ]), + }), + }) + }) + }) + + describe('request body', () => { + it('maps compacted request bodies', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets', + })) + .input(z.object({ name: z.string() })), + }) + + expect(doc.paths?.['/planets']).toEqual({ + post: expect.objectContaining({ + requestBody: { + required: true, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + }), + }, + }, + }, + }), + }) + }) + + it('maps detailed request bodies', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets/{id}', + inputStructure: 'detailed', + })) + .input(z.object({ + params: z.object({ id: z.string() }), + body: z.object({ name: z.string() }), + })), + }) + + expect(doc.paths?.['/planets/{id}']).toEqual({ + post: expect.objectContaining({ + requestBody: { + required: true, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + }), + }, + }, + }, + }), + }) + }) + + it('maps compacted optional request bodies', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets', + })) + .input(z.object({ name: z.string() }).optional()), + }) + + expect(doc.paths?.['/planets']).toEqual({ + post: expect.objectContaining({ + requestBody: { + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + }), + }, + }, + }, + }), + }) + }) + + it('maps detailed optional request bodies', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets/{id}', + inputStructure: 'detailed', + })) + .input(z.object({ + params: z.object({ id: z.string() }), + body: z.object({ name: z.string() }).optional(), + })), + }) + + expect(doc.paths?.['/planets/{id}']).toEqual({ + post: expect.objectContaining({ + requestBody: { + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + }), + }, + }, + }, + }), + }) + }) + + describe('with files', () => { + it('maps compacted request bodies as files', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets', + })) + .input(z.file().mime(['application/pdf', 'application/xml'])), + }) + + expect(doc.paths?.['/planets']).toEqual({ + post: expect.objectContaining({ + requestBody: { + required: true, + content: { + 'application/pdf': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + 'application/xml': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + }, + }, + }), + }) + }) + + it('maps detailed request bodies as files', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets', + inputStructure: 'detailed', + })) + .input(z.object({ body: z.file().mime(['application/pdf', 'application/xml']) })), + }) + + expect(doc.paths?.['/planets']).toEqual({ + post: expect.objectContaining({ + requestBody: { + required: true, + content: { + 'application/pdf': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + 'application/xml': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + }, + }, + }), + }) + }) + + it('maps compacted request bodies as files without mine', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets', + })) + .input(z.file()), + }) + + expect(doc.paths?.['/planets']).toEqual({ + post: expect.objectContaining({ + requestBody: { + required: true, + content: { + '*/*': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + }, + }, + }), + }) + }) + + it('maps detailed request bodies as files without mine', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets', + inputStructure: 'detailed', + })) + .input(z.object({ body: z.file() })), + }) + + expect(doc.paths?.['/planets']).toEqual({ + post: expect.objectContaining({ + requestBody: { + required: true, + content: { + '*/*': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + }, + }, + }), + }) + }) + + it('maps compacted request bodies with nested files', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets', + })) + .input(z.object({ file: z.file().mime(['application/pdf', 'application/xml']) })), + }) + + expect(doc.paths?.['/planets']).toEqual({ + post: expect.objectContaining({ + requestBody: { + required: true, + content: { + 'multipart/form-data': { + schema: expect.objectContaining({ + type: 'object', + }), + }, + }, + }, + }), + }) + }) + + it('maps detailed request bodies with nested files', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets', + inputStructure: 'detailed', + })) + .input(z.object({ body: z.object({ file: z.file().mime(['application/pdf', 'application/xml']) }) })), + }) + + expect(doc.paths?.['/planets']).toEqual({ + post: expect.objectContaining({ + requestBody: { + required: true, + content: { + 'multipart/form-data': { + schema: expect.objectContaining({ + type: 'object', + }), + }, + }, + }, + }), + }) + }) + }) + + it('maps event iterator inputs to an SSE request body', async () => { + const doc = await generator.generate({ + subscribe: oc + .meta(openapi({})) + .input(eventIterator(z.string(), z.boolean())), + }) + + expect(doc.paths?.['/subscribe']).toEqual({ + post: expect.objectContaining({ + requestBody: { + content: { + 'text/event-stream': { + schema: { + oneOf: [ + { + type: 'object', + properties: { + event: { const: 'message' }, + data: { type: 'string' }, + id: { type: 'string' }, + retry: { type: 'number' }, + }, + required: ['event', 'data'], + }, + { + type: 'object', + properties: { + event: { const: 'close' }, + data: { type: 'boolean' }, + id: { type: 'string' }, + retry: { type: 'number' }, + }, + required: ['event', 'data'], + }, + { + type: 'object', + properties: { + event: { const: 'error' }, + data: {}, + id: { type: 'string' }, + retry: { type: 'number' }, + }, + required: ['event'], + }, + ], + }, + }, + }, + required: true, + }, + }), + }) + }) + + it('throws when detailed input has a non-object schema', async () => { + await expect( + generator.generate({ + test: oc.meta(openapi({ inputStructure: 'detailed' })).input(z.string()), + }), + ).rejects.toThrow('Procedure at path "test" has inputStructure "detailed" but its input schema is not an object.') + }) + }) + + describe('response headers', () => { + it('maps detailed response headers', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ outputStructure: 'detailed' })) + .output(z.object({ + headers: z.object({ 'x-request-id': z.string() }), + })), + }) + + expect(doc.paths?.['/createPlanet']).toEqual({ + post: expect.objectContaining({ + responses: { + 200: { + description: 'OK', + headers: { + 'x-request-id': { + required: true, + schema: expect.objectContaining({ type: 'string' }), + }, + }, + }, + }, + }), + }) + }) + }) + + describe('response body', () => { + it('maps compact outputs to a success response body', async () => { + const doc = await generator.generate({ + ping: oc + .meta(openapi({ responseBodyHint: 'json' })) + .output(z.object({ message: z.string() })), + }) + + expect(doc.paths?.['/ping']).toEqual({ + post: expect.objectContaining({ + operationId: 'ping', + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + message: expect.objectContaining({ type: 'string' }), + }, + required: ['message'], + }), + }, + }, + }, + }, + }), + }) + }) + + it('maps detailed outputs to a success response body', async () => { + const doc = await generator.generate({ + ping: oc + .meta(openapi({ outputStructure: 'detailed' })) + .output(z.object({ body: z.object({ message: z.string() }) })), + }) + + expect(doc.paths?.['/ping']).toEqual({ + post: expect.objectContaining({ + operationId: 'ping', + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + message: expect.objectContaining({ type: 'string' }), + }, + required: ['message'], + }), + }, + }, + }, + }, + }), + }) + }) + + describe('with files', () => { + it('maps compact response bodies as files', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({})) + .output(z.file().mime(['application/pdf', 'application/xml'])), + }) + + expect(doc.paths?.['/createPlanet']).toEqual({ + post: expect.objectContaining({ + responses: { + 200: { + description: 'OK', + content: { + 'application/pdf': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + 'application/xml': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + }, + }, + }, + }), + }) + }) + + it('maps detailed response bodies as files', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ outputStructure: 'detailed' })) + .output(z.object({ body: z.file().mime(['application/pdf', 'application/xml']) })), + }) + + expect(doc.paths?.['/createPlanet']).toEqual({ + post: expect.objectContaining({ + responses: { + 200: { + description: 'OK', + content: { + 'application/pdf': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + 'application/xml': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + }, + }, + }, + }), + }) + }) + + it('maps compact response bodies as files without mime', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({})) + .output(z.file()), + }) + + expect(doc.paths?.['/createPlanet']).toEqual({ + post: expect.objectContaining({ + responses: { + 200: { + description: 'OK', + content: { + '*/*': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + }, + }, + }, + }), + }) + }) + + it('maps detailed response bodies as files without mime', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ outputStructure: 'detailed' })) + .output(z.object({ body: z.file() })), + }) + + expect(doc.paths?.['/createPlanet']).toEqual({ + post: expect.objectContaining({ + responses: { + 200: { + description: 'OK', + content: { + '*/*': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + }, + }, + }, + }), + }) + }) + + it('maps compact response bodies with nested files', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({})) + .output(z.object({ file: z.file().mime(['application/pdf', 'application/xml']) })), + }) + + expect(doc.paths?.['/createPlanet']).toEqual({ + post: expect.objectContaining({ + responses: { + 200: { + description: 'OK', + content: { + 'multipart/form-data': { + schema: expect.objectContaining({ + type: 'object', + }), + }, + }, + }, + }, + }), + }) + }) + + it('maps detailed response bodies with nested files', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ outputStructure: 'detailed' })) + .output(z.object({ body: z.object({ file: z.file().mime(['application/pdf', 'application/xml']) }) })), + }) + + expect(doc.paths?.['/createPlanet']).toEqual({ + post: expect.objectContaining({ + responses: { + 200: { + description: 'OK', + content: { + 'multipart/form-data': { + schema: expect.objectContaining({ + type: 'object', + }), + }, + }, + }, + }, + }), + }) + }) + }) + + it('maps event iterator outputs to an SSE success response', async () => { + const doc = await generator.generate({ + subscribe: oc + .meta(openapi({})) + .output(eventIterator(z.string(), z.boolean())), + }) + + expect(doc.paths?.['/subscribe']).toEqual({ + post: expect.objectContaining({ + responses: { + 200: { + description: 'OK', + content: { + 'text/event-stream': { + schema: { + oneOf: [ + { + type: 'object', + properties: { + event: { const: 'message' }, + data: { type: 'string' }, + id: { type: 'string' }, + retry: { type: 'number' }, + }, + required: ['event', 'data'], + }, + { + type: 'object', + properties: { + event: { const: 'close' }, + data: { type: 'boolean' }, + id: { type: 'string' }, + retry: { type: 'number' }, + }, + required: ['event', 'data'], + }, + { + type: 'object', + properties: { + event: { const: 'error' }, + data: {}, + id: { type: 'string' }, + retry: { type: 'number' }, + }, + required: ['event'], + }, + ], + }, + }, + }, + }, + }, + }), + }) + }) + + it('throws when detailed output has a non-object schema', async () => { + await expect( + generator.generate({ + test: oc.meta(openapi({ outputStructure: 'detailed' })).output(z.string()), + }), + ).rejects.toThrow('Procedure at path "test" has outputStructure "detailed" but its output schema is not an object.') + }) + }) + + describe('multiple status response', () => { + it('maps detailed outputs to per-status responses with headers', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ outputStructure: 'detailed' })) + .output(z.union([ + z.object({ + status: z.literal(201), + headers: z.object({ 'x-request-id': z.string() }), + body: z.object({ id: z.string() }), + }), + z.object({ + status: z.literal(202).describe('202 success1'), + body: z.object({ accepted: z.boolean() }), + }), + z.object({ + status: z.literal(202).describe('202 success2'), + body: z.object({ accepted: z.string() }), + }), + ])), + }) + + expect(doc.paths?.['/createPlanet']).toEqual({ + post: expect.objectContaining({ + operationId: 'createPlanet', + responses: { + 201: { + description: 'OK', + headers: { + 'x-request-id': { + required: true, + schema: expect.objectContaining({ type: 'string' }), + }, + }, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + id: expect.objectContaining({ type: 'string' }), + }, + required: ['id'], + }), + }, + }, + }, + 202: { + description: '202 success1, 202 success2', + content: { + 'application/json': { + schema: expect.objectContaining({ + anyOf: [ + expect.objectContaining({ + type: 'object', + properties: { + accepted: expect.objectContaining({ type: 'boolean' }), + }, + required: ['accepted'], + }), + expect.objectContaining({ + type: 'object', + properties: { + accepted: expect.objectContaining({ type: 'string' }), + }, + required: ['accepted'], + }), + ], + }), + }, + }, + }, + }, + }), + }) + }) + + it.each([ + { + name: 'detailed output with a non-literal status', + procedure: oc.meta(openapi({ outputStructure: 'detailed' })).output(z.union([ + z.object({ status: z.number(), body: z.string() }), + z.object({ status: z.literal(201), body: z.string() }), + ])), + message: 'Procedure at path "test" has an invalid "status" field in its outputStructure "detailed" schema.', }, - }, - }, - { - name: 'event iterator', - contract: oc.input(eventIterator(z.string(), z.boolean())), - expected: { - '/': { + { + name: 'detailed output with a non-success status code', + procedure: oc.meta(openapi({ outputStructure: 'detailed' })).output(z.union([ + z.object({ status: z.literal(400), body: z.string() }), + z.object({ status: z.literal(201), body: z.string() }), + ])), + message: 'Procedure at path "test" has an invalid "status" field in its outputStructure "detailed" schema.', + }, + ])('throws when $name', async ({ procedure, message }) => { + await expect(generator.generate({ test: procedure })).rejects.toThrow(message) + }) + }) + + describe('error response', () => { + it('groups defined errors by status and allows overriding the error body schema', async () => { + const generator = new OpenAPIGenerator({ + converters: [zodJsonSchemaConverter], + }) + + const customErrorResponseBodySchema = vi.fn((definedErrors, status) => { + if (status === 400) { + return { + type: 'object' as const, + description: 'custom-400', + } + } + + return undefined + }) + + const doc = await generator.generate({ + ping: oc + .meta(openapi({})) + .errors({ + BAD_REQUEST: { + data: z.object({ field: z.string() }), + }, + BAD_REQUEST_2: { + message: 'Second bad request', + }, + NOT_FOUND: {}, + }) + .output(z.object({ ok: z.boolean() })), + }, { + errorStatusMap: { + BAD_REQUEST: 400, + BAD_REQUEST_2: 400, + NOT_FOUND: 404, + }, + customErrorResponseBodySchema, + }) + + expect(customErrorResponseBodySchema).toHaveBeenCalledTimes(2) + expect(customErrorResponseBodySchema).toHaveBeenNthCalledWith(1, [ + { + code: 'BAD_REQUEST', + dataOptional: false, + dataJsonSchema: expect.any(Object), + }, + { + code: 'BAD_REQUEST_2', + defaultMessage: 'Second bad request', + dataOptional: true, + dataJsonSchema: expect.any(Object), + }, + ], 400) + expect(customErrorResponseBodySchema).toHaveBeenNthCalledWith(2, [ + { + code: 'NOT_FOUND', + dataOptional: true, + dataJsonSchema: expect.any(Object), + }, + ], 404) + + expect(doc.paths?.['/ping']).toEqual({ post: expect.objectContaining({ + responses: { + 200: expect.any(Object), + 400: { + description: 'Second bad request', + content: { + 'application/json': { + schema: { + type: 'object', + description: 'custom-400', + }, + }, + }, + }, + 404: expect.objectContaining({ + description: '404', + content: { + 'application/json': { + schema: expect.objectContaining({ + oneOf: expect.arrayContaining([ + { $ref: '#/components/schemas/UndefinedError' }, + ]), + }), + }, + }, + }), + }, + }), + }) + + expect(doc.components?.schemas).toEqual({ + UndefinedError: { + type: 'object', + properties: { + defined: { const: false }, + inferable: { type: 'boolean' }, + code: { type: 'string' }, + status: { type: 'number' }, + message: { type: 'string' }, + data: {}, + }, + required: ['defined', 'inferable', 'code', 'status', 'message'], + }, + }) + }) + }) + + describe('complex schema', () => { + describe('repeated schemas', () => { + it('merges repeated compact GET input objects before extracting path and query parameters', async () => { + const doc = await generator.generate({ + listPlanets: oc + .meta(openapi({ + method: 'GET', + path: '/systems/{systemId}/planets', + })) + .input(z.looseObject({ systemId: z.string() })) + .input(z.looseObject({ + search: z.string(), + page: z.number().optional(), + tags: z.array(z.string()).optional(), + })) + .output(z.object({ ok: z.boolean() })), + }) + + expect(doc.paths?.['/systems/{systemId}/planets']).toEqual({ + get: expect.objectContaining({ + operationId: 'listPlanets', + parameters: expect.arrayContaining([ + { + name: 'systemId', + in: 'path', + required: true, + schema: expect.objectContaining({ type: 'string' }), + }, + { + name: 'search', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'string' }), + }, + { + name: 'page', + in: 'query', + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'number' }), + }, + { + name: 'tags', + in: 'query', + style: 'deepObject', + explode: true, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'array' }), + }, + ]), + }), + }) + }) + + it('merges repeated event iterator schemas', async () => { + const doc = await generator.generate({ + procedure: oc + .input(eventIterator(z.looseObject({ yield1: z.string() }), z.looseObject({ return1: z.string() }))) + .input(eventIterator(z.looseObject({ yield2: z.string() }), z.looseObject({ return2: z.string() }))) + .output(eventIterator(z.looseObject({ yield3: z.string() }), z.looseObject({ return3: z.string() }))) + .output(eventIterator(z.looseObject({ yield4: z.string() }), z.looseObject({ return4: z.string() }))), + }) + + expect(doc.paths?.['/procedure']?.post).toMatchObject({ requestBody: { content: { 'text/event-stream': { @@ -284,7 +1903,10 @@ const inputTests: TestCase[] = [ type: 'object', properties: { event: { const: 'message' }, - data: { type: 'string' }, + data: { allOf: [ + expect.objectContaining({ type: 'object', properties: { yield1: { type: 'string' } } }), + expect.objectContaining({ type: 'object', properties: { yield2: { type: 'string' } } }), + ] }, id: { type: 'string' }, retry: { type: 'number' }, }, @@ -293,8 +1915,11 @@ const inputTests: TestCase[] = [ { type: 'object', properties: { - event: { const: 'done' }, - data: { type: 'boolean' }, + event: { const: 'close' }, + data: { allOf: [ + expect.objectContaining({ type: 'object', properties: { return1: { type: 'string' } } }), + expect.objectContaining({ type: 'object', properties: { return2: { type: 'string' } } }), + ] }, id: { type: 'string' }, retry: { type: 'number' }, }, @@ -316,955 +1941,1250 @@ const inputTests: TestCase[] = [ }, required: true, }, - }), - }, - }, - }, - { - name: 'inputStructure=detailed', - contract: oc.route({ path: '/planets/{id}', inputStructure: 'detailed' }).input(z.object({ - params: z.object({ id: z.string() }), - query: z.object({ query1: z.string(), query2: z.number().optional() }), - headers: z.object({ header1: z.string(), header2: z.string().optional() }), - body: z.string(), - })), - expected: { - '/planets/{id}': { - post: expect.objectContaining({ - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { - type: 'string', + responses: { + 200: { + description: 'OK', + content: { + 'text/event-stream': { + schema: { + oneOf: [ + { + type: 'object', + properties: { + event: { const: 'message' }, + data: { allOf: [ + expect.objectContaining({ type: 'object', properties: { yield3: { type: 'string' } } }), + expect.objectContaining({ type: 'object', properties: { yield4: { type: 'string' } } }), + ] }, + id: { type: 'string' }, + retry: { type: 'number' }, + }, + required: ['event', 'data'], + }, + { + type: 'object', + properties: { + event: { const: 'close' }, + data: { allOf: [ + expect.objectContaining({ type: 'object', properties: { return3: { type: 'string' } } }), + expect.objectContaining({ type: 'object', properties: { return4: { type: 'string' } } }), + ] }, + id: { type: 'string' }, + retry: { type: 'number' }, + }, + required: ['event', 'data'], + }, + { + type: 'object', + properties: { + event: { const: 'error' }, + data: {}, + id: { type: 'string' }, + retry: { type: 'number' }, + }, + required: ['event'], + }, + ], + }, + }, }, }, - { - name: 'query1', - in: 'query', + }, + }) + }) + + it('merges repeated detailed sections before mapping params, headers, bodies, and success responses', async () => { + const doc = await generator.generate({ + updatePlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets/{id}', + inputStructure: 'detailed', + outputStructure: 'detailed', + })) + .input(z.looseObject({ + params: z.object({ id: z.string() }), + query: z.object({ expand: z.boolean() }), + })) + .input(z.looseObject({ + headers: z.object({ 'x-trace-id': z.string() }), + body: z.object({ name: z.string() }), + })) + .output(z.looseObject({ + headers: z.object({ 'x-request-id': z.string() }), + })) + .output(z.looseObject({ + body: z.object({ updated: z.boolean() }), + })), + }) + + expect(doc.paths?.['/planets/{id}']).toEqual({ + post: expect.objectContaining({ + operationId: 'updatePlanet', + parameters: expect.arrayContaining([ + { + name: 'id', + in: 'path', + required: true, + schema: expect.objectContaining({ type: 'string' }), + }, + { + name: 'expand', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: expect.objectContaining({ type: 'boolean' }), + }, + { + name: 'x-trace-id', + in: 'header', + required: true, + schema: expect.objectContaining({ type: 'string' }), + }, + ]), + requestBody: { required: true, - schema: { - type: 'string', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, }, - allowEmptyValue: true, - allowReserved: true, }, - { - name: 'query2', - in: 'query', - required: false, - schema: { - type: 'number', + responses: { + 200: { + description: 'OK', + headers: { + 'x-request-id': { + required: true, + schema: { type: 'string' }, + }, + }, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + updated: { type: 'boolean' }, + }, + required: ['updated'], + }), + }, + }, }, - allowEmptyValue: true, - allowReserved: true, }, - { - name: 'header1', - in: 'header', + }), + }) + }) + + it('merges repeated detailed output objects when assembling a status-specific response', async () => { + const doc = await generator.generate({ + createPlanet: oc + .meta(openapi({ + method: 'POST', + outputStructure: 'detailed', + })) + .output(z.looseObject({ + status: z.literal(201), + })) + .output(z.looseObject({ + headers: z.object({ 'x-request-id': z.string() }), + body: z.object({ id: z.string(), slug: z.string() }), + })), + }) + + expect(doc.paths?.['/createPlanet']).toEqual({ + post: expect.objectContaining({ + operationId: 'createPlanet', + responses: { + 201: { + description: 'OK', + headers: { + 'x-request-id': { + required: true, + schema: { type: 'string' }, + }, + }, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + id: { type: 'string' }, + slug: { type: 'string' }, + }, + required: ['id', 'slug'], + }), + }, + }, + }, + }, + }), + }) + }) + }) + + describe('union schemas', () => { + it('extracts compact POST path params from a union and keeps the remaining request body object-shaped', async () => { + const doc = await generator.generate({ + createEvent: oc + .meta(openapi({ + method: 'POST', + path: '/events/{type}', + })) + .input(z.discriminatedUnion('type', [ + z.object({ + type: z.literal('a'), + a: z.string(), + }), + z.object({ + type: z.literal('b'), + b: z.number(), + }), + ])), + }) + + expect(doc.paths?.['/events/{type}']).toEqual({ + post: expect.objectContaining({ + operationId: 'createEvent', + parameters: [ + { + name: 'type', + in: 'path', + required: true, + schema: { + anyOf: [ + { const: 'a', type: 'string' }, + { const: 'b', type: 'string' }, + ], + }, + }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'number' }, + }, + }, + }, + }, + }, + }), + }) + }) + + it('extracts compact GET query parameters from a union but preserves the response body union', async () => { + const doc = await generator.generate({ + searchPlanets: oc + .meta(openapi({ + method: 'GET', + })) + .input(z.discriminatedUnion('type', [ + z.object({ + type: z.literal('a'), + a: z.string(), + }), + z.object({ + type: z.literal('b'), + b: z.number(), + }), + ])) + .output(z.discriminatedUnion('type', [ + z.object({ + type: z.literal('a'), + a: z.string(), + }), + z.object({ + type: z.literal('b'), + b: z.number(), + }), + ])), + }) + + expect(doc.paths?.['/searchPlanets']).toEqual({ + get: expect.objectContaining({ + operationId: 'searchPlanets', + parameters: [ + { + name: 'type', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: { + anyOf: [ + { const: 'a', type: 'string' }, + { const: 'b', type: 'string' }, + ], + }, + }, + { + name: 'a', + in: 'query', + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'string' }, + }, + { + name: 'b', + in: 'query', + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'number' }, + }, + ], + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + oneOf: [ + expect.objectContaining({ + type: 'object', + properties: { + type: { const: 'a', type: 'string' }, + a: { type: 'string' }, + }, + required: ['type', 'a'], + }), + expect.objectContaining({ + type: 'object', + properties: { + type: { const: 'b', type: 'string' }, + b: { type: 'number' }, + }, + required: ['type', 'b'], + }), + ], + }, + }, + }, + }, + }, + }), + }) + }) + + it('extracts compact body as files from a union', async () => { + const doc = await generator.generate({ + searchPlanets: oc + .input(z.union([ + z.file().mime('application/zip'), + z.file().mime('application/pdf'), + z.file(), + ])) + .output(z.union([ + z.file().mime('image/gif'), + z.file().mime('image/png'), + z.file(), + ])), + }) + + expect(doc.paths?.['/searchPlanets']).toEqual({ + post: expect.objectContaining({ + operationId: 'searchPlanets', + requestBody: expect.objectContaining({ required: true, - schema: { - type: 'string', + content: { + 'application/zip': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + 'application/pdf': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + '*/*': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, }, - }, - { - name: 'header2', - in: 'header', - required: false, - schema: { - type: 'string', + }), + responses: { + 200: { + description: 'OK', + content: { + 'image/gif': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + 'image/png': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + '*/*': { + schema: expect.objectContaining({ + contentEncoding: 'binary', + }), + }, + }, }, }, - ], - requestBody: { - content: { - 'application/json': { + }), + }) + }) + + it('extracts top-level detailed .input and .output unions', async () => { + const doc = await generator.generate({ + syncPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets/{id}', + inputStructure: 'detailed', + outputStructure: 'detailed', + })) + .input(z.union([ + z.object({ + params: z.object({ id: z.string() }), + query: z.object({ search: z.string() }), + body: z.object({ + type: z.literal('a'), + a: z.string(), + }), + }), + z.object({ + params: z.object({ id: z.number() }), + headers: z.object({ 'x-mode': z.literal('sync') }), + body: z.object({ + type: z.literal('b'), + b: z.number(), + }), + }), + ])) + .output(z.union([ + z.object({ + status: z.literal(201), + headers: z.object({ 'x-mode': z.literal('sync') }), + body: z.object({ + created: z.string(), + }), + }), + z.object({ + status: z.literal(202), + body: z.object({ + queued: z.boolean(), + }), + }), + ])), + }) + + expect(doc.paths?.['/planets/{id}']).toEqual({ + post: expect.objectContaining({ + operationId: 'syncPlanet', + parameters: expect.arrayContaining([ + { + name: 'id', + in: 'path', + required: true, + schema: { + anyOf: [ + { type: 'string' }, + { type: 'number' }, + ], + }, + }, + { + name: 'search', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, schema: { type: 'string' }, }, - }, - required: true, - }, - }), - }, - }, - }, - { - name: 'inputStructure=detailed all field is optional', - contract: oc.route({ inputStructure: 'detailed' }).input(z.object({})), - expected: { - '/': { - post: expect.toSatisfy(v => !v.parameters && !v.requestBody), - }, - }, - }, - { - name: 'inputStructure=detailed + invalid input', - contract: oc.route({ inputStructure: 'detailed' }).input(z.string()), - error: 'When input structure is "detailed", input schema must satisfy', - }, - { - name: 'inputStructure=detailed + invalid input', - contract: oc.route({ inputStructure: 'detailed', path: '/{id}' }), - error: 'When input structure is "detailed", input schema must satisfy', - }, - { - name: 'inputStructure=detailed + invalid input', - contract: oc.route({ inputStructure: 'detailed' }).input(z.object({ })), - expected: expect.any(Object), - }, - { - name: 'inputStructure=detailed + invalid input', - contract: oc.route({ inputStructure: 'detailed' }).input(z.object({ query: z.string() })), - error: 'When input structure is "detailed", input schema must satisfy', - }, - { - name: 'inputStructure=detailed + invalid input', - contract: oc.route({ inputStructure: 'detailed', path: '/{id}' }).input(z.object({ params: z.object({ id: z.string().optional() }) })), - error: 'When input structure is "detailed" and path has dynamic params, the "params" schema must be an object with all dynamic params as required.', - }, -] - -const successResponseTests: TestCase[] = [ - { - name: 'compact mode', - contract: oc.output(z.string()), - expected: { - '/': { - post: expect.objectContaining({ - responses: { - 200: { - description: 'OK', + { + name: 'x-mode', + in: 'header', + required: true, + schema: { const: 'sync', type: 'string' }, + }, + ]), + requestBody: { + required: true, content: { 'application/json': { - schema: { - type: 'string', - }, + schema: expect.objectContaining({ + anyOf: expect.any(Array), + }), }, }, }, - }, - }), - }, - }, - }, - { - name: 'event iterator', - contract: oc.output(eventIterator(z.string(), z.boolean())), - expected: { - '/': { - post: expect.objectContaining({ - responses: { - 200: { - description: 'OK', - content: { - 'text/event-stream': { - schema: { - oneOf: [ - { - type: 'object', - properties: { - event: { const: 'message' }, - data: { type: 'string' }, - id: { type: 'string' }, - retry: { type: 'number' }, - }, - required: ['event', 'data'], - }, - { - type: 'object', - properties: { - event: { const: 'done' }, - data: { type: 'boolean' }, - id: { type: 'string' }, - retry: { type: 'number' }, - }, - required: ['event', 'data'], + responses: { + 201: { + description: 'OK', + headers: { + 'x-mode': { + required: true, + schema: { const: 'sync', type: 'string' }, + }, + }, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + created: { type: 'string' }, }, - { - type: 'object', - properties: { - event: { const: 'error' }, - data: {}, - id: { type: 'string' }, - retry: { type: 'number' }, - }, - required: ['event'], + required: ['created'], + }), + }, + }, + }, + 202: { + description: 'OK', + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + queued: { type: 'boolean' }, }, - ], + required: ['queued'], + }), }, }, }, }, - }, - }), - }, - }, - }, - { - name: 'outputStructure=detailed', - contract: oc.route({ outputStructure: 'detailed' }).output(z.object({ - headers: z.object({ header1: z.string(), header2: z.string().optional() }), - body: z.string(), - })), - expected: { - '/': { - post: expect.objectContaining({ - responses: { - 200: { - description: 'OK', - content: { - 'application/json': { - schema: { type: 'string' }, + }), + }) + }) + + it('extracts unions from .input.params, .input.query, .input.headers, and .output.headers in detailed mode', async () => { + const doc = await generator.generate({ + syncPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets/{id}', + inputStructure: 'detailed', + outputStructure: 'detailed', + })) + .input(z.object({ + params: z.union([ + z.object({ id: z.string() }), + z.object({ id: z.number() }), + ]), + query: z.discriminatedUnion('type', [ + z.object({ + type: z.literal('a'), + a: z.string(), + }), + z.object({ + type: z.literal('b'), + b: z.number(), + }), + ]), + headers: z.discriminatedUnion('type', [ + z.object({ + type: z.literal('a'), + a: z.string(), + }), + z.object({ + type: z.literal('b'), + b: z.number(), + }), + ]), + body: z.discriminatedUnion('type', [ + z.object({ + type: z.literal('a'), + a: z.string(), + }), + z.object({ + type: z.literal('b'), + b: z.number(), + }), + ]), + })) + .output(z.object({ + headers: z.discriminatedUnion('type', [ + z.object({ + type: z.literal('a'), + a: z.string(), + }), + z.object({ + type: z.literal('b'), + b: z.number(), + }), + ]), + body: z.discriminatedUnion('type', [ + z.object({ + type: z.literal('a'), + a: z.string(), + }), + z.object({ + type: z.literal('b'), + b: z.number(), + }), + ]), + })), + }) + + expect(doc.paths?.['/planets/{id}']).toEqual({ + post: expect.objectContaining({ + operationId: 'syncPlanet', + parameters: expect.arrayContaining([ + { + name: 'id', + in: 'path', + required: true, + schema: { + anyOf: [ + { type: 'string' }, + { type: 'number' }, + ], }, }, - headers: { - header1: { - required: true, - schema: { - type: 'string', - }, + { + name: 'type', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: { + anyOf: [ + { const: 'a', type: 'string' }, + { const: 'b', type: 'string' }, + ], }, - header2: { - required: false, - schema: { - type: 'string', - }, + }, + { + name: 'a', + in: 'query', + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'string' }, + }, + { + name: 'b', + in: 'query', + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'number' }, + }, + { + name: 'type', + in: 'header', + required: true, + schema: { + anyOf: [ + { const: 'a', type: 'string' }, + { const: 'b', type: 'string' }, + ], }, }, - }, - }, - }), - }, - }, - }, - { - name: 'outputStructure=detailed all fields is optional', - contract: oc.route({ outputStructure: 'detailed' }).output(z.object({})), - expected: { - '/': { - post: { - operationId: '', - responses: { - 200: expect.toSatisfy(v => !v.content && !v.headers), - }, - }, - }, - }, - }, - { - name: 'outputStructure=detailed', - contract: oc.route({ outputStructure: 'detailed' }).output(z.string()), - error: 'When output structure is "detailed", output schema must satisfy', - }, - { - name: 'outputStructure=detailed', - contract: oc.route({ outputStructure: 'detailed' }).output(z.object({ headers: z.string() })), - error: 'When output structure is "detailed", output schema must satisfy', - }, - { - name: 'outputStructure=compact + output is optional', - contract: oc.route({ outputStructure: 'compact' }).output(z.object({ name: z.string() }).optional()), - expected: { - '/': { - post: expect.objectContaining({ - responses: { - 200: { - description: 'OK', + { + name: 'a', + in: 'header', + schema: { type: 'string' }, + }, + { + name: 'b', + in: 'header', + schema: { type: 'number' }, + }, + ]), + requestBody: { + required: true, content: { 'application/json': { schema: { - anyOf: [ + oneOf: [ { type: 'object', properties: { - name: { type: 'string' }, + type: { const: 'a', type: 'string' }, + a: { type: 'string' }, }, - required: ['name'], - }, - { - not: {}, + required: ['type', 'a'], }, - ], - }, - }, - }, - }, - }, - }), - }, - }, - }, - { - name: 'outputStructure=detailed + body is optional', - contract: oc.route({ outputStructure: 'detailed' }).output(z.object({ body: z.object({ name: z.string() }).optional() })), - expected: { - '/': { - post: expect.objectContaining({ - responses: { - 200: { - description: 'OK', - content: { - 'application/json': { - schema: { - anyOf: [ { type: 'object', properties: { - name: { type: 'string' }, + type: { const: 'b', type: 'string' }, + b: { type: 'number' }, }, - required: ['name'], - }, - { - not: {}, + required: ['type', 'b'], }, ], }, }, }, }, - }, - }), - }, - }, - }, - { - name: 'outputStructure=detailed + headers is optional', - contract: oc.route({ outputStructure: 'detailed' }).output(z.object({ headers: z.object({ 'x-custom': z.string() }).optional() })), - expected: { - '/': { - post: expect.objectContaining({ - responses: { - 200: expect.objectContaining({ - headers: { - 'x-custom': { - required: undefined, - schema: { - type: 'string', + responses: { + 200: { + description: 'OK', + headers: { + type: { + required: true, + schema: { + anyOf: [ + { const: 'a', type: 'string' }, + { const: 'b', type: 'string' }, + ], + }, }, - }, - }, - }), - }, - }), - }, - }, - }, - { - name: 'outputStructure=detailed + multiple status', - contract: oc.route({ outputStructure: 'detailed' }).output(z.union([ - z.object({ body: z.string() }), - z.object({ status: z.literal(201).describe('201 description'), body: z.object({ name: z.string() }), headers: z.object({ 'x-custom-header': z.string() }) }), - ])), - expected: { - '/': { - post: expect.objectContaining({ - responses: { - 200: { - description: 'OK', - content: { - 'application/json': { - schema: { - type: 'string', + a: { + schema: { type: 'string' }, + }, + b: { + schema: { type: 'number' }, }, }, - }, - }, - 201: { - description: '201 description', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - name: { type: 'string' }, + content: { + 'application/json': { + schema: { + oneOf: [ + expect.objectContaining({ + type: 'object', + properties: { + type: { const: 'a', type: 'string' }, + a: { type: 'string' }, + }, + required: ['type', 'a'], + }), + expect.objectContaining({ + type: 'object', + properties: { + type: { const: 'b', type: 'string' }, + b: { type: 'number' }, + }, + required: ['type', 'b'], + }), + ], }, - required: ['name'], }, }, }, - headers: { - 'x-custom-header': { - required: true, - schema: { - type: 'string', + }, + }), + }) + }) + }) + + describe('intersection schemas', () => { + it('extracts compact GET query parameters from an intersection but preserves the response body intersection', async () => { + const doc = await generator.generate({ + listPlanets: oc + .meta(openapi({ + method: 'GET', + })) + .input(z.intersection( + z.looseObject({ search: z.string() }), + z.looseObject({ page: z.number() }), + )) + .output(z.intersection( + z.looseObject({ search: z.string() }), + z.looseObject({ page: z.number() }), + )), + }) + + expect(doc.paths?.['/listPlanets']).toEqual({ + get: expect.objectContaining({ + operationId: 'listPlanets', + parameters: [ + { + name: 'search', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'string' }, + }, + { + name: 'page', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'number' }, + }, + ], + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + allOf: [ + expect.objectContaining({ + type: 'object', + properties: { + search: { type: 'string' }, + }, + required: ['search'], + }), + expect.objectContaining({ + type: 'object', + properties: { + page: { type: 'number' }, + }, + required: ['page'], + }), + ], + }, }, }, }, }, - }, - }), - }, - }, - }, - { - name: 'outputStructure=detailed + duplicate method', - contract: oc.route({ outputStructure: 'detailed' }).output(z.union([ - z.object({ status: z.literal(201), body: z.string() }), - z.object({ status: z.literal(201).describe('201 description') }), - ])), - error: 'When output structure is "detailed", each success status must be unique.', - }, - { - name: 'outputStructure=detailed + invalid status - 1', - contract: oc.route({ outputStructure: 'detailed' }).output(z.union([ - z.object({ status: z.number(), body: z.string() }), - z.object({ status: z.literal(201).describe('201 description') }), - ])), - error: ' When output structure is "detailed", output schema must satisfy:', - }, - { - name: 'outputStructure=detailed + invalid status - 2', - contract: oc.route({ outputStructure: 'detailed' }).output(z.union([ - z.object({ status: z.literal('200'), body: z.string() }), - z.object({ status: z.literal(201).describe('201 description') }), - ])), - error: ' When output structure is "detailed", output schema must satisfy:', - }, - { - name: 'outputStructure=detailed + invalid status - 3', - contract: oc.route({ outputStructure: 'detailed' }).output(z.union([ - z.object({ status: z.literal(201.1), body: z.string() }), - z.object({ status: z.literal(201).describe('201 description') }), - ])), - error: ' When output structure is "detailed", output schema must satisfy:', - }, - { - name: 'outputStructure=detailed + invalid status - 4', - contract: oc.route({ outputStructure: 'detailed' }).output(z.union([ - z.object({ status: z.literal(400), body: z.string() }), - z.object({ status: z.literal(201).describe('201 description') }), - ])), - error: ' When output structure is "detailed", output schema must satisfy:', - }, -] - -const errorResponseTests: TestCase[] = [ - { - name: 'without errors', - contract: oc, - expected: { - '/': { - post: expect.objectContaining({ - responses: { - 200: expect.objectContaining({}), - }, - }), - }, - }, - }, - { - name: 'with errors', - contract: oc.errors({ - UNAUTHORIZED: { - data: z.object({ token: z.string() }), - }, - UNAUTHORIZED_TEST: { - status: 401, - message: 'Unauthorized test', - data: z.object({ token: z.string() }).optional(), - }, - FORBIDDEN: undefined, - TEST: {}, - }), - expected: { - '/': { - post: expect.objectContaining({ - responses: expect.objectContaining({ - 401: { - description: '401', + }), + }) + }) + + it('extracts top-level detailed .input and .output intersections', async () => { + const doc = await generator.generate({ + syncPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets/{id}', + inputStructure: 'detailed', + outputStructure: 'detailed', + })) + .input(z.intersection( + z.object({ + params: z.object({ id: z.string() }), + query: z.object({ search: z.string() }), + body: z.object({ + type: z.literal('a'), + a: z.string(), + }), + }), + z.object({ + headers: z.object({ 'x-mode': z.literal('sync') }), + body: z.object({ + archived: z.boolean(), + }), + }), + )) + .output(z.intersection( + z.object({ + status: z.literal(201), + headers: z.object({ 'x-mode': z.literal('sync') }), + }), + z.object({ + body: z.object({ + created: z.string(), + }), + }), + )), + }) + + expect(doc.paths?.['/planets/{id}']).toEqual({ + post: expect.objectContaining({ + operationId: 'syncPlanet', + parameters: expect.arrayContaining([ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + { + name: 'search', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'string' }, + }, + { + name: 'x-mode', + in: 'header', + required: true, + schema: { const: 'sync', type: 'string' }, + }, + ]), + requestBody: { + required: true, content: { 'application/json': { schema: { - oneOf: [ - { - type: 'object', - properties: { - defined: { const: true }, - code: { const: 'UNAUTHORIZED' }, - status: { const: 401 }, - message: { type: 'string', default: 'Unauthorized' }, - data: { - type: 'object', - properties: { - token: { type: 'string' }, - }, - required: ['token'], - }, - }, - required: ['defined', 'code', 'status', 'message', 'data'], - }, - { + allOf: [ + expect.objectContaining({ type: 'object', properties: { - defined: { const: true }, - code: { const: 'UNAUTHORIZED_TEST' }, - status: { const: 401 }, - message: { type: 'string', default: 'Unauthorized test' }, - data: { - type: 'object', - properties: { - token: { type: 'string' }, - }, - required: ['token'], - }, + type: { const: 'a', type: 'string' }, + a: { type: 'string' }, }, - required: ['defined', 'code', 'status', 'message'], - }, - { + required: ['type', 'a'], + }), + expect.objectContaining({ type: 'object', properties: { - defined: { const: false }, - code: { type: 'string' }, - status: { type: 'number' }, - message: { type: 'string' }, - data: {}, + archived: { type: 'boolean' }, }, - required: ['defined', 'code', 'status', 'message'], - }, + required: ['archived'], + }), ], }, }, }, }, - 500: { - description: '500', + responses: { + 201: { + description: 'OK', + headers: { + 'x-mode': { + required: true, + schema: { const: 'sync', type: 'string' }, + }, + }, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + created: { type: 'string' }, + }, + required: ['created'], + }), + }, + }, + }, + }, + }), + }) + }) + + it('extracts intersections from .input.params, .input.query, .input.headers, and .output.headers in detailed mode', async () => { + const doc = await generator.generate({ + syncPlanet: oc + .meta(openapi({ + method: 'POST', + path: '/planets/{id}/{slug}', + inputStructure: 'detailed', + outputStructure: 'detailed', + })) + .input(z.object({ + params: z.intersection( + z.looseObject({ id: z.string() }), + z.looseObject({ slug: z.string() }), + ), + query: z.intersection( + z.looseObject({ search: z.string() }), + z.looseObject({ page: z.number() }), + ), + headers: z.intersection( + z.looseObject({ 'x-trace-id': z.string() }), + z.looseObject({ 'x-tenant-id': z.string() }), + ), + body: z.intersection( + z.looseObject({ name: z.string() }), + z.looseObject({ archived: z.boolean() }), + ), + })) + .output(z.object({ + headers: z.intersection( + z.looseObject({ 'x-request-id': z.string() }), + z.looseObject({ 'x-region': z.string() }), + ), + body: z.intersection( + z.looseObject({ ok: z.boolean() }), + z.looseObject({ version: z.number() }), + ), + })), + }) + + expect(doc.paths?.['/planets/{id}/{slug}']).toEqual({ + post: expect.objectContaining({ + operationId: 'syncPlanet', + parameters: expect.arrayContaining([ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + { + name: 'slug', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + { + name: 'search', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'string' }, + }, + { + name: 'page', + in: 'query', + required: true, + allowEmptyValue: true, + allowReserved: true, + schema: { type: 'number' }, + }, + { + name: 'x-trace-id', + in: 'header', + required: true, + schema: { type: 'string' }, + }, + { + name: 'x-tenant-id', + in: 'header', + required: true, + schema: { type: 'string' }, + }, + ]), + requestBody: { + required: true, content: { 'application/json': { schema: { - oneOf: [ - { + allOf: [ + expect.objectContaining({ type: 'object', properties: { - defined: { const: true }, - code: { const: 'TEST' }, - status: { const: 500 }, - message: { type: 'string', default: 'TEST' }, - data: {}, + name: { type: 'string' }, }, - required: ['defined', 'code', 'status', 'message'], - }, - { + required: ['name'], + }), + expect.objectContaining({ type: 'object', properties: { - defined: { const: false }, - code: { type: 'string' }, - status: { type: 'number' }, - message: { type: 'string' }, - data: {}, + archived: { type: 'boolean' }, }, - required: ['defined', 'code', 'status', 'message'], - }, + required: ['archived'], + }), ], }, }, }, }, - }), - }), - }, - }, - }, -] - -const customOperationTests: TestCase[] = [ - { - name: 'with security custom', - contract: oc.errors({ - TEST: customOpenAPIOperation({ }, () => ({ security: [{ bearerAuth: [] }] })), - }).input(z.object({ id: z.string() })).output(z.object({ name: z.string() })), - expected: { - '/': { - post: { - security: [{ bearerAuth: [] }], - }, - }, - }, - }, - { - name: 'override entire operation object', - contract: oc - .route({ - spec: { - operationId: 'customOperationId', - tags: ['tag'], - summary: '__OVERRIDE__', - }, - }) - .errors({ - TEST: customOpenAPIOperation({}, { security: [{ bearerAuth: [] }] }), - }) - .input(z.object({ id: z.string() })) - .output(z.object({ name: z.string() })), - expected: { - '/': { - post: { - operationId: 'customOperationId', - tags: ['tag'], - summary: '__OVERRIDE__', - security: [{ bearerAuth: [] }], - }, - }, - }, - }, - { - name: 'extend operation object', - contract: oc - .route({ - spec: spec => ({ - ...spec, - operationId: 'customOperationId', - summary: '__OVERRIDE__', - }), - }) - .errors({ - TEST: customOpenAPIOperation({}, { security: [{ bearerAuth: [] }] }), - }) - .input(z.object({ id: z.string() })) - .output(z.object({ name: z.string() })), - expected: { - '/': { - post: { - operationId: 'customOperationId', - summary: '__OVERRIDE__', - security: [{ bearerAuth: [] }], - requestBody: expect.any(Object), - responses: expect.any(Object), - }, - }, - }, - }, -] - -it.each([ - ...routeTests, - ...inputTests, - ...successResponseTests, - ...errorResponseTests, - ...customOperationTests, -])('openAPIGenerator.generate: %# - $name', async ({ contract, expected, error }) => { - const openAPIGenerator = new OpenAPIGenerator({ - schemaConverters: [ - new ZodToJsonSchemaConverter(), - ], - }) - - const promise = openAPIGenerator.generate(contract, { - info: { - title: 'test', - version: '1.0.0', - }, - }) - - if (error) { - await expect(promise).rejects.toThrow(error) - } - else { - await expect(promise).resolves.toEqual({ - openapi: '3.1.1', - info: { - title: 'test', - version: '1.0.0', - }, - paths: expected, - }) - } -}) - -describe('openAPIGenerator', () => { - it('can generate without base docs', async () => { - const openAPIGenerator = new OpenAPIGenerator() - const spec = await openAPIGenerator.generate({}) - - expect(spec).toEqual({ - openapi: '3.1.1', - info: { - title: 'API Reference', - version: '0.0.0', - }, - }) - }) - - it('openAPIGenerator.generate throw right away if unknown error', async () => { - const openAPIGenerator = new OpenAPIGenerator({ - schemaConverters: [ - { - condition: () => true, - convert: () => { - throw new Error('unknown error') - }, - }, - ], - }) - - await expect(openAPIGenerator.generate(oc, { - info: { - title: 'test', - version: '1.0.0', - }, - })).rejects.toThrow('unknown error') - }) - - it('respect exclude option', async () => { - const openAPIGenerator = new OpenAPIGenerator({ - }) - - const exclude = vi.fn(procedure => !!procedure['~orpc'].route.tags?.includes('admin')) - - const ping = oc.route({ - path: '/ping', - tags: ['admin'], - }) - - const pong = oc.route({ - path: '/pong', - tags: ['user'], - }) - - await expect(openAPIGenerator.generate({ ping, pong }, { exclude })).resolves.toEqual({ - openapi: '3.1.1', - info: { title: 'API Reference', version: '0.0.0' }, - paths: { - '/pong': expect.any(Object), - }, - }) - - expect(exclude).toHaveBeenCalledTimes(2) - expect(exclude).toHaveBeenNthCalledWith(1, ping, ['ping']) - expect(exclude).toHaveBeenNthCalledWith(2, pong, ['pong']) - }) - - describe('generator - commonSchemas', async () => { - const generator = new OpenAPIGenerator({ - schemaConverters: [ - new ZodToJsonSchemaConverter(), - ], - }) - - const User = z.object({ - id: z.string(), - get parent() { - return User.optional() - }, - }) - - const Pet = z.object({ - id: z.string().transform(v => Number(v)).pipe(z.number().min(0).max(100)), - }) - - const Params = z.object({ - pet: Pet, - }) - - const Query = z.object({ - user: User, - }) - - const Headers = z.object({ - user: User, - }) - - const InputDetailedStructure = z.object({ - params: Params, - query: Query, - headers: Headers, - body: User, - }) - - const OutputDetailedStructure = z.union([ - z.object({ - status: z.literal(200), - headers: Headers, - body: User, - }), - z.object({ - status: z.literal(201), - body: User, - }), - ]) - - const spec = await generator.generate({ - user: oc.input(User).errors({ TEST: { data: User } }).output(User), - pet: oc.input(Pet).errors({ TEST: { data: Pet } }).output(Pet), - iterator: oc.input(eventIterator(User, Pet)).output(eventIterator(User, Pet)), - dynamicParams: oc.route({ path: '/user/{id}', method: 'POST' }).input(User), - detailedStructure: oc.route({ path: '/detailed/{pet}', inputStructure: 'detailed', outputStructure: 'detailed' }) - .input(InputDetailedStructure) - .output(OutputDetailedStructure), - getWithoutParams: oc.route({ method: 'GET' }).input(Query), - }, { - commonSchemas: { - User: { - schema: User, - }, - Pet: { - strategy: 'output', - schema: Pet, - }, - DetailedStructure: { - strategy: 'output', - schema: InputDetailedStructure, - }, - Params: { - strategy: 'output', - schema: Params, - }, - Query: { - schema: Query, - }, - Headers: { - strategy: 'output', - schema: Headers, - }, - OutputDetailedStructure: { - schema: OutputDetailedStructure, - }, - UndefinedError2: { - error: 'UndefinedError', - }, - }, - }) - - it('fill correct components.schemas', async () => { - expect(spec.components).toEqual({ - schemas: { - User: { - type: 'object', - properties: { - id: { type: 'string' }, - parent: { $ref: '#/components/schemas/User' }, - }, - required: ['id'], - }, - Pet: { - type: 'object', - properties: { - id: { type: 'number', minimum: 0, maximum: 100 }, - }, - required: ['id'], - }, - Params: { - type: 'object', - properties: { - pet: { $ref: '#/components/schemas/Pet' }, - }, - required: ['pet'], - }, - Query: { - type: 'object', - properties: { - user: { $ref: '#/components/schemas/User' }, - }, - required: ['user'], - }, - Headers: { - type: 'object', - properties: { - user: { $ref: '#/components/schemas/User' }, - }, - required: ['user'], - }, - DetailedStructure: { - type: 'object', - properties: { - params: { $ref: '#/components/schemas/Params' }, - query: { $ref: '#/components/schemas/Query' }, - headers: { $ref: '#/components/schemas/Headers' }, - body: { $ref: '#/components/schemas/User' }, - }, - required: ['params', 'query', 'headers', 'body'], - }, - OutputDetailedStructure: { - anyOf: [ - { - type: 'object', - properties: { - status: { const: 200 }, - headers: { $ref: '#/components/schemas/Headers' }, - body: { $ref: '#/components/schemas/User' }, + responses: { + 200: { + description: 'OK', + headers: { + 'x-request-id': { + required: true, + schema: { type: 'string' }, + }, + 'x-region': { + required: true, + schema: { type: 'string' }, + }, }, - required: ['status', 'headers', 'body'], - }, - { - type: 'object', - properties: { - status: { const: 201 }, - body: { $ref: '#/components/schemas/User' }, + content: { + 'application/json': { + schema: { + allOf: [ + expect.objectContaining({ + type: 'object', + properties: { + ok: { type: 'boolean' }, + }, + required: ['ok'], + }), + expect.objectContaining({ + type: 'object', + properties: { + version: { type: 'number' }, + }, + required: ['version'], + }), + ], + }, + }, }, - required: ['status', 'body'], }, - ], - }, - UndefinedError2: { - type: 'object', - properties: { - defined: { const: false }, - code: { type: 'string' }, - status: { type: 'number' }, - message: { type: 'string' }, - data: {}, }, - required: ['defined', 'code', 'status', 'message'], - }, - }, + }), + }) }) }) + }) + + describe('component schemas', () => { + describe('hoisting', () => { + it('hoists $defs components, rewrites wrapper refs, and collapses local aliases', 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({ + category: oc + .input(z.object({ category: Category })) + .output(z.object({ category2: Category })), + }) - it('works with schema that input & output is same + error', async () => { - expect(spec.paths!['/user']).toEqual({ - post: { + expect(doc.paths?.['/category']?.post).toEqual(expect.objectContaining({ + operationId: 'category', requestBody: { + required: true, content: { 'application/json': { - schema: { $ref: '#/components/schemas/User' }, + schema: expect.objectContaining({ + type: 'object', + properties: { + category: { $ref: '#/components/schemas/Category' }, + }, + }), }, }, - required: true, }, responses: { 200: { description: 'OK', content: { 'application/json': { - schema: { $ref: '#/components/schemas/User' }, + schema: expect.objectContaining({ + type: 'object', + properties: { + category2: { $ref: '#/components/schemas/Category' }, + }, + }), }, }, }, - 500: { - description: '500', - content: { - 'application/json': { - schema: { - oneOf: [ - { - type: 'object', - properties: { - defined: { const: true }, - code: { const: 'TEST' }, - status: { const: 500 }, - message: { type: 'string', default: 'TEST' }, - data: { $ref: '#/components/schemas/User' }, - }, - required: ['defined', 'code', 'status', 'message', 'data'], - }, - { - $ref: '#/components/schemas/UndefinedError2', + }, + })) + + expect(doc.components?.schemas).toEqual({ + Category: { + type: 'object', + additionalProperties: {}, + properties: { + children: { + items: { + $ref: '#/components/schemas/Category', + }, + type: 'array', + }, + name: { + type: 'string', + }, + }, + required: [ + 'name', + ], + }, + }) + }) + + it('hoists a component referenced by a JSON Pointer encoded', async () => { + const planetSchema = z.object({}) + + const generator = new OpenAPIGenerator({ + converters: [ + { + condition: schema => schema === planetSchema, + async convert(_schema, _direction) { + return [{ + type: 'object', + properties: { + planet: { $ref: '#/$defs/domain~1Planet' }, + }, + required: ['planet'], + $defs: { + 'domain/Planet': { + type: 'object', + properties: { + id: { type: 'string' }, }, - ], + required: ['id'], + }, }, - }, + }, false] + }, + }, + ], + }) + + const doc = await generator.generate({ + planet: oc.input(planetSchema), + }) + + expect(doc.paths?.['/planet']?.post).toEqual(expect.objectContaining({ + requestBody: { + required: true, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + planet: { $ref: '#/components/schemas/domain~1Planet' }, + }, + }), }, }, }, - operationId: 'user', - }, + })) + + expect(doc.components?.schemas).toEqual({ + 'domain/Planet': { + type: 'object', + properties: { + id: { type: 'string' }, + }, + required: ['id'], + }, + }) }) - }) - it('works with schema that input & output is different + error', async () => { - expect(spec.paths!['/pet']).toEqual({ - post: { - operationId: 'pet', + it('uses shouldHoistDef to select defs and related', async () => { + const planetSchema = z.object({}) + + const shouldHoistDef = vi.fn((defName: string, _schema) => { + return defName !== '_PlanetAlias' + }) + + const generator = new OpenAPIGenerator({ + converters: [ + { + condition: schema => schema === planetSchema, + async convert(_schema, _direction) { + return [{ + type: 'object', + properties: { + planet: { $ref: '#/$defs/_PlanetAlias' }, + }, + required: ['planet'], + $defs: { + Planet: { + type: 'object', + properties: { + id: { $ref: '#/$defs/_PlanetId' }, + }, + required: ['id'], + }, + _PlanetId: { type: 'string' }, + _PlanetAlias: { + $ref: '#/$defs/Planet', + }, + }, + }, false] + }, + }, + ], + }) + + const doc = await generator.generate({ + planet: oc + .input(planetSchema) + .output(planetSchema), + }, { + shouldHoistDef, + }) + + expect(doc.paths?.['/planet']?.post).toEqual(expect.objectContaining({ requestBody: { required: true, content: { @@ -1272,9 +3192,14 @@ describe('openAPIGenerator', () => { schema: { type: 'object', properties: { - id: { type: 'string' }, + planet: { $ref: '#/$defs/_PlanetAlias' }, + }, + required: ['planet'], + $defs: { + _PlanetAlias: { + $ref: '#/components/schemas/Planet', + }, }, - required: ['id'], }, }, }, @@ -1282,88 +3207,111 @@ describe('openAPIGenerator', () => { responses: { 200: { description: 'OK', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/Pet' }, - }, - }, - }, - 500: { - description: '500', content: { 'application/json': { schema: { - oneOf: [ - { - type: 'object', - properties: { - defined: { const: true }, - code: { const: 'TEST' }, - status: { const: 500 }, - message: { type: 'string', default: 'TEST' }, - data: { $ref: '#/components/schemas/Pet' }, - }, - required: ['defined', 'code', 'status', 'message', 'data'], - }, - { - $ref: '#/components/schemas/UndefinedError2', + type: 'object', + properties: { + planet: { $ref: '#/$defs/_PlanetAlias' }, + }, + required: ['planet'], + $defs: { + _PlanetAlias: { + $ref: '#/components/schemas/Planet', }, - ], + }, }, }, }, }, }, - }, + })) + + expect(doc.components?.schemas).toEqual({ + Planet: { + type: 'object', + properties: { + id: { $ref: '#/components/schemas/_PlanetId' }, + }, + required: ['id'], + }, + _PlanetId: { type: 'string' }, + }) + + expect(shouldHoistDef).toHaveBeenCalledWith('Planet', { + type: 'object', + properties: { + id: { $ref: '#/$defs/_PlanetId' }, + }, + required: ['id'], + }) + expect(shouldHoistDef).toHaveBeenCalledWith('_PlanetId', { + type: 'string', + }) + expect(shouldHoistDef).toHaveBeenCalledWith('_PlanetAlias', { + $ref: '#/$defs/Planet', + }) + expect(shouldHoistDef).toHaveBeenCalledWith('Planet', { + type: 'object', + properties: { + id: { $ref: '#/$defs/_PlanetId' }, + }, + required: ['id'], + }) + expect(shouldHoistDef).toHaveBeenCalledWith('_PlanetId', { + type: 'string', + }) + expect(shouldHoistDef).toHaveBeenCalledWith('_PlanetAlias', { + $ref: '#/$defs/Planet', + }) }) - }) - it('works with event iterator', async () => { - expect(spec.paths!['/iterator']).toEqual({ - post: { - operationId: 'iterator', + it('hoists $defs from each allOf branch when multiple zod inputs and outputs are combined', async () => { + const inputSharedLeft = z.object({ source: z.literal('input-left') }).meta({ id: 'InputLeft' }) + const inputSharedRight = z.object({ source: z.literal('input-right') }).meta({ id: 'Right' }) + const outputSharedLeft = z.object({ source: z.literal('output-left') }).meta({ id: 'OutputLeft' }) + const outputSharedRight = z.object({ source: z.literal('output-right') }).meta({ id: 'Right' }) + + const doc = await generator.generate({ + planet: oc + .input(z.looseObject({ left: inputSharedLeft })) + .input(z.looseObject({ right: inputSharedRight })) + .output(z.looseObject({ left: outputSharedLeft })) + .output(z.looseObject({ right: outputSharedRight })), + }, { + shouldHoistDef: name => name !== 'InputLeft', + }) + + expect(doc.paths?.['/planet']?.post).toEqual(expect.objectContaining({ requestBody: { required: true, content: { - 'text/event-stream': { + 'application/json': { schema: { - oneOf: [ - { + $defs: { + InputLeft: expect.objectContaining({ type: 'object', properties: { - event: { const: 'message' }, - data: { $ref: '#/components/schemas/User' }, - id: { type: 'string' }, - retry: { type: 'number' }, + source: { const: 'input-left', type: 'string' }, }, - required: ['event', 'data'], - }, - { + required: ['source'], + }), + }, + allOf: [ + expect.objectContaining({ type: 'object', properties: { - event: { const: 'done' }, - data: { - type: 'object', - properties: { - id: { type: 'string' }, - }, - required: ['id'], - }, - id: { type: 'string' }, - retry: { type: 'number' }, + left: { $ref: '#/$defs/InputLeft' }, }, - required: ['event', 'data'], - }, - { + required: ['left'], + }), + expect.objectContaining({ type: 'object', properties: { - event: { const: 'error' }, - data: {}, - id: { type: 'string' }, - retry: { type: 'number' }, + right: { $ref: '#/components/schemas/Right' }, }, - required: ['event'], - }, + required: ['right'], + }), ], }, }, @@ -1373,513 +3321,571 @@ describe('openAPIGenerator', () => { 200: { description: 'OK', content: { - 'text/event-stream': { + 'application/json': { schema: { - oneOf: [ - { - type: 'object', - properties: { - event: { const: 'message' }, - data: { $ref: '#/components/schemas/User' }, - id: { type: 'string' }, - retry: { type: 'number' }, - }, - required: ['event', 'data'], - }, - { + allOf: [ + expect.objectContaining({ type: 'object', properties: { - event: { const: 'done' }, - data: { $ref: '#/components/schemas/Pet' }, - id: { type: 'string' }, - retry: { type: 'number' }, + left: { $ref: '#/components/schemas/OutputLeft' }, }, - required: ['event', 'data'], - }, - { + required: ['left'], + }), + expect.objectContaining({ type: 'object', properties: { - event: { const: 'error' }, - data: {}, - id: { type: 'string' }, - retry: { type: 'number' }, + right: { $ref: '#/components/schemas/Right2' }, }, - required: ['event'], - }, - + required: ['right'], + }), ], }, }, }, }, }, - }, - }) - }) + })) - it('works with compact + dynamic params', async () => { - expect(spec.paths!['/user/{id}']).toEqual({ - post: { - operationId: 'dynamicParams', - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { type: 'string' }, + expect(doc.components?.schemas).toEqual(expect.objectContaining({ + Right: expect.objectContaining({ + type: 'object', + properties: { + source: { const: 'input-right', type: 'string' }, }, - ], + required: ['source'], + }), + OutputLeft: expect.objectContaining({ + type: 'object', + properties: { + source: { const: 'output-left', type: 'string' }, + }, + required: ['source'], + }), + Right2: expect.objectContaining({ + type: 'object', + properties: { + source: { const: 'output-right', type: 'string' }, + }, + required: ['source'], + }), + })) + }) + + it('keeps direct recursive roots inline when they are not inside $defs', async () => { + const Planet: z.ZodTypeAny = z.lazy(() => z.object({ + id: z.string(), + children: z.array(Planet).optional(), + })).meta({ id: 'Planet' }) + + const doc = await generator.generate({ + planet: oc.input(Planet), + }) + + expect(doc.paths?.['/planet']?.post).toEqual(expect.objectContaining({ requestBody: { + required: true, content: { 'application/json': { schema: { type: 'object', properties: { - parent: { $ref: '#/components/schemas/User' }, + id: { type: 'string' }, + children: { + type: 'array', + items: { $ref: '#' }, + }, }, + required: ['id'], }, }, }, - required: false, }, - responses: expect.any(Object), - }, + })) + + expect(doc.components?.schemas).toBeUndefined() }) - }) - it('works with complex detailed structure', async () => { - expect(spec.paths!['/detailed/{pet}']).toEqual({ - post: { - operationId: 'detailedStructure', - parameters: [ - { - name: 'pet', + it('can maps params, query, headers, body as $ref in detailed mode', async () => { + const Planet: z.ZodTypeAny = z.lazy(() => z.object({ + id: z.string(), + children: z.array(Planet).optional(), + })).meta({ id: 'Planet' }) + + const doc = await generator.generate({ + planet: oc + .meta(openapi({ path: '/{id}', inputStructure: 'detailed', outputStructure: 'detailed' })) + .input(z.object({ + params: z.object({ id: z.string() }).meta({ id: 'InputParams' }), + query: z.object({ filter: z.string() }).meta({ id: 'InputQuery' }), + headers: z.object({ 'x-token-1': z.string() }).meta({ id: 'InputHeaders' }), + body: z.object({ name1: z.string() }).meta({ id: 'InputBody' }), + })) + .output(z.object({ + headers: z.object({ 'x-token-2': z.string() }).meta({ id: 'OutputHeaders' }), + body: z.object({ name2: z.string() }).meta({ id: 'OutputBody' }), + })), + }) + + expect(doc.paths?.['/{id}']?.post).toEqual(expect.objectContaining({ + parameters: expect.arrayContaining([ + expect.objectContaining({ + name: 'id', in: 'path', - required: true, - schema: { - type: 'object', - properties: { - id: { type: 'string' }, + }), + expect.objectContaining({ + name: 'filter', + in: 'query', + }), + expect.objectContaining({ + name: 'x-token-1', + in: 'header', + }), + ]), + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/InputBody' }, + }, + }, + }, + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/OutputBody' }, }, - required: ['id'], + }, + headers: { + 'x-token-2': expect.objectContaining({}), }, }, - { - name: 'user', - in: 'query', - required: true, - schema: { $ref: '#/components/schemas/User' }, - style: 'deepObject', - allowEmptyValue: true, - allowReserved: true, - explode: true, + }, + })) + + expect(doc.components?.schemas).toEqual(expect.objectContaining({ + InputBody: expect.objectContaining({ + type: 'object', + properties: { + name1: { type: 'string' }, + }, + }), + OutputBody: expect.objectContaining({ + type: 'object', + properties: { + name2: { type: 'string' }, + }, + }), + })) + }) + }) + + describe('name reuse', () => { + it('reuses the same component name when input and output json schemas are equal', async () => { + const doc = await generator.generate({ + planet: oc + .input(z.object({ left: z.looseObject({ id: z.string() }).meta({ id: 'Planet' }) })) + .output(z.object({ right: z.looseObject({ id: z.string() }).meta({ id: 'Planet' }) })), + }) + + expect(doc.paths?.['/planet']?.post).toEqual(expect.objectContaining({ + requestBody: { + required: true, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + left: { $ref: '#/components/schemas/Planet' }, + }, + }), + }, + }, + }, + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + right: { $ref: '#/components/schemas/Planet' }, + }, + }), + }, + }, + }, + }, + })) + + expect(doc.components?.schemas).toEqual({ + Planet: expect.objectContaining({ + type: 'object', + }), + }) + }) + + it('reuses an equal base component without adding a postfix', async () => { + const Planet = z.object({ id: z.string() }).meta({ id: 'Planet' }) + + const doc = await generator.generate({ + planet: oc.input(z.object({ planet: Planet })), + }, { + base: { + components: { + schemas: { + Planet: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + required: ['id'], + } as any, + }, + }, + }, + }) + + expect(doc.paths?.['/planet']?.post).toEqual(expect.objectContaining({ + requestBody: { + required: true, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + planet: { $ref: '#/components/schemas/Planet' }, + }, + }), + }, }, - { - name: 'user', - in: 'header', - required: true, - schema: { $ref: '#/components/schemas/User' }, + }, + })) + + expect(doc.components?.schemas).toEqual({ + Planet: expect.objectContaining({ + type: 'object', + properties: { + id: { type: 'string' }, }, - ], + required: ['id'], + }), + }) + }) + + it('can reuses schemas reference each others recursively', async () => { + const Schema1: z._ZodType = z.object({ + // eslint-disable-next-line ts/no-use-before-define + schema2: z.lazy(() => Schema2).optional(), + }).meta({ id: 'Schema1' }) + + const Schema2: z.ZodTypeAny = z.object({ + schema1: z.lazy(() => Schema1).optional(), + }).meta({ id: 'Schema2' }) + + const doc = await generator.generate({ + planet1: oc + .input(z.object({ Schema1 })) + .output(z.object({ Schema1 })), + planet2: oc + .input(z.object({ Schema2 })) + .output(z.object({ Schema2 })), + }) + + expect(doc.paths?.['/planet1']?.post).toEqual(expect.objectContaining({ requestBody: { + required: true, content: { 'application/json': { - schema: { - $ref: '#/components/schemas/User', - }, + schema: expect.objectContaining({ + type: 'object', + properties: { + Schema1: { $ref: '#/components/schemas/Schema1' }, + }, + }), }, }, - required: true, }, responses: { 200: { description: 'OK', content: { 'application/json': { - schema: { - $ref: '#/components/schemas/User', - }, + schema: expect.objectContaining({ + type: 'object', + properties: { + Schema1: { $ref: '#/components/schemas/Schema1' }, + }, + }), }, }, - headers: { - user: { - required: true, - schema: { - $ref: '#/components/schemas/User', + }, + }, + })) + + expect(doc.paths?.['/planet2']?.post).toEqual(expect.objectContaining({ + requestBody: { + required: true, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + Schema2: { $ref: '#/components/schemas/Schema2' }, }, - }, + }), }, }, - 201: { + }, + responses: { + 200: { description: 'OK', content: { 'application/json': { - schema: { - $ref: '#/components/schemas/User', - }, + schema: expect.objectContaining({ + type: 'object', + properties: { + Schema2: { $ref: '#/components/schemas/Schema2' }, + }, + }), }, }, }, }, - }, + })) + + expect(doc.components?.schemas).toEqual({ + Schema1: expect.objectContaining({ + type: 'object', + properties: { + schema2: { $ref: '#/components/schemas/Schema2' }, + }, + }), + Schema2: expect.objectContaining({ + type: 'object', + properties: { + schema1: { $ref: '#/components/schemas/Schema1' }, + }, + }), + }) }) }) - it('work with method=GET, inputStructure=compact, and without params', async () => { - expect(spec.paths!['/getWithoutParams']).toEqual({ - get: { - operationId: 'getWithoutParams', - parameters: [ - { - allowEmptyValue: true, - allowReserved: true, - name: 'user', - in: 'query', - explode: true, - required: true, - schema: { - $ref: '#/components/schemas/User', + describe('name conflicts', () => { + it('adds a numbered postfix when equal refs map to different schema', async () => { + const PlanetInput = z.object({ id: z.string() }).meta({ id: 'Planet', description: 'PlanetInput' }) + const PlanetOutput = z.object({ id: z.number() }).meta({ id: 'Planet', description: 'PlanetOutput' }) + + const doc = await generator.generate({ + planet: oc + .input(z.object({ left: PlanetInput, right: PlanetInput })) + .output(z.object({ left: PlanetOutput, right: PlanetOutput })), + }) + + expect(doc.paths?.['/planet']?.post).toEqual(expect.objectContaining({ + requestBody: { + required: true, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + left: { $ref: '#/components/schemas/Planet' }, + right: { $ref: '#/components/schemas/Planet' }, + }, + }), }, - style: 'deepObject', }, - ], + }, responses: { 200: { description: 'OK', content: { 'application/json': { - schema: { - anyOf: [ - {}, - { not: {} }, - ], - }, + schema: expect.objectContaining({ + type: 'object', + properties: { + left: { $ref: '#/components/schemas/Planet2' }, + right: { $ref: '#/components/schemas/Planet2' }, + }, + }), }, }, }, }, - }, - }) - }) - }) - - it('customErrorResponseBodySchema', async () => { - const openAPIGenerator = new OpenAPIGenerator({ - schemaConverters: [ - new ZodToJsonSchemaConverter(), - ], - }) - - const router = { - ping: oc.errors({ - BAD_REQUEST: { - data: z.string().describe('data_BAD_REQUEST'), - }, - BAD_REQUEST_2: { - status: 400, - message: 'message_BAD_REQUEST_2', - }, - NOT_FOUND: {}, - }), - pong: oc.route({ method: 'GET', path: '/pong' }).errors({ - INTERNAL_SERVER_ERROR: { - message: 'message_INTERNAL_SERVER_ERROR', - data: z.string().describe('data_BAD_REQUEST_2'), - }, - }), - } + })) - let time = 1 - const customErrorResponseBodySchema = vi.fn(() => { - if (time++ === 3) { - return null // fallback to default - } + expect(doc.components?.schemas).toEqual({ + Planet: expect.objectContaining({ + description: 'PlanetInput', + }), + Planet2: expect.objectContaining({ + description: 'PlanetOutput', + }), + }) + }) - return ({ type: 'object', description: 'custom' }) - }) - const spec = await openAPIGenerator.generate(router, { - customErrorResponseBodySchema, - }) + it('adds a postfix when an existing base component has a different json schema', async () => { + const Planet: z.ZodTypeAny = z.lazy(() => z.object({ + id: z.string(), + children: z.array(Planet).optional(), + })).meta({ id: 'Planet' }) - expect(customErrorResponseBodySchema).toHaveBeenCalledTimes(3) - expect(customErrorResponseBodySchema).toHaveBeenNthCalledWith(1, [ - ['BAD_REQUEST', 'Bad Request', true, { description: 'data_BAD_REQUEST', type: 'string' }], - ['BAD_REQUEST_2', 'message_BAD_REQUEST_2', false, { }], - ], 400) - expect(customErrorResponseBodySchema).toHaveBeenNthCalledWith(2, [ - ['NOT_FOUND', 'Not Found', false, { }], - ], 404) - expect(customErrorResponseBodySchema).toHaveBeenNthCalledWith(3, [ - ['INTERNAL_SERVER_ERROR', 'message_INTERNAL_SERVER_ERROR', true, { description: 'data_BAD_REQUEST_2', type: 'string' }], - ], 500) - - expect(spec).toEqual({ - openapi: '3.1.1', - info: { - title: 'API Reference', - version: '0.0.0', - }, - paths: { - '/ping': { - post: { - operationId: 'ping', - responses: { - 200: expect.any(Object), - 400: { description: '400', content: { 'application/json': { schema: customErrorResponseBodySchema.mock.results[0]!.value } } }, - 404: { description: '404', content: { 'application/json': { schema: customErrorResponseBodySchema.mock.results[1]!.value } } }, + const doc = await generator.generate({ + planet: oc.input(z.object({ Planet })), + }, { + base: { + components: { + schemas: { + Planet: { + type: 'object', + properties: { + legacy: { type: 'boolean' }, + }, + }, + }, }, }, - }, - '/pong': { - get: { - operationId: 'pong', - responses: { - 200: expect.any(Object), - 500: { description: '500', content: { 'application/json': { - schema: expect.toSatisfy((schema) => { // default behavior - expect(schema).not.toEqual(customErrorResponseBodySchema.mock.results[2]!.value) - - expect(schema).toEqual({ oneOf: expect.any(Array) }) + }) - return true + expect(doc.paths?.['/planet']?.post).toEqual(expect.objectContaining({ + requestBody: { + required: true, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + Planet: { $ref: '#/components/schemas/Planet2' }, + }, }), - } } }, + }, }, }, - }, - }, - }) - }) + })) - it('expand support for union/interaction of object schemas in some cases', async () => { - const openAPIGenerator = new OpenAPIGenerator({ - schemaConverters: [ - new ZodToJsonSchemaConverter(), - ], - }) + expect(doc.components?.schemas).toEqual({ + Planet: expect.objectContaining({ + type: 'object', + properties: { + legacy: { type: 'boolean' }, + }, + }), + Planet2: expect.objectContaining({ + properties: expect.objectContaining({ + id: { type: 'string' }, + }), + }), + }) + }) - const schema = z.discriminatedUnion('type', [ - z.object({ - type: z.literal('a'), - a: z.string(), - }), - z.object({ - type: z.literal('b'), - b: z.number(), - }), - ]) - - const router = { - ping: oc - .route({ path: '/{type}' }) - .input(schema), - pong: oc.route({ method: 'GET' }) - .input(schema), - peng: oc - .route({ path: '/{id}', inputStructure: 'detailed', outputStructure: 'detailed' }) - .input(z.object({ - params: z.union([z.object({ id: z.string() }), z.object({ id: z.number() })]), - query: schema, - headers: schema, - body: schema, - })) - .output(z.object({ - headers: schema, - body: schema, - })), - } + it('adds numbered postfixes for recursive reference schemas when base component names conflict', async () => { + const Schema1: z._ZodType = z.object({ + // eslint-disable-next-line ts/no-use-before-define + schema2: z.lazy(() => Schema2).optional(), + }).meta({ id: 'Schema1' }) - const spec = await openAPIGenerator.generate(router) + const Schema2: z.ZodTypeAny = z.object({ + schema1: z.lazy(() => Schema1).optional(), + }).meta({ id: 'Schema2' }) - expect(spec.paths!['/{type}']!.post).toEqual({ - operationId: 'ping', - parameters: [ - { - name: 'type', - in: 'path', - required: true, - schema: { - anyOf: [ - { const: 'a' }, - { const: 'b' }, - ], - }, - }, - ], - requestBody: { - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - a: { type: 'string' }, - b: { type: 'number' }, + const doc = await generator.generate({ + planet1: oc + .input(z.object({ Schema1 })) + .output(z.object({ Schema1 })), + planet2: oc + .input(z.object({ Schema2 })) + .output(z.object({ Schema2 })), + }, { + base: { + components: { + schemas: { + Schema1: { type: 'string' }, }, }, }, - }, - required: false, - }, - responses: expect.any(Object), - }) - - expect(spec.paths!['/pong']!.get).toEqual({ - operationId: 'pong', - parameters: [ - { - allowEmptyValue: true, - allowReserved: true, - name: 'type', - in: 'query', - required: true, - schema: { - anyOf: [ - { const: 'a' }, - { const: 'b' }, - ], - }, - }, - { - allowEmptyValue: true, - allowReserved: true, - name: 'a', - in: 'query', - schema: { type: 'string' }, - required: false, - }, - { - allowEmptyValue: true, - allowReserved: true, - name: 'b', - in: 'query', - schema: { type: 'number' }, - required: false, - }, - ], - responses: expect.any(Object), - }) + }) - expect(spec.paths!['/{id}']!.post).toEqual({ - operationId: 'peng', - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { - anyOf: [ - { - type: 'string', - }, - { - type: 'number', - }, - ], - }, - }, - { - name: 'type', - in: 'query', - required: true, - schema: { - anyOf: [ - { - const: 'a', - }, - { - const: 'b', + expect(doc.paths?.['/planet1']?.post).toEqual(expect.objectContaining({ + requestBody: { + required: true, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + Schema1: { $ref: '#/components/schemas/Schema12' }, + }, + }), }, - ], - }, - allowEmptyValue: true, - allowReserved: true, - }, - { - name: 'a', - in: 'query', - required: false, - schema: { - type: 'string', - }, - allowEmptyValue: true, - allowReserved: true, - }, - { - name: 'b', - in: 'query', - required: false, - schema: { - type: 'number', + }, }, - allowEmptyValue: true, - allowReserved: true, - }, - { - name: 'type', - in: 'header', - required: true, - schema: { - anyOf: [ - { - const: 'a', - }, - { - const: 'b', + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + Schema1: { $ref: '#/components/schemas/Schema12' }, + }, + }), + }, }, - ], - }, - }, - { - name: 'a', - in: 'header', - required: false, - schema: { - type: 'string', - }, - }, - { - name: 'b', - in: 'header', - required: false, - schema: { - type: 'number', + }, }, - }, - ], - requestBody: expect.any(Object), - responses: { - 200: { - description: 'OK', - headers: { - type: { - schema: { - anyOf: [ - { - const: 'a', - }, - { - const: 'b', + })) + + expect(doc.paths?.['/planet2']?.post).toEqual(expect.objectContaining({ + requestBody: { + required: true, + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + Schema2: { $ref: '#/components/schemas/Schema2' }, }, - ], - }, - required: true, - }, - a: { - schema: { - type: 'string', + }), }, - required: false, }, - b: { - schema: { - type: 'number', + }, + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: expect.objectContaining({ + type: 'object', + properties: { + Schema2: { $ref: '#/components/schemas/Schema2' }, + }, + }), + }, }, - required: false, }, }, - content: expect.any(Object), - }, - }, + })) + + expect(doc.components?.schemas).toEqual({ + Schema1: expect.objectContaining({ type: 'string' }), + Schema12: expect.objectContaining({ + type: 'object', + properties: { + schema2: { $ref: '#/components/schemas/Schema2' }, + }, + }), + Schema2: expect.objectContaining({ + type: 'object', + properties: { + schema1: { $ref: '#/components/schemas/Schema12' }, + }, + }), + }) + }) }) }) }) diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index e8b15366d..d990a0617 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -1,73 +1,70 @@ -import type { AnyContractProcedure, AnyContractRouter, AnySchema, ErrorMap, OpenAPI } from '@orpc/contract' -import type { StandardOpenAPIJsonSerializerOptions } from '@orpc/openapi-client/standard' -import type { AnyProcedure, AnyRouter, TraverseContractProcedureCallbackOptions } from '@orpc/server' +// eslint-disable-next-line no-restricted-imports +import type { OpenAPIV3_1 } from '@hey-api/spec-types' +import type { AnyProcedureContract, AnySchema, ErrorMap, RouterContract } from '@orpc/contract' +import type { JsonSchema, JsonSchemaConverter, JsonSchemaConverterDirection } from '@orpc/json-schema' +import type { AnyProcedure, AnyRouter } from '@orpc/server' import type { Value } from '@orpc/shared' -import type { JSONSchema } from './schema' -import type { ConditionalSchemaConverter, SchemaConverter, SchemaConverterComponent, SchemaConvertOptions } from './schema-converter' -import { fallbackORPCErrorMessage, fallbackORPCErrorStatus, isORPCErrorStatus } from '@orpc/client' -import { toHttpPath } from '@orpc/client/standard' -import { fallbackContractConfig, getEventIteratorSchemaDetails } from '@orpc/contract' -import { getDynamicParams, StandardOpenAPIJsonSerializer } from '@orpc/openapi-client/standard' -import { resolveContractProcedures } from '@orpc/server' -import { clone, stringifyJSON, toArray, value } from '@orpc/shared' -import { applyCustomOpenAPIOperation } from './openapi-custom' -import { checkParamsSchema, resolveOpenAPIJsonSchemaRef, simplifyComposedObjectJsonSchemasAndRefs, toOpenAPIContent, toOpenAPIEventIteratorContent, toOpenAPIMethod, toOpenAPIParameters, toOpenAPIPath, toOpenAPISchema } from './openapi-utils' -import { CompositeSchemaConverter } from './schema-converter' -import { applySchemaOptionality, expandUnionSchema, isAnySchema, isObjectSchema, separateObjectSchema } from './schema-utils' - -class OpenAPIGeneratorError extends Error { } - -export interface OpenAPIGeneratorOptions extends StandardOpenAPIJsonSerializerOptions { - schemaConverters?: ConditionalSchemaConverter[] -} +import type { OpenAPIMeta } from './meta' +import type { OpenAPIDocument, OpenAPIOperationObject } from './types' +import { COMMON_ERROR_STATUS_MAP } from '@orpc/client' +import { getEventIteratorSchemaDetails } from '@orpc/contract' +import { + combineJsonObjectSchemaEntries, + combineJsonSchemasWithComposition, + decodeJsonPointerSegment, + DelegatingJsonSchemaConverter, + encodeJsonPointerSegment, + ensureJsonSchemaObject, + extractJsonObjectSchemaEntries, + flattenJsonUnionSchema, + isJsonFileSchema, + isJsonPrimitiveSchema, + isUnconstrainedSchema, + mapJsonSchemaRefs, + matchArrayableJsonSchema, + StandardJsonSchemaConverter, +} from '@orpc/json-schema' +import { DEFAULT_ERROR_STATUS, DEFAULT_SUCCESS_STATUS, walkProcedureContractsAsync } from '@orpc/server' +import { clone, findDeepMatches, isDeepEqual, isPlainObject, mergeHttpPath, pathToHttpPath, stringifyJSON, toArray, value } from '@orpc/shared' +import { + DEFAULT_OPENAPI_INPUT_STRUCTURE, + DEFAULT_OPENAPI_METHOD, + DEFAULT_OPENAPI_OUTPUT_STRUCTURE, + DEFAULT_OPENAPI_SUCCESS_DESCRIPTION, +} from './constants' +import { getOpenAPIMeta } from './meta' +import { OpenAPISerializer } from './openapi-serializer' +import { getDynamicPathParams } from './utils' + +type DynamicPathParam = NonNullable>[number] + +export class OpenAPIGeneratorError extends TypeError { } + +export interface OpenAPIGeneratorOptions { + converters?: JsonSchemaConverter[] | undefined -export interface OpenAPIGeneratorGenerateOptions extends Partial> { /** - * Exclude procedures from the OpenAPI specification. - * - * @deprecated Use `filter` option instead. - * @default () => false + * The serializer used to serialize the generated OpenAPI documentation */ - exclude?: (procedure: AnyProcedure | AnyContractProcedure, path: readonly string[]) => boolean + serializer?: Pick | undefined +} + +export interface OpenAPIGeneratorGenerateOptions { + base?: Partial | undefined /** - * Filter procedures. Return `false` to exclude a procedure from the OpenAPI specification. + * Controls whether a generated json schema `$defs` at root-level should be moved into `components.schemas`. * * @default true */ - filter?: Value + shouldHoistDef?: Value /** - * Common schemas to be used for $ref resolution. + * Filter procedures. Return `false` to exclude a procedure from the OpenAPI specification. + * + * @default true */ - commonSchemas?: Record Number(v)) - * .pipe(z.number()) - * - * // Input schema: { type: 'string' } - * // Output schema: { type: 'number' } - * ``` - * - * When schemas differ between input and output, you must explicitly choose - * which version to use for the OpenAPI specification. - * - * @default 'input' - Uses the input schema definition by default - */ - strategy?: SchemaConvertOptions['strategy'] - schema: AnySchema - } | { - error: 'UndefinedError' - schema?: never - }> + filter?: Value /** * Define a custom JSON schema for the error response body when using @@ -78,105 +75,93 @@ export interface OpenAPIGeneratorGenerateOptions extends Partial + + /** + * Mapping ORPCError Code -> HTTP Status Code + * + * @default COMMON_ERROR_STATUS_MAP, DEFAULT_ERROR_STATUS + */ + errorStatusMap?: Record | undefined } -/** - * The generator that converts oRPC routers/contracts to OpenAPI specifications. - * - * @see {@link https://orpc.dev/docs/openapi/openapi-specification OpenAPI Specification Docs} - */ export class OpenAPIGenerator { - private readonly serializer: StandardOpenAPIJsonSerializer - private readonly converter: SchemaConverter + private readonly serializer: Pick + private readonly converter: Pick constructor(options: OpenAPIGeneratorOptions = {}) { - this.serializer = new StandardOpenAPIJsonSerializer(options) - this.converter = new CompositeSchemaConverter(toArray(options.schemaConverters)) + this.serializer = options.serializer ?? new OpenAPISerializer() + this.converter = new DelegatingJsonSchemaConverter([ + ...toArray(options.converters), + new StandardJsonSchemaConverter(), + ]) } - /** - * Generates OpenAPI specifications from oRPC routers/contracts. - * - * @see {@link https://orpc.dev/docs/openapi/openapi-specification OpenAPI Specification Docs} - */ - async generate( - router: AnyContractRouter | AnyRouter, - { customErrorResponseBodySchema, commonSchemas, filter: baseFilter, exclude, ...baseDoc }: OpenAPIGeneratorGenerateOptions = {}, - ): Promise { - const filter = baseFilter - ?? (({ contract, path }: TraverseContractProcedureCallbackOptions) => { - return !(exclude?.(contract, path) ?? false) - }) - - const doc: OpenAPI.Document = { - ...clone(baseDoc), - info: baseDoc.info ?? { title: 'API Reference', version: '0.0.0' }, - openapi: '3.1.1', - } as OpenAPI.Document - - const { baseSchemaConvertOptions, undefinedErrorJsonSchema } = await this.#resolveCommonSchemas(doc, commonSchemas) + async generate(router: RouterContract | AnyRouter, options: OpenAPIGeneratorGenerateOptions = {}): Promise { + const doc: OpenAPIDocument = { + ...clone(options.base), + openapi: options.base?.openapi ?? '3.1.2', + info: options.base?.info ?? { title: 'API Reference', version: '0.0.0' }, + } - const contracts: TraverseContractProcedureCallbackOptions[] = [] + const errors: string[] = [] - await resolveContractProcedures({ path: [], router }, (traverseOptions) => { - if (!value(filter, traverseOptions)) { + await walkProcedureContractsAsync(router, async (contract, path) => { + if (value(options.filter, contract, path) === false) { return } - contracts.push(traverseOptions) - }) - - const errors: string[] = [] - - for (const { contract, path } of contracts) { - const stringPath = path.join('.') - try { const def = contract['~orpc'] + const meta = getOpenAPIMeta(contract) - const method = toOpenAPIMethod(fallbackContractConfig('defaultMethod', def.route.method)) - const httpPath = toOpenAPIPath(def.route.path ?? toHttpPath(path)) + const method = (meta?.method ?? DEFAULT_OPENAPI_METHOD).toLowerCase() as Lowercase> + const postPath = meta?.path ?? pathToHttpPath(path) + const httpPath = meta?.prefix ? mergeHttpPath(meta.prefix, postPath) : postPath + const dynamicPathParams = getDynamicPathParams(httpPath) + const openApiPath = toOpenAPIPath(httpPath, dynamicPathParams) - let operationObjectRef: OpenAPI.OperationObject + let operationRef: OpenAPIOperationObject - if (def.route.spec !== undefined && typeof def.route.spec !== 'function') { - operationObjectRef = def.route.spec + if (meta?.spec !== undefined && typeof meta.spec !== 'function') { + operationRef = meta.spec } else { - operationObjectRef = { - operationId: def.route.operationId ?? stringPath, - summary: def.route.summary, - description: def.route.description, - deprecated: def.route.deprecated, - tags: def.route.tags?.map(tag => tag), + operationRef = { + operationId: meta?.operationId ?? path.join('.'), + summary: meta?.summary, + description: meta?.description, + deprecated: meta?.deprecated, + tags: meta?.tags?.map(tag => tag), } - await this.#request(doc, operationObjectRef, def, baseSchemaConvertOptions) - await this.#successResponse(doc, operationObjectRef, def, baseSchemaConvertOptions) - await this.#errorResponse(operationObjectRef, def, baseSchemaConvertOptions, undefinedErrorJsonSchema, customErrorResponseBodySchema) + await this.request(doc, operationRef, def, meta, dynamicPathParams, options, path) + await this.successResponse(doc, operationRef, def, meta, options, path) + await this.errorResponse(doc, operationRef, def, meta, options) } - if (typeof def.route.spec === 'function') { - operationObjectRef = def.route.spec(operationObjectRef) + if (typeof meta?.spec === 'function') { + operationRef = meta.spec(operationRef) } doc.paths ??= {} - doc.paths[httpPath] ??= {} - doc.paths[httpPath][method] = applyCustomOpenAPIOperation(operationObjectRef, contract) as any + doc.paths[openApiPath] ??= {} + doc.paths[openApiPath][method] = operationRef } catch (e) { if (!(e instanceof OpenAPIGeneratorError)) { throw e } - errors.push( - `[OpenAPIGenerator] Error occurred while generating OpenAPI for procedure at path: ${stringPath}\n${e.message}`, + `[OpenAPIGenerator] Error occurred while generating OpenAPI for procedure at path: ${path.join('.')}\n${e.message}`, ) } - } + }) if (errors.length) { throw new OpenAPIGeneratorError( @@ -184,425 +169,1108 @@ export class OpenAPIGenerator { ) } - return this.serializer.serialize(doc)[0] as OpenAPI.Document + return this.serializer.serialize(doc, { asFormData: false, useFormDataForBlobFields: false }) as OpenAPIDocument } - async #resolveCommonSchemas(doc: OpenAPI.Document, commonSchemas: OpenAPIGeneratorGenerateOptions['commonSchemas']): Promise<{ - baseSchemaConvertOptions: Pick - undefinedErrorJsonSchema: JSONSchema - }> { - let undefinedErrorJsonSchema: JSONSchema = { - type: 'object', - properties: { - defined: { const: false }, - code: { type: 'string' }, - status: { type: 'number' }, - message: { type: 'string' }, - data: {}, - }, - required: ['defined', 'code', 'status', 'message'], + private async convertSchema(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): Promise<[JsonSchema, boolean]> { + const [jsonSchema, optional] = await this.converter.convert(schema as any, direction) + return [strip$schemaField(jsonSchema), optional] + } + + private async convertSchemas(schemas: AnySchema[] | undefined, direction: JsonSchemaConverterDirection): Promise<[JsonSchema, boolean]> { + if (!schemas || schemas.length <= 1) { + return this.convertSchema(schemas?.[0], direction) + } + + const results = await Promise.all(schemas.map(s => this.convertSchema(s, direction))) + const allOfSchemas: JsonSchema[] = [] + let optional = true + + for (const [jsonSchema, opt] of results) { + allOfSchemas.push(jsonSchema) + if (!opt) { + optional = false + } + } + + return [combineJsonSchemasWithComposition('allOf', allOfSchemas), optional] + } + + private async request( + doc: OpenAPIDocument, + ref: OpenAPIOperationObject, + def: AnyProcedureContract['~orpc'], + meta: OpenAPIMeta | undefined, + dynamicPathParams: DynamicPathParam[] | undefined, + options: OpenAPIGeneratorGenerateOptions, + path: string[], + ): Promise { + const method = meta?.method ?? DEFAULT_OPENAPI_METHOD + const inputStructure = meta?.inputStructure ?? DEFAULT_OPENAPI_INPUT_STRUCTURE + const inputSchemas = def.inputSchemas + + if (inputStructure === 'compact') { + const eventIteratorDetails = getEventIteratorDetails(inputSchemas) + + if (eventIteratorDetails) { + const [yieldSchemas, returnSchemas] = eventIteratorDetails + const yieldResult = await this.convertSchemas(yieldSchemas, 'input') + const returnResult = await this.convertSchemas(returnSchemas, 'input') + + ref.requestBody = { + required: true, + content: toEventIteratorContent(yieldResult, returnResult, doc, options), + } + + return + } + } + + const dynamicParams = dynamicPathParams?.map(v => v.parameterName) + + const [schema, optional] = await this.convertSchemas(inputSchemas, 'input') + + if (isUnconstrainedSchema(schema) && !dynamicParams?.length) { + return + } + + const objectSchemaEntries = extractJsonObjectSchemaEntries(schema) + + if (!objectSchemaEntries) { + if (inputStructure === 'detailed') { + throw new OpenAPIGeneratorError( + `Procedure at path "${path.join('.')}" has inputStructure "detailed" but its input schema is not an object.\n` + + ` Expected shape: { params?: Record, query?: Record, headers?: Record, body?: unknown }`, + ) + } + + if (method === 'GET') { + throw new OpenAPIGeneratorError( + `Procedure at path "${path.join('.')}" uses method "GET" but its input schema is not an object.\n` + + ` GET procedures map all input fields to query parameters, so the schema must be an object.\n` + + ` Expected: Record`, + ) + } } - const baseSchemaConvertOptions: { components?: SchemaConverterComponent[] } = {} - if (commonSchemas) { - baseSchemaConvertOptions.components = [] + const paramsObjectSchemaEntries = inputStructure === 'compact' + ? objectSchemaEntries?.filter(([name]) => dynamicParams?.includes(name)) + : extractJsonObjectSchemaEntries(objectSchemaEntries?.find(([name]) => name === 'params')?.[1] ?? false) + const queryObjectSchemaEntries = inputStructure === 'compact' + ? method === 'GET' ? objectSchemaEntries?.filter(([name]) => !dynamicParams?.includes(name)) : undefined + : extractJsonObjectSchemaEntries(objectSchemaEntries?.find(([name]) => name === 'query')?.[1] ?? false) + const headersObjectSchemaEntries = inputStructure === 'compact' + ? undefined + : extractJsonObjectSchemaEntries(objectSchemaEntries?.find(([name]) => name === 'headers')?.[1] ?? false) + const bodySchema = inputStructure === 'compact' + ? method === 'GET' || method === 'HEAD' ? undefined : (!dynamicParams?.length ? schema : objectSchemaEntries ? combineJsonObjectSchemaEntries(objectSchemaEntries?.filter(([name]) => !dynamicParams?.includes(name))) : undefined) + : objectSchemaEntries?.find(([name]) => name === 'body')?.[1] + + if (dynamicParams?.length) { + if (!paramsObjectSchemaEntries) { + throw new OpenAPIGeneratorError( + `Procedure at path "${path.join('.')}" has dynamic path params (${dynamicParams.map(p => p).join(', ')}) but its input schema is not an object.\n` + + ` Each dynamic param must appear as a required key in the schema.`, + ) + } - for (const key in commonSchemas) { - const options = commonSchemas[key]! + dynamicParams.forEach((name) => { + const entry = paramsObjectSchemaEntries.find(([n]) => n === name) - if (options.schema === undefined) { - continue + if (!entry) { + throw new OpenAPIGeneratorError( + `Procedure at path "${path.join('.')}" is missing dynamic param "${name}" in its input schema.\n` + + ` Route params: ${dynamicParams.map(p => `{${p}}`).join(', ')}\n` + + ` Schema keys: ${paramsObjectSchemaEntries.map(([n]) => n).join(', ') || '(none)'}`, + ) } - const { schema, strategy = 'input' } = options + if (entry[2]) { + throw new OpenAPIGeneratorError( + `Procedure at path "${path.join('.')}" has dynamic param "${name}" marked as optional in its input schema, but path params must always be required in OpenAPI.`, + ) + } - const [required, json] = await this.converter.convert(schema, { strategy }) + ref.parameters ??= [] + ref.parameters.push({ + in: 'path', + required: true, + name, + schema: toOpenAPISchema(entry[1], doc, options), + }) + }) + } - const allowedStrategies: SchemaConvertOptions['strategy'][] = [strategy] + if (queryObjectSchemaEntries) { + queryObjectSchemaEntries.forEach(([name, schema, optional]) => { + const style = meta?.queryStyles?.[name] + const parameter: Exclude[number] = { + in: 'query', + name, + schema: toOpenAPISchema(schema, doc, options), + allowEmptyValue: true, + allowReserved: true, + } - if (strategy === 'input') { - const [outputRequired, outputJson] = await this.converter.convert(schema, { strategy: 'output' }) + if (!optional) { + parameter.required = true + } - if (outputRequired === required && stringifyJSON(outputJson) === stringifyJSON(json)) { - allowedStrategies.push('output') + if (style === 'comma-delimited-array' || style === 'comma-delimited-object') { + parameter.explode = false + } + else if (style === 'pipe-delimited-array' || style === 'pipe-delimited-object') { + parameter.style = 'pipeDelimited' + } + else if (style === 'space-delimited-array' || style === 'space-delimited-object') { + parameter.style = 'spaceDelimited' + } + else if (style === 'json') { + parameter.content = { + 'application/json': { schema: parameter.schema }, } + delete parameter.schema } - else if (strategy === 'output') { - const [inputRequired, inputJson] = await this.converter.convert(schema, { strategy: 'input' }) + else if (style === undefined) { + if (!isJsonPrimitiveSchema(schema)) { + const arrayable = matchArrayableJsonSchema(schema) - if (inputRequired === required && stringifyJSON(inputJson) === stringifyJSON(json)) { - allowedStrategies.push('input') + if (!arrayable || !isJsonPrimitiveSchema(arrayable[0])) { + parameter.style = 'deepObject' + parameter.explode = true + } } } + else { + const _expect: 'primitive' | 'array' = style + } + + ref.parameters ??= [] + ref.parameters.push(parameter) + }) + } - baseSchemaConvertOptions.components.push({ - schema, - required, - ref: `#/components/schemas/${key}`, - allowedStrategies, + if (headersObjectSchemaEntries) { + headersObjectSchemaEntries.forEach(([name, schema, optional]) => { + ref.parameters ??= [] + ref.parameters.push({ + in: 'header', + name, + required: optional ? undefined : true, + schema: toOpenAPISchema(schema, doc, options), }) + }) + } + + if (bodySchema !== undefined) { + const bodyOptional = inputStructure === 'compact' + ? !dynamicParams?.length ? optional : objectSchemaEntries?.filter(([name]) => !dynamicParams?.includes(name)).every(([,,optional]) => optional) + : objectSchemaEntries?.find(([name]) => name === 'body')?.[2] + + ref.requestBody = { + required: bodyOptional ? undefined : true, + content: toBodyContent(bodySchema, doc, options), } + } + } - doc.components ??= {} - doc.components.schemas ??= {} + private async successResponse( + doc: OpenAPIDocument, + ref: OpenAPIOperationObject, + def: AnyProcedureContract['~orpc'], + meta: OpenAPIMeta | undefined, + options: OpenAPIGeneratorGenerateOptions, + path: string[], + ): Promise { + const outputSchemas = def.outputSchemas + const status = meta?.successStatus ?? DEFAULT_SUCCESS_STATUS + const description = meta?.successDescription ?? DEFAULT_OPENAPI_SUCCESS_DESCRIPTION + const outputStructure = meta?.outputStructure ?? DEFAULT_OPENAPI_OUTPUT_STRUCTURE - for (const key in commonSchemas) { - const options = commonSchemas[key]! + if (outputStructure === 'compact') { + const eventDetails = getEventIteratorDetails(outputSchemas) - if (options.schema === undefined) { - if (options.error === 'UndefinedError') { - doc.components.schemas[key] = toOpenAPISchema(undefinedErrorJsonSchema) - undefinedErrorJsonSchema = { $ref: `#/components/schemas/${key}` } - } + if (eventDetails) { + const [yieldSchemas, returnSchemas] = eventDetails + const yieldResult = await this.convertSchemas(yieldSchemas, 'output') + const returnResult = await this.convertSchemas(returnSchemas, 'output') - continue + ref.responses ??= {} + ref.responses[status] = { + description, + content: toEventIteratorContent(yieldResult, returnResult, doc, options), } - const { schema, strategy = 'input' } = options + return + } + } + + const [schema] = await this.convertSchemas(outputSchemas, 'output') + + if (isUnconstrainedSchema(schema) || outputStructure === 'compact') { + ref.responses ??= {} + ref.responses[status] = { + description, + content: toBodyContent(schema, doc, options), + } + return + } + + const schemasByStatus = new Map() + + for (const item of flattenJsonUnionSchema(schema)) { + const objectSchemaEntries = extractJsonObjectSchemaEntries(item) + + if (!objectSchemaEntries) { + throw new OpenAPIGeneratorError( + `Procedure at path "${path.join('.')}" has outputStructure "detailed" but its output schema is not an object.\n` + + ` Expected shape: { status: number (200-299), headers?: Record, body?: unknown }`, + ) + } + + const statusSchema = objectSchemaEntries?.find(([name]) => name === 'status')?.[1] + + if (statusSchema !== undefined && (typeof statusSchema !== 'object' || !Number.isInteger(statusSchema.const) || statusSchema.const < 200 || statusSchema.const >= 300)) { + throw new OpenAPIGeneratorError( + `Procedure at path "${path.join('.')}" has an invalid "status" field in its outputStructure "detailed" schema.\n` + + ` Expected: a const integer in the 200-299 range\n` + + ` Received: ${stringifyJSON(statusSchema)}`, - const [, json] = await this.converter.convert( - schema, - { - ...baseSchemaConvertOptions, - strategy, - minStructureDepthForRef: 1, // not allow use $ref for root schemas - }, ) - doc.components.schemas[key] = toOpenAPISchema(json) + } + + const status = (statusSchema?.const as number || undefined) ?? meta?.successStatus ?? DEFAULT_SUCCESS_STATUS + + const description = statusSchema?.description + const schemas = schemasByStatus.get(status) + const headersSchema = objectSchemaEntries?.find(([name]) => name === 'headers')?.[1] + const bodySchema = objectSchemaEntries?.find(([name]) => name === 'body')?.[1] + + if (schemas) { + schemas.push({ description, headers: headersSchema, body: bodySchema }) + } + else { + schemasByStatus.set(status, [{ description, headers: headersSchema, body: bodySchema }]) } } - return { baseSchemaConvertOptions, undefinedErrorJsonSchema } + for (const [status, schemas] of schemasByStatus.entries()) { + const descriptions = schemas.map(({ description }) => description).filter(d => d !== undefined) + const responseObject: OpenAPIV3_1.ResponseObject = { + description: descriptions.length ? descriptions.join(', ') : description, + } + + const bodySchemas = schemas.map(({ body }) => body).filter(b => b !== undefined) + if (bodySchemas.length) { + responseObject.content = toBodyContent(combineJsonSchemasWithComposition('anyOf', bodySchemas), doc, options) + } + + const headerSchemas = schemas.map(({ headers }) => headers).filter(b => b !== undefined) + if (headerSchemas.length) { + const entries = extractJsonObjectSchemaEntries(combineJsonSchemasWithComposition('anyOf', headerSchemas)) + entries?.forEach(([name, schema, optional]) => { + responseObject.headers ??= {} + responseObject.headers[name] = { + required: optional ? undefined : true, + schema: toOpenAPISchema(schema, doc, options), + } + }) + } + ref.responses ??= {} + ref.responses[status] = responseObject + } } - async #request( - doc: OpenAPI.Document, - ref: OpenAPI.OperationObject, - def: AnyContractProcedure['~orpc'], - baseSchemaConvertOptions: Pick, + private async errorResponse( + doc: OpenAPIDocument, + ref: OpenAPIOperationObject, + def: AnyProcedureContract['~orpc'], + meta: OpenAPIMeta | undefined, + options: OpenAPIGeneratorGenerateOptions, ): Promise { - const method = fallbackContractConfig('defaultMethod', def.route.method) - const details = getEventIteratorSchemaDetails(def.inputSchema) + const errorStatusMap: Record = options.errorStatusMap ?? COMMON_ERROR_STATUS_MAP + const errorMap: ErrorMap = def.errorMap - if (details) { - ref.requestBody = { - required: true, - content: toOpenAPIEventIteratorContent( - await this.converter.convert(details.yields, { ...baseSchemaConvertOptions, strategy: 'input' }), - await this.converter.convert(details.returns, { ...baseSchemaConvertOptions, strategy: 'input' }), - ), + const errorDefinitionsByStatus = new Map< + number, + { code: string, defaultMessage: string | undefined, dataOptional: boolean, dataJsonSchema: JsonSchema }[] + >() + + for (const code in errorMap) { + const config = errorMap[code] + if (!config) { + continue } - return + const status = errorStatusMap[code] ?? DEFAULT_ERROR_STATUS + const defaultMessage = config.message + const [dataJsonSchema, dataOptional] = await this.convertSchema(config.data, 'output') + + const definitions = errorDefinitionsByStatus.get(status) + if (definitions) { + definitions.push({ code, dataJsonSchema, dataOptional, defaultMessage }) + } + else { + errorDefinitionsByStatus.set(status, [{ code, dataJsonSchema, dataOptional, defaultMessage }]) + } } - const dynamicParams = getDynamicParams(def.route.path)?.map(v => v.name) - const inputStructure = fallbackContractConfig('defaultInputStructure', def.route.inputStructure) + if (errorDefinitionsByStatus.size) { + const undefinedErrorSchema = hoistDefs({ + $defs: { + UndefinedError: { + type: 'object', + properties: { + defined: { const: false }, + inferable: { type: 'boolean' }, + code: { type: 'string' }, + status: { type: 'number' }, + message: { type: 'string' }, + data: {}, + }, + required: ['defined', 'inferable', 'code', 'status', 'message'], + }, + }, + $ref: '#/$defs/UndefinedError', + }, doc, options) + + for (const [status, definitions] of errorDefinitionsByStatus.entries()) { + const descriptions = definitions.map(({ defaultMessage }) => defaultMessage).filter(m => m !== undefined) + const customBodySchema = value( + options.customErrorResponseBodySchema, + definitions.map(def => ({ ...def, dataJsonSchema: hoistDefs(def.dataJsonSchema, doc, options) })), + status, + ) + const responseSchema = customBodySchema ?? combineJsonSchemasWithComposition('oneOf', [ + ...definitions.map(({ code, dataJsonSchema, dataOptional, defaultMessage }) => { + return combineJsonObjectSchemaEntries([ + ['defined', { const: true }, false], + ['inferable', { type: 'boolean' }, false], + ['code', { const: code }, false], + ['status', { const: status }, false], + ['message', { type: 'string', default: defaultMessage }, false], + ['data', dataJsonSchema, dataOptional], + ]) + }), + undefinedErrorSchema, + ]) + + ref.responses ??= {} + ref.responses[status] = { + description: descriptions.length ? descriptions.join(', ') : status.toString(), + content: { + 'application/json': { + schema: toOpenAPISchema(responseSchema, doc, options), + }, + }, + } satisfies OpenAPIV3_1.ResponseObject + } + } + } +} - let [required, schema] = await this.converter.convert( - def.inputSchema, - { - ...baseSchemaConvertOptions, - strategy: 'input', - }, - ) +function toOpenAPISchema(schema: JsonSchema, doc: OpenAPIDocument, options: OpenAPIGeneratorGenerateOptions): OpenAPIV3_1.SchemaObject { + return ensureJsonSchemaObject(hoistDefs( + schema, + doc, + options, + )) as OpenAPIV3_1.SchemaObject +} - let omitResponseBody = false +function toOpenAPIPath(path: `/${string}`, dynamicPathParams: DynamicPathParam[] | undefined): `/${string}` { + if (!dynamicPathParams?.length) { + return path + } - if (isAnySchema(schema) && !dynamicParams?.length) { - return + let normalized = '' + let currentIndex = 0 + + for (const param of dynamicPathParams) { + normalized += path.slice(currentIndex, param.startIndex) + normalized += `{${param.parameterName}}` + currentIndex = param.startIndex + param.segment.length + } + + normalized += path.slice(currentIndex) + + return normalized as `/${string}` +} + +function strip$schemaField(schema: JsonSchema): JsonSchema { + if (typeof schema !== 'object') { + return schema + } + const { $schema, ...rest } = schema + return rest +} + +function getEventIteratorDetails(schemas: AnySchema[] | undefined): [yieldSchemas: AnySchema[], returnSchemas: AnySchema[]] | undefined { + if (!schemas || schemas.length === 0) { + return undefined + } + + const yieldSchemas: AnySchema[] = [] + const returnSchemas: AnySchema[] = [] + + for (const s of schemas) { + const details = getEventIteratorSchemaDetails(s) + if (!details) { + return undefined } - if (inputStructure === 'detailed' || (inputStructure === 'compact' && (dynamicParams?.length || method === 'GET'))) { - schema = simplifyComposedObjectJsonSchemasAndRefs(schema, doc) + yieldSchemas.push(details.yieldSchema) + if (details.returnSchema) { + returnSchemas.push(details.returnSchema) } + } - if (inputStructure === 'compact') { - if (dynamicParams?.length) { - const error = new OpenAPIGeneratorError( - 'When input structure is "compact", and path has dynamic params, input schema must be an object with all dynamic params as required.', - ) + return yieldSchemas.length || returnSchemas.length ? [yieldSchemas, returnSchemas] : undefined +} - if (!isObjectSchema(schema)) { - throw error - } +function toEventIteratorContent( + [yieldSchema, yieldOptional]: [JsonSchema, optional: boolean], + [returnSchema, returnOptional]: [JsonSchema, optional: boolean], + doc: OpenAPIDocument, + options: OpenAPIGeneratorGenerateOptions, +): Record { + const schema = combineJsonSchemasWithComposition('oneOf', [ + combineJsonObjectSchemaEntries([ + ['event', { const: 'message' }, false], + ['data', yieldSchema, yieldOptional], + ['id', { type: 'string' }, true], + ['retry', { type: 'number' }, true], + ]), + combineJsonObjectSchemaEntries([ + ['event', { const: 'close' }, false], + ['data', returnSchema, returnOptional], + ['id', { type: 'string' }, true], + ['retry', { type: 'number' }, true], + ]), + { + type: 'object', + properties: { + event: { const: 'error' }, + data: {}, + id: { type: 'string' }, + retry: { type: 'number' }, + }, + required: ['event'], + }, + ]) + + return { + 'text/event-stream': { + schema: toOpenAPISchema(schema, doc, options), + }, + } +} - const [paramsSchema, rest] = separateObjectSchema(schema, dynamicParams) +function toBodyContent(schema: JsonSchema, doc: OpenAPIDocument, options: OpenAPIGeneratorGenerateOptions): Record { + const fileSchemasByMediaType = new Map() - schema = rest - required = rest.required ? rest.required.length !== 0 : false - omitResponseBody = !required && !rest.properties + const rest = flattenJsonUnionSchema(schema).filter((s) => { + if (!isJsonFileSchema(s)) { + return !isUnconstrainedSchema(s) + } - if (!checkParamsSchema(paramsSchema, dynamicParams)) { - throw error - } + const contentMediaType = s.contentMediaType ?? '*/*' + const schemas = fileSchemasByMediaType.get(contentMediaType) + if (schemas) { + schemas.push(s) + } + else { + fileSchemasByMediaType.set(contentMediaType, [s]) + } - ref.parameters ??= [] - ref.parameters.push(...toOpenAPIParameters(paramsSchema, 'path')) - } + return false + }) - if (method === 'GET') { - if (!isObjectSchema(schema)) { - throw new OpenAPIGeneratorError( - 'When method is "GET", input schema must satisfy: object | any | unknown', - ) - } + const content: Record = {} - ref.parameters ??= [] - ref.parameters.push(...toOpenAPIParameters(schema, 'query')) - } - else if (!omitResponseBody) { - ref.requestBody = { - required, - content: toOpenAPIContent(schema), - } - } + if (rest.length > 0) { + const restSchema = fileSchemasByMediaType.size ? combineJsonSchemasWithComposition('anyOf', rest) : schema + const hasNestedFiles = findDeepMatches( + v => isPlainObject(v) && isJsonFileSchema(v as any), + restSchema, + ).values.length > 0 - return + const contentType = hasNestedFiles ? 'multipart/form-data' : 'application/json' + const fileSchemas = fileSchemasByMediaType.get(contentType) + fileSchemasByMediaType.delete(contentType) + + content[contentType] = { + schema: toOpenAPISchema(combineJsonSchemasWithComposition('anyOf', [restSchema, ...toArray(fileSchemas)]), doc, options), } + } - const error = new OpenAPIGeneratorError( - 'When input structure is "detailed", input schema must satisfy: ' - + '{ params?: Record, query?: Record, headers?: Record, body?: unknown }', - ) + for (const [contentType, schemas] of fileSchemasByMediaType.entries()) { + content[contentType] = { + schema: toOpenAPISchema(combineJsonSchemasWithComposition('anyOf', schemas), doc, options), + } + } + + return content +} + +function hoistDefs( + schema: JsonSchema, + doc: OpenAPIDocument, + options: OpenAPIGeneratorGenerateOptions, +): JsonSchema { + if (typeof schema !== 'object') { + return schema + } + + if (!schema.$defs) { + return schema + } + + const { $defs, ...rest } = schema + const localDefs: Record> = {} + const hoistedDefs: Record> = {} - if (!isObjectSchema(schema)) { - throw error + for (const defName of Object.keys($defs)) { + const defSchema = $defs[defName] + + if (defSchema === undefined) { + continue } - const resolvedParamSchema = schema.properties?.params !== undefined - ? simplifyComposedObjectJsonSchemasAndRefs(schema.properties.params, doc) - : undefined + const normalized = normalizeHoistedDefSchema(defSchema) - if ( - dynamicParams?.length && ( - resolvedParamSchema === undefined - || !isObjectSchema(resolvedParamSchema) - || !checkParamsSchema(resolvedParamSchema, dynamicParams) - ) - ) { - throw new OpenAPIGeneratorError( - 'When input structure is "detailed" and path has dynamic params, the "params" schema must be an object with all dynamic params as required.', - ) + if (value(options.shouldHoistDef, defName, normalized) !== false) { + hoistedDefs[defName] = normalized + } + else { + localDefs[defName] = normalized } + } - for (const from of ['params', 'query', 'headers']) { - const fromSchema = schema.properties?.[from] - if (fromSchema !== undefined) { - const resolvedSchema = simplifyComposedObjectJsonSchemasAndRefs(fromSchema, doc) + hoistReferencedLocalDefs(hoistedDefs, localDefs) - if (!isObjectSchema(resolvedSchema)) { - throw error - } + if (Object.keys(hoistedDefs).length === 0) { + return schema + } - const parameterIn: 'path' | 'query' | 'header' = from === 'params' - ? 'path' - : from === 'headers' - ? 'header' - : 'query' + doc.components ??= {} + doc.components.schemas ??= {} + + const componentsSchemas = doc.components.schemas + const identityRenameMap = Object.fromEntries( + Object.keys(hoistedDefs).map(defName => [defName, defName]), + ) as Record + const renameMap: Record = {} + const pendingSchemas: { cleanSchema: Exclude, componentName: string }[] = [] + + for (const defName of Object.keys(hoistedDefs)) { + const cleanSchema = hoistedDefs[defName]! + const existingSchema = componentsSchemas[defName] + const candidateSchemas = Object.fromEntries( + Object.keys(hoistedDefs).map(currentDefName => [ + currentDefName, + rewriteComponentSchemaRefs( + withReferencedLocalDefs(hoistedDefs[currentDefName]!, localDefs), + { + ...identityRenameMap, + ...renameMap, + }, + ), + ]), + ) as Record + const prelimSchema = candidateSchemas[defName]! - ref.parameters ??= [] - ref.parameters.push(...toOpenAPIParameters(resolvedSchema, parameterIn)) + if (existingSchema !== undefined) { + const reusableComponentName = findReusableComponentName(componentsSchemas, defName, prelimSchema, candidateSchemas) + + if (reusableComponentName !== undefined) { + renameMap[defName] = reusableComponentName + continue } + + const componentName = findUniqueComponentName(componentsSchemas, defName) + + renameMap[defName] = componentName + pendingSchemas.push({ cleanSchema, componentName }) } + else { + const reusableComponentName = findReusableComponentName(componentsSchemas, defName, prelimSchema, candidateSchemas) - if (schema.properties?.body !== undefined) { - ref.requestBody = { - required: schema.required?.includes('body'), - content: toOpenAPIContent(schema.properties.body), + if (reusableComponentName !== undefined) { + renameMap[defName] = reusableComponentName + continue } + + renameMap[defName] = defName + pendingSchemas.push({ cleanSchema, componentName: defName }) } } - async #successResponse( - doc: OpenAPI.Document, - ref: OpenAPI.OperationObject, - def: AnyContractProcedure['~orpc'], - baseSchemaConvertOptions: Pick, - ): Promise { - const outputSchema = def.outputSchema - const status = fallbackContractConfig('defaultSuccessStatus', def.route.successStatus) - const description = fallbackContractConfig('defaultSuccessDescription', def.route?.successDescription) - const eventIteratorSchemaDetails = getEventIteratorSchemaDetails(outputSchema) - const outputStructure = fallbackContractConfig('defaultOutputStructure', def.route.outputStructure) + for (const { cleanSchema, componentName } of pendingSchemas) { + componentsSchemas[componentName] = rewriteComponentSchemaRefs( + withReferencedLocalDefs(cleanSchema, localDefs), + renameMap, + ) as OpenAPIV3_1.SchemaObject + } - if (eventIteratorSchemaDetails) { - ref.responses ??= {} - ref.responses[status] = { - description, - content: toOpenAPIEventIteratorContent( - await this.converter.convert(eventIteratorSchemaDetails.yields, { ...baseSchemaConvertOptions, strategy: 'output' }), - await this.converter.convert(eventIteratorSchemaDetails.returns, { ...baseSchemaConvertOptions, strategy: 'output' }), - ), - } + return rewriteComponentSchemaRefs(withReferencedLocalDefs(rest, localDefs), renameMap) +} - return +function normalizeHoistedDefSchema(schema: JsonSchema): Exclude { + let cleanSchema = typeof schema === 'boolean' + ? (schema ? {} : { not: {} }) + : { ...schema } + + if (cleanSchema.additionalProperties === false) { + const { additionalProperties: _ignored, ...withoutAdditionalProperties } = cleanSchema + cleanSchema = withoutAdditionalProperties + } + + return cleanSchema +} + +function hoistReferencedLocalDefs( + hoistedDefs: Record>, + localDefs: Record>, +): void { + const queue = Object.values(hoistedDefs) + + while (queue.length > 0) { + const current = queue.shift() + + if (current === undefined) { + continue } - const [required, json] = await this.converter.convert( - outputSchema, - { - ...baseSchemaConvertOptions, - strategy: 'output', - minStructureDepthForRef: outputStructure === 'detailed' ? 1 : 0, - }, - ) + visitSchemaRefs(current, (refName) => { + const referenced = localDefs[refName] - if (outputStructure === 'compact') { - ref.responses ??= {} - ref.responses[status] = { - description, + if (referenced === undefined) { + return } - ref.responses[status].content = toOpenAPIContent(applySchemaOptionality(required, json)) + hoistedDefs[refName] = referenced + delete localDefs[refName] + queue.push(referenced) + }) + } +} - return - } +function withReferencedLocalDefs( + schema: Exclude, + localDefs: Record>, +): Exclude { + const referencedLocalDefs = collectReferencedLocalDefNames(schema, localDefs) - const handledStatuses = new Set() + if (referencedLocalDefs.length === 0) { + return schema + } - for (const item of expandUnionSchema(json)) { - const error = new OpenAPIGeneratorError(` - When output structure is "detailed", output schema must satisfy: - { - status?: number, // must be a literal number and in the range of 200-399 - headers?: Record, - body?: unknown - } - - But got: ${stringifyJSON(item)} - `) + const mergedDefs: Record> = { + ...(schema.$defs as Record> | undefined), + } - const simplifiedItem = simplifyComposedObjectJsonSchemasAndRefs(item, doc) + for (const defName of referencedLocalDefs) { + mergedDefs[defName] = localDefs[defName]! + } - if (!isObjectSchema(simplifiedItem)) { - throw error - } + return { + ...schema, + $defs: mergedDefs, + } +} - let schemaStatus: number | undefined - let schemaDescription: string | undefined +function collectReferencedLocalDefNames( + schema: JsonSchema, + localDefs: Record>, +): string[] { + if (Object.keys(localDefs).length === 0) { + return [] + } - if (simplifiedItem.properties?.status !== undefined) { - const statusSchema = resolveOpenAPIJsonSchemaRef(doc, simplifiedItem.properties.status) + const referenced = new Set() + const queued = new Set() + const queue: JsonSchema[] = [schema] - if ( - typeof statusSchema !== 'object' - || statusSchema.const === undefined - || typeof statusSchema.const !== 'number' - || !Number.isInteger(statusSchema.const) - || isORPCErrorStatus(statusSchema.const) - ) { - throw error - } + while (queue.length > 0) { + const current = queue.shift() + + if (current === undefined) { + continue + } - schemaStatus = statusSchema.const - schemaDescription = statusSchema.description + visitSchemaRefs(current, (refName) => { + if (localDefs[refName] === undefined || referenced.has(refName)) { + return } - const itemStatus = schemaStatus ?? status - const itemDescription = schemaDescription ?? description + referenced.add(refName) - if (handledStatuses.has(itemStatus)) { - throw new OpenAPIGeneratorError(` - When output structure is "detailed", each success status must be unique. - But got status: ${itemStatus} used more than once. - `) + if (!queued.has(refName)) { + queued.add(refName) + queue.push(localDefs[refName]!) } + }) + } - handledStatuses.add(itemStatus) + return [...referenced] +} - ref.responses ??= {} - ref.responses[itemStatus] = { - description: itemDescription, - } +function visitSchemaRefs(schema: JsonSchema, onRef: (defName: string) => void, seen = new Set()): void { + if (typeof schema !== 'object' || schema === null) { + return + } - if (simplifiedItem.properties?.headers !== undefined) { - const headersSchema = simplifyComposedObjectJsonSchemasAndRefs(simplifiedItem.properties.headers, doc) + if (seen.has(schema)) { + return + } - if (!isObjectSchema(headersSchema)) { - throw error - } + seen.add(schema) - for (const key in headersSchema.properties) { - const headerSchema = headersSchema.properties[key] + if (typeof schema.$ref === 'string') { + const refName = parseLocalDefRefName(schema.$ref) - if (headerSchema !== undefined) { - ref.responses[itemStatus].headers ??= {} - ref.responses[itemStatus].headers[key] = { - schema: toOpenAPISchema(headerSchema) as any, - required: simplifiedItem.required?.includes('headers') && headersSchema.required?.includes(key), - } - } - } - } + if (refName !== undefined) { + onRef(refName) + } + } - if (simplifiedItem.properties?.body !== undefined) { - ref.responses[itemStatus].content = toOpenAPIContent( - applySchemaOptionality(simplifiedItem.required?.includes('body') ?? false, simplifiedItem.properties.body), - ) + for (const keyword of ['allOf', 'anyOf', 'oneOf'] as const) { + if (Array.isArray(schema[keyword])) { + for (const item of schema[keyword]) { + visitSchemaRefs(item, onRef, seen) } } } - async #errorResponse( - ref: OpenAPI.OperationObject, - def: AnyContractProcedure['~orpc'], - baseSchemaConvertOptions: Pick, - undefinedErrorSchema: JSONSchema, - customErrorResponseBodySchema: OpenAPIGeneratorGenerateOptions['customErrorResponseBodySchema'], - ): Promise { - const errorMap = def.errorMap as ErrorMap + if (schema.properties) { + for (const property of Object.values(schema.properties)) { + visitSchemaRefs(property, onRef, seen) + } + } - const errorResponsesByStatus: Record = {} + if (schema.items !== undefined) { + visitSchemaRefs(schema.items, onRef, seen) + } - for (const code in errorMap) { - const config = errorMap[code] + if (typeof schema.additionalProperties === 'object' && schema.additionalProperties !== null) { + visitSchemaRefs(schema.additionalProperties, onRef, seen) + } - if (!config) { - continue + if (schema.not !== undefined) { + visitSchemaRefs(schema.not, onRef, seen) + } + + if (schema.if !== undefined) { + visitSchemaRefs(schema.if, onRef, seen) + } + + if (schema.then !== undefined) { + visitSchemaRefs(schema.then, onRef, seen) + } + + if (schema.else !== undefined) { + visitSchemaRefs(schema.else, onRef, seen) + } + + if (Array.isArray(schema.prefixItems)) { + for (const item of schema.prefixItems) { + visitSchemaRefs(item, onRef, seen) + } + } + + if (schema.$defs) { + for (const def of Object.values(schema.$defs)) { + if (def !== undefined) { + visitSchemaRefs(def, onRef, seen) } + } + } +} + +function findUniqueComponentName(componentsSchemas: Record, baseName: string): string { + const candidate = `${baseName}` + if (componentsSchemas[candidate] === undefined) + return candidate - const status = fallbackORPCErrorStatus(code, config.status) - const defaultMessage = fallbackORPCErrorMessage(code, config.message) + let i = 2 + while (componentsSchemas[`${baseName}${i}`] !== undefined) { + i++ + } + return `${baseName}${i}` +} - errorResponsesByStatus[status] ??= { status, definedErrorDefinitions: [], errorSchemaVariants: [] } +function findReusableComponentName( + componentsSchemas: Record, + defName: string, + schema: JsonSchema, + candidateSchemas: Record, +): string | undefined { + const exactMatch = componentsSchemas[defName] + + if ( + exactMatch !== undefined + && areSchemasEquivalentForReuse( + schema, + exactMatch, + schema, + exactMatch, + candidateSchemas, + componentsSchemas, + new Map([[defName, defName]]), + new Map([[defName, defName]]), + ) + ) { + return defName + } - const [dataRequired, dataSchema] = await this.converter.convert(config.data, { ...baseSchemaConvertOptions, strategy: 'output' }) + for (const [componentName, componentSchema] of Object.entries(componentsSchemas)) { + if (componentName === defName) { + continue + } - errorResponsesByStatus[status].definedErrorDefinitions.push([code, defaultMessage, dataRequired, dataSchema]) - errorResponsesByStatus[status].errorSchemaVariants.push({ - type: 'object', - properties: { - defined: { const: true }, - code: { const: code }, - status: { const: status }, - message: { type: 'string', default: defaultMessage }, - data: dataSchema, - }, - required: dataRequired ? ['defined', 'code', 'status', 'message', 'data'] : ['defined', 'code', 'status', 'message'], - }) + if (areSchemasEquivalentForReuse( + schema, + componentSchema, + schema, + componentSchema, + candidateSchemas, + componentsSchemas, + new Map([[defName, componentName]]), + new Map([[componentName, defName]]), + )) { + return componentName } + } + + return undefined +} + +function areSchemasEquivalentForReuse( + candidate: unknown, + existing: unknown, + candidateRootSchema: JsonSchema, + existingRootSchema: JsonSchema, + candidateSchemas: Record, + existingSchemas: Record, + candidateToExistingComponentNames: Map, + existingToCandidateComponentNames: Map, + visited = new WeakMap>(), +): boolean { + if (candidate === existing) { + return true + } + + if (typeof candidate !== typeof existing) { + return false + } + + if (candidate === null || existing === null) { + return candidate === existing + } + + if (typeof candidate !== 'object' || typeof existing !== 'object') { + return isDeepEqual(candidate, existing) + } + + const seenExisting = visited.get(candidate) + + if (seenExisting?.has(existing)) { + return true + } - ref.responses ??= {} + if (seenExisting) { + seenExisting.add(existing) + } + else { + visited.set(candidate, new WeakSet([existing])) + } + + if (Array.isArray(candidate) || Array.isArray(existing)) { + if (!Array.isArray(candidate) || !Array.isArray(existing) || candidate.length !== existing.length) { + return false + } - for (const statusString in errorResponsesByStatus) { - const errorResponse = errorResponsesByStatus[statusString]! + return candidate.every((item, index) => areSchemasEquivalentForReuse( + item, + existing[index], + candidateRootSchema, + existingRootSchema, + candidateSchemas, + existingSchemas, + candidateToExistingComponentNames, + existingToCandidateComponentNames, + visited, + )) + } - const customBodySchema = value(customErrorResponseBodySchema, errorResponse.definedErrorDefinitions, errorResponse.status) + const candidateObject = candidate as Record + const existingObject = existing as Record + const candidateKeys = Object.keys(candidateObject).sort() + const existingKeys = Object.keys(existingObject).sort() - ref.responses[statusString] = { - description: statusString, - content: toOpenAPIContent(customBodySchema ?? { - oneOf: [ - ...errorResponse.errorSchemaVariants, - undefinedErrorSchema, - ], - }), + if (!isDeepEqual(candidateKeys, existingKeys)) { + return false + } + + return candidateKeys.every((key) => { + const candidateValue = candidateObject[key] + const existingValue = existingObject[key] + + if (key === '$ref' && typeof candidateValue === 'string' && typeof existingValue === 'string') { + return areSchemaRefsEquivalentForReuse( + candidateValue, + existingValue, + candidateRootSchema, + existingRootSchema, + candidateSchemas, + existingSchemas, + candidateToExistingComponentNames, + existingToCandidateComponentNames, + visited, + ) + } + + return areSchemasEquivalentForReuse( + candidateValue, + existingValue, + candidateRootSchema, + existingRootSchema, + candidateSchemas, + existingSchemas, + candidateToExistingComponentNames, + existingToCandidateComponentNames, + visited, + ) + }) +} + +function parseComponentRefName(ref: string): string | undefined { + if (!ref.startsWith('#/components/schemas/')) { + return undefined + } + + return ref + .slice('#/components/schemas/'.length) + .split('/') + .map(decodeJsonPointerSegment) + .join('/') +} + +function resolveSchemaComparisonRef( + ref: string, + rootSchema: JsonSchema, + componentsSchemas: Record, +): { schema: JsonSchema, rootSchema: JsonSchema } | undefined { + const localDefName = parseLocalDefRefName(ref) + + if (localDefName !== undefined && typeof rootSchema === 'object' && rootSchema !== null) { + const localDef = rootSchema.$defs?.[localDefName] + + if (localDef !== undefined) { + return { + schema: localDef, + rootSchema, + } + } + } + + const componentName = parseComponentRefName(ref) + + if (componentName !== undefined) { + const componentSchema = componentsSchemas[componentName] + + if (componentSchema !== undefined) { + return { + schema: componentSchema, + rootSchema: componentSchema, } } } + + return undefined +} + +function areSchemaRefsEquivalentForReuse( + candidateRef: string, + existingRef: string, + candidateRootSchema: JsonSchema, + existingRootSchema: JsonSchema, + candidateSchemas: Record, + existingSchemas: Record, + candidateToExistingComponentNames: Map, + existingToCandidateComponentNames: Map, + visited: WeakMap>, +): boolean { + const candidateComponentName = parseComponentRefName(candidateRef) + const existingComponentName = parseComponentRefName(existingRef) + + if ((candidateComponentName === undefined) !== (existingComponentName === undefined)) { + return false + } + + if (candidateComponentName !== undefined && existingComponentName !== undefined) { + const mappedExisting = candidateToExistingComponentNames.get(candidateComponentName) + + if (mappedExisting !== undefined && mappedExisting !== existingComponentName) { + return false + } + + const mappedCandidate = existingToCandidateComponentNames.get(existingComponentName) + + if (mappedCandidate !== undefined && mappedCandidate !== candidateComponentName) { + return false + } + + candidateToExistingComponentNames.set(candidateComponentName, existingComponentName) + existingToCandidateComponentNames.set(existingComponentName, candidateComponentName) + } + + const resolvedCandidate = resolveSchemaComparisonRef(candidateRef, candidateRootSchema, candidateSchemas) + const resolvedExisting = resolveSchemaComparisonRef(existingRef, existingRootSchema, existingSchemas) + + if (resolvedCandidate === undefined || resolvedExisting === undefined) { + return candidateRef === existingRef + } + + return areSchemasEquivalentForReuse( + resolvedCandidate.schema, + resolvedExisting.schema, + resolvedCandidate.rootSchema, + resolvedExisting.rootSchema, + candidateSchemas, + existingSchemas, + candidateToExistingComponentNames, + existingToCandidateComponentNames, + visited, + ) +} + +function parseLocalDefRefName(ref: string): string | undefined { + if (!ref.startsWith('#/$defs/')) { + return undefined + } + + return ref + .slice('#/$defs/'.length) + .split('/') + .map(decodeJsonPointerSegment) + .join('/') +} + +function rewriteComponentSchemaRefs(schema: JsonSchema, renameMap: Record): JsonSchema { + return mapJsonSchemaRefs(schema, (ref) => { + const refName = parseLocalDefRefName(ref) + + if (refName === undefined) { + return ref + } + + const renamedName = renameMap[refName] + + if (renamedName === undefined) { + return ref + } + + return `#/components/schemas/${encodeJsonPointerSegment(renamedName)}` + }) } diff --git a/packages/openapi/src/openapi-json-serializer.test.ts b/packages/openapi/src/openapi-json-serializer.test.ts new file mode 100644 index 000000000..bf6cc9ec6 --- /dev/null +++ b/packages/openapi/src/openapi-json-serializer.test.ts @@ -0,0 +1,172 @@ +import { OpenAPIJsonSerializer } from './openapi-json-serializer' + +class Person { + constructor( + public name: string, + public date: Date, + ) {} + + toJSON() { + return { + name: this.name, + date: this.date, + } + } +} + +describe('openAPIJsonSerializer', () => { + const serializer = new OpenAPIJsonSerializer() + + describe('serialize', () => { + it('passes through primitives unchanged', () => { + expect(serializer.serialize(1).json).toBe(1) + expect(serializer.serialize('hello').json).toBe('hello') + expect(serializer.serialize(true).json).toBe(true) + expect(serializer.serialize(null).json).toBe(null) + }) + + it('serializes nested undefined values to null', () => { + expect(serializer.serialize([undefined]).json).toEqual([null]) + }) + + it('serializes NaN to null', () => { + expect(serializer.serialize(Number.NaN).json).toBeNull() + }) + + it('serializes Date to ISO string', () => { + expect(serializer.serialize(new Date('2023-01-01')).json).toBe('2023-01-01T00:00:00.000Z') + }) + + it('serializes invalid Date to null', () => { + expect(serializer.serialize(new Date('invalid')).json).toBeNull() + }) + + it('serializes bigint to string', () => { + expect(serializer.serialize(42n).json).toBe('42') + }) + + it('serializes URL to string', () => { + expect(serializer.serialize(new URL('https://unnoq.com')).json).toBe('https://unnoq.com/') + }) + + it('serializes RegExp to string', () => { + expect(serializer.serialize(/uic/gi).json).toBe('/uic/gi') + }) + + it('serializes Set to array', () => { + expect(serializer.serialize(new Set([1, 2, 3])).json).toEqual([1, 2, 3]) + }) + + it('serializes Map to entries array', () => { + expect(serializer.serialize(new Map([['a', 1]])).json).toEqual([['a', 1]]) + }) + + it('serializes nested objects', () => { + expect(serializer.serialize({ + date: new Date('2023-01-01'), + count: 1n, + flag: true, + }).json).toEqual({ + date: '2023-01-01T00:00:00.000Z', + count: '1', + flag: true, + }) + }) + + it('serializes nested arrays', () => { + expect(serializer.serialize([new Date('2023-01-01'), 42n]).json).toEqual([ + '2023-01-01T00:00:00.000Z', + '42', + ]) + }) + + it('omits undefined object properties by default', () => { + expect(serializer.serialize({ a: 1, b: undefined }).json).not.toHaveProperty('b') + }) + + it('skips toJSON methods', () => { + expect(serializer.serialize({ value: { toJSON: () => 'hello' } }).json).toEqual({ value: {} }) + }) + + it('keeps non-function toJSON properties', () => { + expect(serializer.serialize({ value: { toJSON: 'hello' } }).json).toEqual({ value: { toJSON: 'hello' } }) + }) + + it('collects blobs and maps', () => { + const blob = new Blob(['hello']) + const { maps, blobs } = serializer.serialize({ file: blob }) + expect(blobs).toEqual([blob]) + expect(maps).toEqual([['file']]) + }) + }) + + describe('deserialize', () => { + it('restores blobs at mapped paths', () => { + const blob = new Blob(['hello']) + const result = serializer.deserialize({ json: { file: null }, maps: [['file']], blobs: [blob] }) + expect((result as any).file).toBe(blob) + }) + + it('returns json as-is when no blobs', () => { + const json = { a: 1, b: '2023-01-01T00:00:00.000Z' } + expect(serializer.deserialize({ json })).toEqual(json) + }) + + it.each(['doesNotExist', '__proto__', 'constructor'])('throws on invalid segment "%s" to prevent prototype pollution', (segment) => { + expect( + () => serializer.deserialize({ json: { o: {} }, blobs: [new Blob()], maps: [[segment]] }), + ).toThrowError(`Security error: Invalid serialized data. Segment "${segment}" does not exist.`) + + expect( + () => serializer.deserialize({ json: { o: {} }, blobs: [new Blob()], maps: [['o', segment]] }), + ).toThrowError(`Security error: Invalid serialized data. Segment "${segment}" does not exist.`) + + expect( + () => serializer.deserialize({ json: { o: {} }, blobs: [new Blob()], maps: [[segment, 'o']] }), + ).toThrowError(`Security error: Invalid serialized data. Segment "${segment}" does not exist.`) + }) + }) + + describe('options', () => { + it('supports overriding default handlers', () => { + const custom = new OpenAPIJsonSerializer({ + handlers: { + date: { + condition: data => data instanceof Date, + serialize: (value: Date) => `___TEST___${value.getTime()}`, + }, + }, + }) + + const date = new Date('2023-01-01') + expect(custom.serialize({ value: date }).json).toEqual({ value: `___TEST___${date.getTime()}` }) + }) + + it('supports disabling default handlers', () => { + const custom = new OpenAPIJsonSerializer({ handlers: { date: undefined } }) + const date = new Date('2023-01-01') + expect(custom.serialize({ value: date }).json).toEqual({ value: date }) + }) + + it('supports custom handlers', () => { + const custom = new OpenAPIJsonSerializer({ + handlers: { + person: { + condition: data => data instanceof Person, + serialize: (data: Person) => data.toJSON(), + }, + }, + }) + + expect(custom.serialize(new Person('unnoq', new Date('2023-01-01'))).json).toEqual({ + name: 'unnoq', + date: '2023-01-01T00:00:00.000Z', + }) + }) + + it('can disable omitting undefined properties', () => { + const custom = new OpenAPIJsonSerializer({ omitUndefinedProperties: false }) + expect(custom.serialize({ a: 1, b: undefined }).json).toEqual({ a: 1, b: null }) + }) + }) +}) diff --git a/packages/openapi/src/openapi-json-serializer.ts b/packages/openapi/src/openapi-json-serializer.ts new file mode 100644 index 000000000..40f3c5395 --- /dev/null +++ b/packages/openapi/src/openapi-json-serializer.ts @@ -0,0 +1,244 @@ +import type { Segment } from '@orpc/shared' +import { isPlainObject } from '@orpc/shared' + +export type OpenAPIJsonSerialization + = | { json: unknown, maps?: undefined, blobs?: undefined } + | { json: unknown, maps: Segment[][], blobs: Blob[] } + +export interface OpenAPIJsonSerializerHandler { + condition(value: unknown): boolean + serialize(value: any): unknown + /** + * If false, the result of this serializer will not be further processed by other serializers, + * even if it matches their conditions and treat it as final serialized value. + * This can be useful for serializers that return primitive values, which should not be further processed. + * to improve performance and avoid potential issues with other serializers. + * + * @default false + */ + isTerminal?: boolean +} + +const DEFAULT_OPEN_API_JSON_SERIALIZER_HANDLERS: Record = { + undefined: { + condition(data: unknown): boolean { + return data === undefined + }, + serialize() { + return null + }, + isTerminal: true, + }, + bigint: { + condition(data: unknown): boolean { + return typeof data === 'bigint' + }, + serialize(data: bigint): string { + return data.toString() + }, + isTerminal: true, + }, + date: { + condition(data: unknown): boolean { + return data instanceof Date + }, + serialize(data: Date): string | null { + if (Number.isNaN(data.getTime())) { + return null + } + + return data.toISOString() + }, + isTerminal: true, + }, + nan: { + condition(data: unknown): boolean { + return typeof data === 'number' && Number.isNaN(data) + }, + serialize() { + return null + }, + isTerminal: true, + }, + url: { + condition(data: unknown): boolean { + return data instanceof URL + }, + serialize(data: URL): string { + return data.toString() + }, + isTerminal: true, + }, + regexp: { + condition(data: unknown): boolean { + return data instanceof RegExp + }, + serialize(data: RegExp): string { + return data.toString() + }, + isTerminal: true, + }, + set: { + condition(data: unknown): boolean { + return data instanceof Set + }, + serialize(data: Set): unknown[] { + return Array.from(data) + }, + }, + map: { + condition(data: unknown): boolean { + return data instanceof Map + }, + serialize(data: Map): unknown[] { + return Array.from(data.entries()) + }, + }, +} + +export interface OpenAPIJsonSerializerOptions { + /** + * Extend or override the built-in type handlers used during serialization. + * + * Each key is a unique type identifier (e.g. `"date"`, `"bigint"`) and maps to a handler + * that defines how to detect and serialize values of that type. + * + * **Extending:** Add new keys to support custom types: + * ```ts + * handlers: { + * buffer: { + * condition: (v) => v instanceof Buffer, + * serialize: (v: Buffer) => v.toString('base64'), + * isTerminal: true, + * } + * } + * ``` + * + * **Overriding:** Use an existing key to replace a built-in handler: + * ```ts + * handlers: { + * date: { + * condition: (v) => v instanceof Date, + * serialize: (v: Date) => v.getTime(), + * isTerminal: true, + * } + * } + * ``` + * + * **Disabling:** Set a key to `undefined` to remove a built-in handler: + * ```ts + * handlers: { regexp: undefined } + * ``` + * + * Built-in type keys: `undefined`, `bigint`, `date`, `nan`, `url`, `regexp`, `set`, `map`. + */ + handlers?: Record | undefined + + /** + * If true, properties with undefined values will be omitted during serialization. + * + * @default true + */ + omitUndefinedProperties?: boolean | undefined +} + +export class OpenAPIJsonSerializer { + private readonly handlers: Exclude + private readonly omitUndefinedProperties: boolean + + constructor(options: OpenAPIJsonSerializerOptions = {}) { + this.handlers = { + ...DEFAULT_OPEN_API_JSON_SERIALIZER_HANDLERS, + ...options.handlers, + } + + this.omitUndefinedProperties = options.omitUndefinedProperties !== false + } + + serialize(data: unknown): OpenAPIJsonSerialization { + const [json, maps, blobs] = this.serializeValue(data, [], [], []) + + return { json, maps, blobs } + } + + private serializeValue(data: unknown, segments: Segment[], maps: Segment[][], blobs: Blob[]): [unknown, Segment[][], Blob[]] { + for (const key in this.handlers) { + const handler = this.handlers[key] + + if (handler && handler.condition(data)) { + const serialized = handler.serialize(data) + + if (handler.isTerminal) { + return [serialized, maps, blobs] + } + + const result = this.serializeValue(serialized, segments, maps, blobs) + return result + } + } + + if (data instanceof Blob) { + maps.push(segments) + blobs.push(data) + return [data, maps, blobs] + } + + if (Array.isArray(data)) { + const json = data.map((v, i) => { + return this.serializeValue(v, [...segments, i], maps, blobs)[0] + }) + + return [json, maps, blobs] + } + + if (isPlainObject(data)) { + const json: Record = {} + + for (const k in data) { + const v = data[k] + /** + * Skip custom toJSON methods to avoid JSON.stringify invoking them, + * which could cause meta and serialized data mismatches during deserialization. + * Instead, rely on custom handlers. + */ + if (k === 'toJSON' && typeof v === 'function') { + continue + } + + if (v === undefined && this.omitUndefinedProperties) { + continue + } + + json[k] = this.serializeValue(v, [...segments, k], maps, blobs)[0] + } + + return [json, maps, blobs] + } + + return [data, maps, blobs] + } + + deserialize(serialized: OpenAPIJsonSerialization): unknown { + const ref = { data: serialized.json } + + if (serialized.blobs?.length) { + serialized.maps.forEach((segments, i) => { + let currentRef: any = ref + let preSegment: string | number = 'data' + + segments.forEach((segment) => { + currentRef = currentRef[preSegment] + preSegment = segment + + if (!Object.hasOwn(currentRef, preSegment)) { + throw new Error(`Security error: Invalid serialized data. Segment "${preSegment}" does not exist.`) + } + }) + + currentRef[preSegment] = serialized.blobs[i] + }) + } + + return ref.data + } +} diff --git a/packages/openapi/src/openapi-serializer.test.ts b/packages/openapi/src/openapi-serializer.test.ts new file mode 100644 index 000000000..b9c513685 --- /dev/null +++ b/packages/openapi/src/openapi-serializer.test.ts @@ -0,0 +1,318 @@ +import { ORPCError } from '@orpc/client' +import { isAsyncIteratorObject } from '@orpc/shared' +import { ErrorEvent } from '@standardserver/core' +import { OpenAPISerializer } from './openapi-serializer' + +describe('openAPISerializer', () => { + const serializer = new OpenAPISerializer() + + describe('serialize', () => { + it('uses OpenAPIJsonSerializer for serialization', () => { + expect(serializer.serialize({ date: new Date('2023-01-01'), count: 1n })).toEqual({ + date: '2023-01-01T00:00:00.000Z', + count: '1', + }) + }) + + it('returns a root-level undefined as-is', () => { + expect(serializer.serialize(undefined)).toBe(undefined) + }) + + it('returns a root-level Blob as-is without wrapping in FormData', () => { + const blob = new Blob(['hello']) + expect(serializer.serialize(blob)).toBe(blob) + }) + + it('returns a root-level ReadableStream as-is', () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('hello')) + controller.close() + }, + }) + + expect(serializer.serialize(stream)).toBe(stream) + }) + + it('converts object with blob fields to FormData', () => { + const blob = new Blob(['hello'], { type: 'text/plain' }) + const result = serializer.serialize({ file: blob, date: new Date('2023-01-01') }) as FormData + + expect(result).toBeInstanceOf(FormData) + const file = result.get('file') as Blob + expect(file).toBeInstanceOf(Blob) + expect(file.size).toBe(blob.size) + expect(file.type).toBe(blob.type) + expect(result.get('date')).toBe('2023-01-01T00:00:00.000Z') + }) + + it('omits null and undefined fields from FormData', () => { + const result = serializer.serialize({ file: new Blob(['data']), empty: null }) as FormData + expect(result.has('empty')).toBe(false) + }) + + it('skips useFormDataForBlobFields when false', () => { + const blob = new Blob(['hello']) + expect(serializer.serialize({ file: blob }, { useFormDataForBlobFields: false })).not.toBeInstanceOf(FormData) + }) + + it('always returns FormData when asFormData is true, using bracket notation', () => { + const result = serializer.serialize( + { user: { name: 'test' }, tags: ['a', 'b'] }, + { asFormData: true }, + ) as FormData + + expect(result).toBeInstanceOf(FormData) + expect(result.get('user[name]')).toBe('test') + expect(result.get('tags[0]')).toBe('a') + expect(result.get('tags[1]')).toBe('b') + }) + + it('serializes root-level arrays to numeric FormData keys', () => { + const result = serializer.serialize(['a', 'b'], { asFormData: true }) as FormData + + expect(result).toBeInstanceOf(FormData) + expect(result.get('0')).toBe('a') + expect(result.get('1')).toBe('b') + }) + + it('call-site options override constructor defaults', () => { + const s = new OpenAPISerializer({ serialize: { asFormData: true, useFormDataForBlobFields: false } }) + + expect(s.serialize({ name: 'test' })).toBeInstanceOf(FormData) + expect(s.serialize({ name: 'test' }, { asFormData: false })).not.toBeInstanceOf(FormData) + + const blob = new Blob(['hello']) + expect(s.serialize({ file: blob }, { asFormData: false })).not.toBeInstanceOf(FormData) + expect(s.serialize({ file: blob }, { asFormData: false, useFormDataForBlobFields: true })).toBeInstanceOf(FormData) + }) + + describe('async iterator object', () => { + async function* toAsyncIter(values: T[]) { + for (const v of values) yield v + } + + it('returns an async iterator and serializes yielded values', async () => { + const result = serializer.serialize(toAsyncIter([new Date('2023-01-01'), 42n])) as AsyncIteratorObject + expect(result).toSatisfy(isAsyncIteratorObject) + + const collected: unknown[] = [] + for await (const v of result) collected.push(v) + expect(collected).toEqual(['2023-01-01T00:00:00.000Z', '42']) + }) + + it('passes through undefined yielded values as-is', async () => { + const result = serializer.serialize(toAsyncIter([undefined, 'value'])) as AsyncIteratorObject + expect(result).toSatisfy(isAsyncIteratorObject) + + const collected: unknown[] = [] + for await (const v of result) collected.push(v) + expect(collected).toEqual([undefined, 'value']) + }) + + it('ignores asFormData default option and never wraps yielded values', async () => { + const s = new OpenAPISerializer({ serialize: { asFormData: true } }) + const result = s.serialize(toAsyncIter([{ name: 'test' }])) as AsyncIteratorObject + expect(result).toSatisfy(isAsyncIteratorObject) + + const collected: unknown[] = [] + for await (const v of result) collected.push(v) + expect(collected).toEqual([{ name: 'test' }]) + }) + + it('converts thrown ORPC errors into ErrorEvent payloads', async () => { + const error = new ORPCError('BAD_GATEWAY', { data: { reason: 'upstream' } }) + const result = serializer.serialize((async function* () { + throw error + })()) as AsyncIteratorObject + + await expect(result.next()).rejects.toSatisfy((e: any) => { + expect(e).toBeInstanceOf(ErrorEvent) + expect(e.data).toEqual({ + cause: error, + data: error.toJSON(), + }) + + return true + }) + }) + + it('maps unknown iterator errors into INTERNAL_SERVER_ERROR payloads', async () => { + const error = new Error('unexpected') + const result = serializer.serialize((async function* () { + throw error + })()) as AsyncIteratorObject + + await expect(result.next()).rejects.toSatisfy((e: any) => { + expect(e).toBeInstanceOf(ErrorEvent) + expect(e.data).toMatchObject({ + cause: error, + data: { + code: 'INTERNAL_SERVER_ERROR', + defined: false, + inferable: false, + message: 'Internal Server Error', + }, + }) + + return true + }) + }) + }) + }) + + describe('deserialize', () => { + it('uses OpenAPIJsonSerializer for deserialization', () => { + expect(serializer.deserialize({ name: 'test', value: 42 })).toEqual({ name: 'test', value: 42 }) + }) + + it.each([ + ['URLSearchParams', () => new URLSearchParams('user[name]=test&tags[0]=a&tags[1]=b')], + ['FormData', () => { + const f = new FormData() + f.append('user[name]', 'test') + f.append('tags[0]', 'a') + f.append('tags[1]', 'b') + return f + }], + ])('deserializes %s using bracket notation', (_, makeInput) => { + const result = serializer.deserialize(makeInput()) as any + expect(result.user.name).toBe('test') + expect(result.tags).toEqual(['a', 'b']) + }) + + it('deserializes root-level numeric bracket notation as an object', () => { + const result = serializer.deserialize(new URLSearchParams('0=a&1=b')) as any + + expect(Array.isArray(result)).toBe(false) + expect(result).toEqual({ + 0: 'a', + 1: 'b', + }) + }) + + it('deserializes FormData blob fields', () => { + const blob = new Blob(['hello'], { type: 'text/plain' }) + const form = new FormData() + form.append('file', blob) + const result = serializer.deserialize(form) as any + expect(result.file).toBeInstanceOf(Blob) + expect(result.file.size).toBe(blob.size) + expect(result.file.type).toBe(blob.type) + }) + + it('returns undefined bodies as-is', () => { + expect(serializer.deserialize(undefined)).toBe(undefined) + }) + + it('returns Blob bodies as-is', () => { + const blob = new Blob(['hi']) + expect(serializer.deserialize(blob)).toBe(blob) + }) + + it('returns ReadableStream bodies as-is', () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('hello')) + controller.close() + }, + }) + + expect(serializer.deserialize(stream)).toBe(stream) + }) + + describe('async iterator object', () => { + async function* toAsyncIter(values: T[]) { + for (const v of values) yield v + } + + it('returns an async iterator and passes through yielded values', async () => { + const result = serializer.deserialize(toAsyncIter([{ a: 1 }, { b: 2 }])) as AsyncIterable + expect(result).toSatisfy(isAsyncIteratorObject) + + const collected: unknown[] = [] + for await (const v of result) collected.push(v) + expect(collected).toEqual([{ a: 1 }, { b: 2 }]) + }) + + it('passes through undefined yielded values as-is', async () => { + const result = serializer.deserialize(toAsyncIter([undefined, { a: 1 }])) as AsyncIterable + expect(result).toSatisfy(isAsyncIteratorObject) + + const collected: unknown[] = [] + for await (const v of result) collected.push(v) + expect(collected).toEqual([undefined, { a: 1 }]) + }) + + it('converts ErrorEvent ORPC payloads back into ORPCError instances', async () => { + const error = new ErrorEvent( + new ORPCError('BAD_GATEWAY', { data: { reason: 'upstream' } }).toJSON(), + ) + const result = serializer.deserialize((async function* () { + throw error + })()) as AsyncIteratorObject + + await expect(result.next()).rejects.toSatisfy((e: any) => { + expect(e).toBeInstanceOf(ORPCError) + expect(e.code).toBe('BAD_GATEWAY') + expect(e.data).toEqual({ reason: 'upstream' }) + expect(e.cause).toBe(error) + + return true + }) + }) + + it('passes through ErrorEvent instances with non-ORPC payloads', async () => { + const error = new ErrorEvent({ reason: 'upstream' }) + const result = serializer.deserialize((async function* () { + throw error + })()) as AsyncIteratorObject + + await expect(result.next()).rejects.toBe(error) + }) + + it('passes through non-ErrorEvent iterator errors', async () => { + const error = new Error('unexpected') + const result = serializer.deserialize((async function* () { + throw error + })()) as AsyncIteratorObject + + await expect(result.next()).rejects.toBe(error) + }) + }) + }) + + describe('options', () => { + it('passes OpenAPIJsonSerializerOptions to OpenAPIJsonSerializer', () => { + const s = new OpenAPISerializer({ + handlers: { + date: { + condition: v => v instanceof Date, + serialize: (v: Date) => `___TEST___${v.getTime()}`, + }, + }, + }) + + const date = new Date('2023-01-01') + expect(s.serialize({ value: date })).toEqual({ value: `___TEST___${date.getTime()}` }) + }) + + it('passes omitUndefinedProperties to OpenAPIJsonSerializer', () => { + const s = new OpenAPISerializer({ omitUndefinedProperties: false }) + expect(s.serialize({ a: 1, b: undefined })).toEqual({ a: 1, b: null }) + }) + + it('passes BracketNotationSerializerOptions to BracketNotationSerializer', () => { + const s = new OpenAPISerializer({ bracketNotation: { maxExplicitDeserializingArrayIndex: 0 } }) + + // index 1 exceeds the limit of 0, so the array should be deserialized as an object + const form = new FormData() + form.append('tags[0]', 'a') + form.append('tags[1]', 'b') + const result = s.deserialize(form) as any + expect(Array.isArray(result.tags)).toBe(false) + expect(result.tags['0']).toBe('a') + expect(result.tags['1']).toBe('b') + }) + }) +}) diff --git a/packages/openapi/src/openapi-serializer.ts b/packages/openapi/src/openapi-serializer.ts new file mode 100644 index 000000000..5d6e5c249 --- /dev/null +++ b/packages/openapi/src/openapi-serializer.ts @@ -0,0 +1,140 @@ +import type { StandardBody } from '@standardserver/core' +import type { BracketNotationSerializerOptions } from './bracket-notation' +import type { OpenAPIJsonSerializerOptions } from './openapi-json-serializer' +import { createORPCErrorFromJson, isORPCErrorJson, toORPCError, wrapEventIteratorPreservingMeta } from '@orpc/client' +import { isAsyncIteratorObject } from '@orpc/shared' +import { ErrorEvent } from '@standardserver/core' +import { BracketNotationSerializer } from './bracket-notation' +import { OpenAPIJsonSerializer } from './openapi-json-serializer' + +export interface OpenAPISerializerSerializeOptions { + /** + * Use FormData for serialization when nested blobs are present. + * Does not apply to root-level Blob values. + * + * @default true + */ + useFormDataForBlobFields?: boolean + + /** + * When enabled, the serialized output is always returned as a FormData instance using bracket notation. + * + * @default false + */ + asFormData?: boolean | undefined +} + +export interface OpenAPISerializerOptions extends OpenAPIJsonSerializerOptions, OpenAPISerializerSerializeOptions { + /** + * Options for bracket notation serializer, like maxExplicitDeserializingArrayIndex + */ + bracketNotation?: BracketNotationSerializerOptions | undefined + + /** + * Default options for serialize method + */ + serialize?: OpenAPISerializerSerializeOptions | undefined +} + +export class OpenAPISerializer { + private readonly jsonSerializer: OpenAPIJsonSerializer + private readonly bracketNotation: BracketNotationSerializer + private readonly defaultSerializeOptions: OpenAPISerializerSerializeOptions | undefined + + constructor( + { bracketNotation, serialize, ...options }: OpenAPISerializerOptions = {}, + ) { + this.jsonSerializer = new OpenAPIJsonSerializer(options) + this.bracketNotation = new BracketNotationSerializer(bracketNotation) + this.defaultSerializeOptions = serialize + } + + serialize(data: unknown, options: OpenAPISerializerSerializeOptions = {}): StandardBody { + const useFormDataForBlobFields = options.useFormDataForBlobFields ?? this.defaultSerializeOptions?.useFormDataForBlobFields ?? true + const asFormData = options.asFormData ?? this.defaultSerializeOptions?.asFormData ?? false + + if (!options.asFormData) { + // standard body already supports these types without additional serialization. + if (data === undefined || data instanceof ReadableStream || data instanceof Blob) { + return data + } + + if (isAsyncIteratorObject(data)) { + return wrapEventIteratorPreservingMeta(data, { + mapResult: (result) => { + // standard event stream data already supports these types without additional serialization. + if (result.value === undefined) { + return result + } + + return { done: result.done, value: this.serializeValue(result.value, { asFormData: false, useFormDataForBlobFields: false }) } + }, + mapError: (e) => { + return new ErrorEvent({ + data: this.serializeValue(toORPCError(e).toJSON(), { asFormData: false, useFormDataForBlobFields: false }), + cause: e, + }) + }, + }) + } + } + + return this.serializeValue(data, { useFormDataForBlobFields, asFormData }) + } + + private serializeValue(value: unknown, options: Required): unknown { + const { json, blobs } = this.jsonSerializer.serialize(value) + + if (!options.asFormData && (json instanceof Blob || json === undefined || !blobs?.length || !options.useFormDataForBlobFields)) { + return json + } + + const form = new FormData() + + for (const [path, value] of this.bracketNotation.serialize(json)) { + if (value instanceof Blob) { + form.append(path, value) + } + else if (value !== undefined && value !== null) { + form.append(path, String(value)) + } + } + + return form + } + + deserialize(data: StandardBody): unknown { + if (data === undefined || data instanceof ReadableStream || data instanceof Blob) { + return data + } + + if (isAsyncIteratorObject(data)) { + return wrapEventIteratorPreservingMeta(data, { + mapResult: (result) => { + if (result.value === undefined) { + return result + } + + return { done: result.done, value: this.jsonSerializer.deserialize({ json: result.value }) } + }, + mapError: (e) => { + if (e instanceof ErrorEvent) { + const deserialized = this.jsonSerializer.deserialize({ json: e.data }) + + if (isORPCErrorJson(deserialized)) { + return createORPCErrorFromJson(deserialized, { cause: e }) + } + } + + return e + }, + }) + } + + if (data instanceof URLSearchParams || data instanceof FormData) { + data = this.bracketNotation.deserialize(Array.from(data.entries())) + } + + return this.jsonSerializer.deserialize({ json: data }) + } +} diff --git a/packages/openapi/src/openapi-utils.test.ts b/packages/openapi/src/openapi-utils.test.ts deleted file mode 100644 index 0024f2f0f..000000000 --- a/packages/openapi/src/openapi-utils.test.ts +++ /dev/null @@ -1,892 +0,0 @@ -import type { OpenAPI } from '@orpc/contract' -import type { FileSchema, JSONSchema, ObjectSchema } from './schema' -import { - checkParamsSchema, - resolveOpenAPIJsonSchemaRef, - simplifyComposedObjectJsonSchemasAndRefs, - toOpenAPIContent, - toOpenAPIEventIteratorContent, - toOpenAPIMethod, - toOpenAPIParameters, - toOpenAPIPath, - toOpenAPISchema, -} from './openapi-utils' - -it('toOpenAPIPath', () => { - expect(toOpenAPIPath('/path')).toBe('/path') - expect(toOpenAPIPath('/path//{id}')).toBe('/path/{id}') - expect(toOpenAPIPath('/path//to/{+id}')).toBe('/path/to/{id}') - expect(toOpenAPIPath('//path//{+id}//something{+id}//')).toBe('/path/{id}/something{+id}') -}) - -it('toOpenAPIMethod', () => { - expect(toOpenAPIMethod('GET')).toBe('get') - expect(toOpenAPIMethod('POST')).toBe('post') - expect(toOpenAPIMethod('PUT')).toBe('put') - expect(toOpenAPIMethod('DELETE')).toBe('delete') - expect(toOpenAPIMethod('PATCH')).toBe('patch') -}) - -describe('toOpenAPIContent', () => { - const fileSchema: FileSchema = { type: 'string', contentMediaType: 'image/png' } - - it('normal schema', () => { - const schema: JSONSchema = { - type: 'object', - properties: { - a: { type: 'string' }, - b: { type: 'number' }, - }, - required: ['a'], - } - - expect(toOpenAPIContent(schema)).toEqual({ - 'application/json': { - schema, - }, - }) - }) - - it('body can be file schema', () => { - expect(toOpenAPIContent(fileSchema)).toEqual({ - 'image/png': { - schema: fileSchema, - }, - }) - - expect(toOpenAPIContent({ - anyOf: [ - fileSchema, - { type: 'number' }, - ], - })).toEqual({ - 'image/png': { - schema: fileSchema, - }, - 'application/json': { - schema: { type: 'number' }, - }, - }) - }) - - it('omits unconstrained non-file branches', () => { - expect(toOpenAPIContent({ - anyOf: [ - fileSchema, - {}, - ], - })).toEqual({ - 'image/png': { - schema: fileSchema, - }, - }) - - expect(toOpenAPIContent({ properties: undefined })).toEqual({}) - }) - - it('omits never non-file branches', () => { - expect(toOpenAPIContent({ - anyOf: [ - fileSchema, - { not: {} }, - ], - })).toEqual({ - 'image/png': { - schema: fileSchema, - }, - }) - }) - - it('body contain file schema', () => { - const schema: JSONSchema = { - type: 'object', - properties: { - a: { type: 'string' }, - b: { type: 'number' }, - c: fileSchema, - }, - required: ['a'], - } - - expect(toOpenAPIContent(schema)).toEqual({ - 'application/json': { - schema, - }, - 'multipart/form-data': { - schema, - }, - }) - }) -}) - -describe('toOpenAPIEventIteratorContent', () => { - it('required yields & not required returns', () => { - expect(toOpenAPIEventIteratorContent([true, { type: 'string' }], [false, { type: 'number' }])).toEqual({ - 'text/event-stream': { - schema: { - oneOf: [ - { - type: 'object', - properties: { - event: { const: 'message' }, - data: { type: 'string' }, - id: { type: 'string' }, - retry: { type: 'number' }, - }, - required: ['event', 'data'], - }, - { - type: 'object', - properties: { - event: { const: 'done' }, - data: { type: 'number' }, - id: { type: 'string' }, - retry: { type: 'number' }, - }, - required: ['event'], - }, - { - type: 'object', - properties: { - event: { const: 'error' }, - data: {}, - id: { type: 'string' }, - retry: { type: 'number' }, - }, - required: ['event'], - }, - ], - }, - }, - }) - }) - - it('not required yields & required returns', () => { - expect(toOpenAPIEventIteratorContent([false, { type: 'string' }], [true, { type: 'number' }])).toEqual({ - 'text/event-stream': { - schema: { - oneOf: [ - { - type: 'object', - properties: { - event: { const: 'message' }, - data: { type: 'string' }, - id: { type: 'string' }, - retry: { type: 'number' }, - }, - required: ['event'], - }, - { - type: 'object', - properties: { - event: { const: 'done' }, - data: { type: 'number' }, - id: { type: 'string' }, - retry: { type: 'number' }, - }, - required: ['event', 'data'], - }, - { - type: 'object', - properties: { - event: { const: 'error' }, - data: {}, - id: { type: 'string' }, - retry: { type: 'number' }, - }, - required: ['event'], - }, - ], - }, - }, - }) - }) -}) - -describe('toOpenAPIParameters', () => { - const schema: ObjectSchema = { - type: 'object', - properties: { - a: { type: 'string' }, - b: { - type: 'object', - properties: { - b1: { type: 'number' }, - b2: { type: 'string' }, - }, - required: ['b1'], - }, - c: { - oneOf: [ - { type: 'string' }, - { type: 'array', items: { type: 'string' } }, - ], - }, - }, - required: ['a', 'c'], - } - - it('normal', () => { - expect(toOpenAPIParameters(schema, 'path')).toEqual([{ - name: 'a', - in: 'path', - required: true, - schema: { - type: 'string', - }, - }, { - name: 'b', - in: 'path', - required: false, - schema: { - type: 'object', - properties: { - b1: { type: 'number' }, - b2: { type: 'string' }, - }, - required: ['b1'], - }, - }, { - name: 'c', - in: 'path', - required: true, - schema: { - oneOf: [ - { type: 'string' }, - { type: 'array', items: { type: 'string' } }, - ], - }, - }]) - }) - - it('query', () => { - expect(toOpenAPIParameters(schema, 'query')).toEqual([{ - name: 'a', - in: 'query', - required: true, - schema: { - type: 'string', - }, - allowEmptyValue: true, - allowReserved: true, - }, { - name: 'b', - in: 'query', - required: false, - explode: true, - style: 'deepObject', - schema: { - type: 'object', - properties: { - b1: { type: 'number' }, - b2: { type: 'string' }, - }, - required: ['b1'], - }, - allowEmptyValue: true, - allowReserved: true, - }, { - name: 'c', - in: 'query', - required: true, - schema: { - oneOf: [ - { type: 'string' }, - { type: 'array', items: { type: 'string' } }, - ], - }, - allowEmptyValue: true, - allowReserved: true, - }]) - }) -}) - -describe('checkParamsSchema', () => { - it('missing properties', () => { - const schema: ObjectSchema = { - type: 'object', - required: ['a', 'b'], - } - - expect(checkParamsSchema(schema, ['a', 'b'])).toBe(false) - }) - - it('redundant properties', () => { - const schema: ObjectSchema = { - type: 'object', - properties: { - a: { type: 'string' }, - b: { type: 'string' }, - }, - required: ['a', 'b'], - } - - expect(checkParamsSchema(schema, ['a'])).toBe(false) - }) - - it('missing required', () => { - const schema: ObjectSchema = { - type: 'object', - properties: { - a: { type: 'string' }, - b: { type: 'string' }, - }, - } - - expect(checkParamsSchema(schema, ['a', 'b'])).toBe(false) - }) - - it('redundant required', () => { - const schema: ObjectSchema = { - type: 'object', - properties: { - a: { type: 'string' }, - }, - required: ['a', 'b'], - } - - expect(checkParamsSchema(schema, ['a'])).toBe(false) - }) - - it('correct', () => { - const schema: ObjectSchema = { - type: 'object', - properties: { - a: { type: 'string' }, - b: { type: 'string' }, - }, - required: ['a', 'b'], - } - - expect(checkParamsSchema(schema, ['a', 'b'])).toBe(true) - }) -}) - -it('toOpenAPISchema', () => { - expect(toOpenAPISchema(true)).toEqual({}) - expect(toOpenAPISchema(false)).toEqual({ not: {} }) - expect(toOpenAPISchema({ type: 'string' })).toEqual({ type: 'string' }) -}) - -describe('resolveOpenAPIJsonSchemaRef', () => { - const doc = { - components: { - schemas: { - 'a': { type: 'string' }, - 'b': { type: 'number' }, - 'c/c': { type: 'object' }, - }, - }, - } as any - - it('works', () => { - expect(resolveOpenAPIJsonSchemaRef(doc, { $ref: '#/components/schemas/a' })).toEqual({ type: 'string' }) - expect(resolveOpenAPIJsonSchemaRef(doc, { $ref: '#/components/schemas/b' })).toEqual({ type: 'number' }) - expect(resolveOpenAPIJsonSchemaRef(doc, { $ref: '#/components/schemas/c/c' })).toEqual({ type: 'object' }) - }) - - it('do nothing if schema is not $ref', () => { - expect(resolveOpenAPIJsonSchemaRef(doc, true)).toEqual(true) - expect(resolveOpenAPIJsonSchemaRef(doc, false)).toEqual(false) - expect(resolveOpenAPIJsonSchemaRef(doc, {})).toEqual({}) - expect(resolveOpenAPIJsonSchemaRef(doc, { type: 'object' })).toEqual({ type: 'object' }) - }) - - it('it do nothing if have no components.schemas', () => { - const doc = {} as OpenAPI.Document - const doc2 = { - components: {}, - } as OpenAPI.Document - - expect(resolveOpenAPIJsonSchemaRef(doc, { type: 'string' })).toEqual({ type: 'string' }) - expect(resolveOpenAPIJsonSchemaRef(doc, { $ref: '#/components/schemas/a' })).toEqual({ $ref: '#/components/schemas/a' }) - expect(resolveOpenAPIJsonSchemaRef(doc2, { $ref: '#/components/schemas/a' })).toEqual({ $ref: '#/components/schemas/a' }) - }) - - it('not resolve if $ref is not a components.schemas', () => { - expect(resolveOpenAPIJsonSchemaRef(doc, { $ref: '#/$defs/a' })).toEqual({ $ref: '#/$defs/a' }) - }) - - it('not resolve if $ref not found', () => { - expect(resolveOpenAPIJsonSchemaRef(doc, { $ref: '#/components/schemas/not-found' })).toEqual({ $ref: '#/components/schemas/not-found' }) - }) -}) - -describe('simplifyComposedObjectJsonSchemasAndRefs', () => { - it('does not simplify non-object or non-composed schemas', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs(true)).toEqual(true) - expect(simplifyComposedObjectJsonSchemasAndRefs({ type: 'string' })).toEqual({ type: 'string' }) - expect(simplifyComposedObjectJsonSchemasAndRefs({ anyOf: [{ type: 'string' }, { type: 'number' }] })).toEqual({ anyOf: [{ type: 'string' }, { type: 'number' }] }) - expect(simplifyComposedObjectJsonSchemasAndRefs({ allOf: [{ type: 'array' }] })).toEqual({ allOf: [{ type: 'array' }] }) - - expect(simplifyComposedObjectJsonSchemasAndRefs({ - anyOf: [ - { type: 'object', properties: { a: { type: 'string' } } }, - { type: 'number' }, - ], - })).toEqual({ - anyOf: [ - { type: 'object', properties: { a: { type: 'string' } } }, - { type: 'number' }, - ], - }) - - expect(simplifyComposedObjectJsonSchemasAndRefs({ - description: 'description', - type: 'object', - properties: { a: { type: 'string' } }, - additionalProperties: false, - })).toEqual({ - description: 'description', - type: 'object', - properties: { a: { type: 'string' } }, - additionalProperties: false, - }) - }) - - it('only remain type, properties, required logics', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - anyOf: [ - { - type: 'object', - properties: { a: { type: 'string' } }, - required: ['a'], - description: 'description a', - }, - { - type: 'object', - properties: { b: { type: 'number' } }, - required: ['b'], - additionalProperties: false, - }, - ], - description: 'object description', - additionalProperties: true, - })).toEqual({ - type: 'object', - properties: { - a: { type: 'string' }, - b: { type: 'number' }, - }, - required: [], - }) - }) - - describe.each(['anyOf', 'oneOf'])('%s', (keyword) => { - it('ignore additional object logic', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - description: 'animal', - [keyword]: [ - { - type: 'object', - properties: { type: { const: 'pig' }, weight: { type: 'number' } }, - required: ['type', 'weight'], - additionalProperties: false, - }, - { - type: 'object', - properties: { type: { const: 'dog' }, barkVolume: { type: 'number' } }, - required: ['type', 'barkVolume'], - patternProperties: { - '^S_': { type: 'string' }, - '^I_': { type: 'integer' }, - }, - }, - ], - })).toEqual({ - type: 'object', - properties: { - type: { anyOf: [{ const: 'pig' }, { const: 'dog' }] }, - weight: { type: 'number' }, - barkVolume: { type: 'number' }, - }, - required: ['type'], - }) - }) - - it('handles empty', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - description: 'empty', - [keyword]: [], - })).toEqual({ - description: 'empty', - [keyword]: [], - }) - }) - - it('does not merge mixed object and non-object schemas', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - [keyword]: [ - { type: 'object', properties: { a: { type: 'string' } } }, - { type: 'boolean' }, - ], - })).toEqual({ - [keyword]: [ - { type: 'object', properties: { a: { type: 'string' } } }, - { type: 'boolean' }, - ], - }) - }) - - it('merges object schemas with discriminated union', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - description: 'animal', - [keyword]: [ - { - type: 'object', - properties: { type: { const: 'pig' }, weight: { type: 'number' } }, - required: ['type', 'weight'], - }, - { - type: 'object', - properties: { type: { const: 'dog' }, barkVolume: { type: 'number' } }, - required: ['type', 'barkVolume'], - }, - ], - })).toEqual({ - type: 'object', - properties: { - type: { anyOf: [{ const: 'pig' }, { const: 'dog' }] }, - weight: { type: 'number' }, - barkVolume: { type: 'number' }, - }, - required: ['type'], - }) - }) - - it('handle required & dedupe schemas correctly', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - [keyword]: [ - { type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' } }, required: ['a'] }, - { type: 'object', properties: { a: { type: 'string' }, c: { type: 'string' } }, required: ['a', 'c'] }, - ], - })).toEqual({ - type: 'object', - properties: { - a: { type: 'string' }, - b: { type: 'string' }, - c: { type: 'string' }, - }, - required: ['a'], - }) - }) - - it('handles nested union recursively', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - [keyword]: [ - { [keyword]: [{ type: 'string' }, { type: 'number' }] }, - { type: 'boolean' }, - ], - })).toEqual({ - [keyword]: [ - { [keyword]: [{ type: 'string' }, { type: 'number' }] }, - { type: 'boolean' }, - ], - }) - }) - }) - - describe('allOf', () => { - it('handles empty', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - description: 'empty', - allOf: [], - })).toEqual({ - description: 'empty', - allOf: [], - }) - }) - - it('merges object schemas', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - allOf: [ - { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] }, - { type: 'object', properties: { b: { type: 'number' } }, required: ['b'] }, - ], - })).toEqual({ - type: 'object', - properties: { - a: { type: 'string' }, - b: { type: 'number' }, - }, - required: ['a', 'b'], - }) - }) - - it('merges overlapping properties with allOf', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - allOf: [ - { type: 'object', properties: { a: { type: 'string', minLength: 1 } }, required: ['a'] }, - { type: 'object', properties: { a: { type: 'string', maxLength: 10 } }, required: ['a'] }, - ], - })).toEqual({ - type: 'object', - properties: { - a: { allOf: [{ type: 'string', minLength: 1 }, { type: 'string', maxLength: 10 }] }, - }, - required: ['a'], - }) - }) - - it('handle required correctly & dedupe schemas', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - allOf: [ - { type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' } }, required: ['a'] }, - { type: 'object', properties: { a: { type: 'string' }, c: { type: 'string' } }, required: ['a', 'c'] }, - ], - })).toEqual({ - type: 'object', - properties: { - a: { type: 'string' }, - b: { type: 'string' }, - c: { type: 'string' }, - }, - required: ['a', 'c'], - }) - }) - - it('handle nested compositions', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - allOf: [ - { - allOf: [ - { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] }, - { type: 'object', properties: { b: { type: 'number' } } }, - ], - }, - { type: 'object', properties: { c: { type: 'boolean' } }, required: ['c'] }, - ], - })).toEqual({ - type: 'object', - properties: { - a: { type: 'string' }, - b: { type: 'number' }, - c: { type: 'boolean' }, - }, - required: ['a', 'c'], - }) - }) - }) - - describe('combined compositions', () => { - it('recursively simplifies oneOf with nested allOf', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - oneOf: [ - { - allOf: [ - { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] }, - { type: 'object', properties: { b: { type: 'number' } }, required: ['b'] }, - ], - }, - { - allOf: [ - { type: 'object', properties: { a: { type: 'number' } }, required: ['a'] }, - { type: 'object', properties: { c: { type: 'boolean' } }, required: ['c'] }, - ], - }, - ], - })).toEqual({ - type: 'object', - properties: { - a: { anyOf: [{ type: 'string' }, { type: 'number' }] }, - b: { type: 'number' }, - c: { type: 'boolean' }, - }, - required: ['a'], - }) - }) - - it('recursively simplifies anyOf with nested allOf', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - anyOf: [ - { - allOf: [ - { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] }, - { type: 'object', properties: { b: { type: 'number' } }, required: ['b'] }, - ], - }, - { - allOf: [ - { type: 'object', properties: { c: { type: 'boolean' } }, required: ['c'] }, - { type: 'object', properties: { d: { type: 'string' } }, required: ['d'] }, - ], - }, - ], - })).toEqual({ - type: 'object', - properties: { - a: { type: 'string' }, - b: { type: 'number' }, - c: { type: 'boolean' }, - d: { type: 'string' }, - }, - required: [], - }) - }) - - it('handles deeply nested compositions', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - anyOf: [ - { - allOf: [ - { type: 'object', properties: { a: { type: 'string' } } }, - ], - }, - ], - })).toEqual({ - type: 'object', - properties: { a: { type: 'string' } }, - required: [], - }) - }) - - it('can simplify composed schemas with many compositions', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - anyOf: [{ type: 'object', properties: { a: { type: 'string' } }, required: ['a'] }], - allOf: [{ type: 'object', properties: { b: { type: 'number' } }, required: ['b'] }], - })).toEqual({ - type: 'object', - properties: { - a: { type: 'string' }, - b: { type: 'number' }, - }, - required: ['a', 'b'], - }) - }) - - it('dedupes schemas when anyOf and allOf coexist at the same level', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - allOf: [ - { type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' } }, required: ['a'] }, - { type: 'object', properties: { a: { type: 'string' }, c: { type: 'string' } }, required: ['a', 'c'] }, - ], - anyOf: [ - { type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' } }, required: ['a'] }, - { type: 'object', properties: { a: { type: 'number' }, d: { type: 'string' } }, required: ['a', 'd'] }, - ], - })).toEqual({ - type: 'object', - properties: { - a: { - allOf: [ - { type: 'string' }, - { anyOf: [{ type: 'string' }, { type: 'number' }] }, - ], - }, - b: { type: 'string' }, - c: { type: 'string' }, - d: { type: 'string' }, - }, - required: ['a', 'c'], - }) - }) - - it('schema with object and composed schemas in the same level', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs({ - type: 'object', - properties: { - a: { type: 'string' }, - b: { type: 'string' }, - }, - required: ['a'], - anyOf: [ - { - type: 'object', - properties: { - b: { type: 'number' }, - c: { type: 'boolean' }, - }, - required: ['b', 'c'], - }, - { - type: 'object', - properties: { - c: { type: 'boolean' }, - }, - required: ['c'], - }, - ], - allOf: [ - { - type: 'object', - properties: { - f: { type: 'string' }, - }, - required: ['f'], - }, - ], - })).toEqual({ - type: 'object', - properties: { - a: { type: 'string' }, - b: { allOf: [{ type: 'string' }, { type: 'number' }] }, - c: { type: 'boolean' }, - f: { type: 'string' }, - }, - required: ['c', 'f', 'a'], - }) - }) - }) - - describe('with $ref', () => { - const doc = { - components: { - schemas: { - Base: { - type: 'object', - properties: { - id: { type: 'string' }, - }, - required: ['id'], - }, - Extended: { - allOf: [ - { $ref: '#/components/schemas/Base' }, - { - type: 'object', - properties: { - name: { type: 'string' }, - }, - required: ['name'], - }, - ], - }, - }, - }, - } as any - - it('resolves $ref before simplifying', () => { - expect(simplifyComposedObjectJsonSchemasAndRefs( - { $ref: '#/components/schemas/Extended' }, - doc, - )).toEqual({ - type: 'object', - properties: { - id: { type: 'string' }, - name: { type: 'string' }, - }, - required: ['id', 'name'], - }) - - expect(simplifyComposedObjectJsonSchemasAndRefs({ - allOf: [ - { $ref: '#/components/schemas/Base' }, - { - type: 'object', - properties: { - age: { type: 'number' }, - }, - required: ['age'], - }, - ], - }, doc)).toEqual({ - type: 'object', - properties: { - id: { type: 'string' }, - age: { type: 'number' }, - }, - required: ['id', 'age'], - }) - }) - }) -}) diff --git a/packages/openapi/src/openapi-utils.ts b/packages/openapi/src/openapi-utils.ts deleted file mode 100644 index e0394c824..000000000 --- a/packages/openapi/src/openapi-utils.ts +++ /dev/null @@ -1,316 +0,0 @@ -import type { HTTPMethod, HTTPPath } from '@orpc/client' -import type { OpenAPI } from '@orpc/contract' -import type { FileSchema, JSONSchema, ObjectSchema } from './schema' -import { standardizeHTTPPath } from '@orpc/openapi-client/standard' -import { findDeepMatches, isObject, stringifyJSON, toArray } from '@orpc/shared' -import { expandArrayableSchema, filterSchemaBranches, isAnySchema, isFileSchema, isNeverSchema, isObjectSchema, isPrimitiveSchema } from './schema-utils' - -/** - * @internal - */ -export function toOpenAPIPath(path: HTTPPath): string { - return standardizeHTTPPath(path).replace(/\/\{\+([^}]+)\}/g, '/{$1}') -} - -/** - * @internal - */ -export function toOpenAPIMethod(method: HTTPMethod): Lowercase { - return method.toLocaleLowerCase() as Lowercase -} - -/** - * @internal - */ -export function toOpenAPIContent(schema: JSONSchema): Record { - const content: Record = {} - - const [matches, restSchema] = filterSchemaBranches(schema, isFileSchema) - - for (const file of matches as FileSchema[]) { - content[file.contentMediaType] = { - schema: toOpenAPISchema(file), - } - } - - if (restSchema !== undefined && !isAnySchema(restSchema) && !isNeverSchema(restSchema)) { - content['application/json'] = { - schema: toOpenAPISchema(restSchema), - } - - const isStillHasFileSchema = findDeepMatches(v => isObject(v) && isFileSchema(v), restSchema).values.length > 0 - - if (isStillHasFileSchema) { - content['multipart/form-data'] = { - schema: toOpenAPISchema(restSchema), - } - } - } - - return content -} - -/** - * @internal - */ -export function toOpenAPIEventIteratorContent( - [yieldsRequired, yieldsSchema]: [boolean, JSONSchema], - [returnsRequired, returnsSchema]: [boolean, JSONSchema], -): Record { - return { - 'text/event-stream': { - schema: toOpenAPISchema({ - oneOf: [ - { - type: 'object', - properties: { - event: { const: 'message' }, - data: yieldsSchema, - id: { type: 'string' }, - retry: { type: 'number' }, - }, - required: yieldsRequired ? ['event', 'data'] : ['event'], - }, - { - type: 'object', - properties: { - event: { const: 'done' }, - data: returnsSchema, - id: { type: 'string' }, - retry: { type: 'number' }, - }, - required: returnsRequired ? ['event', 'data'] : ['event'], - }, - { - type: 'object', - properties: { - event: { const: 'error' }, - data: {}, - id: { type: 'string' }, - retry: { type: 'number' }, - }, - required: ['event'], - }, - ], - }), - }, - } -} - -/** - * @internal - */ -export function toOpenAPIParameters(schema: ObjectSchema, parameterIn: 'path' | 'query' | 'header' | 'cookie'): OpenAPI.ParameterObject[] { - const parameters: OpenAPI.ParameterObject[] = [] - - for (const key in schema.properties) { - const keySchema = schema.properties[key]! - - let isDeepObjectStyle = true - - if (parameterIn !== 'query') { - isDeepObjectStyle = false - } - else if (isPrimitiveSchema(keySchema)) { - isDeepObjectStyle = false - } - else { - const [item] = expandArrayableSchema(keySchema) ?? [] - - if (item !== undefined && isPrimitiveSchema(item)) { - isDeepObjectStyle = false - } - } - - parameters.push({ - name: key, - in: parameterIn, - required: schema.required?.includes(key), - schema: toOpenAPISchema(keySchema) as any, - style: isDeepObjectStyle ? 'deepObject' : undefined, - explode: isDeepObjectStyle ? true : undefined, - allowEmptyValue: parameterIn === 'query' ? true : undefined, - allowReserved: parameterIn === 'query' ? true : undefined, - }) - } - - return parameters -} - -/** - * @internal - */ -export function checkParamsSchema(schema: ObjectSchema, params: string[]): boolean { - const properties = Object.keys(schema.properties ?? {}) - const required = schema.required ?? [] - - if (properties.length !== params.length || properties.some(v => !params.includes(v))) { - return false - } - - if (required.length !== params.length || required.some(v => !params.includes(v))) { - return false - } - - return true -} - -/** - * @internal - */ -export function toOpenAPISchema(schema: JSONSchema): OpenAPI.SchemaObject & object { - return schema === true - ? {} - : schema === false - ? { not: {} } - : schema as OpenAPI.SchemaObject -} - -const OPENAPI_JSON_SCHEMA_REF_PREFIX = /* @__PURE__ */ '#/components/schemas/' - -export function resolveOpenAPIJsonSchemaRef(doc: OpenAPI.Document, schema: JSONSchema): JSONSchema { - if (typeof schema !== 'object' || !schema.$ref?.startsWith(OPENAPI_JSON_SCHEMA_REF_PREFIX)) { - return schema - } - - const name = schema.$ref.slice(OPENAPI_JSON_SCHEMA_REF_PREFIX.length) - const resolved = doc.components?.schemas?.[name] - return resolved as JSONSchema ?? schema -} - -/** - * Simplifies composed object JSON Schemas (using anyOf, oneOf, allOf) by flattening nested compositions - * - * @warning The result is looser than the original schema and may not fully validate the same data. - */ -export function simplifyComposedObjectJsonSchemasAndRefs(schema: JSONSchema, doc?: OpenAPI.Document): JSONSchema { - if (doc) { - schema = resolveOpenAPIJsonSchemaRef(doc, schema) - } - - if (typeof schema !== 'object' || (!schema.anyOf && !schema.oneOf && !schema.allOf)) { - return schema - } - - const unionSchemas = [ - ...toArray(schema.anyOf?.map(s => simplifyComposedObjectJsonSchemasAndRefs(s, doc))), - ...toArray(schema.oneOf?.map(s => simplifyComposedObjectJsonSchemasAndRefs(s, doc))), - ] - const objectUnionSchemas: ObjectSchema[] = [] - for (const u of unionSchemas) { - if (!isObjectSchema(u)) { - return schema - } - - objectUnionSchemas.push(u) - } - - const mergedUnionPropertyMap: Map = new Map() - for (const u of objectUnionSchemas) { - if (u.properties) { - for (const [key, value] of Object.entries(u.properties)) { - let entry = mergedUnionPropertyMap.get(key) - if (!entry) { - const required = objectUnionSchemas.every(s => s.required?.includes(key)) - - entry = { required, schemas: [] } - mergedUnionPropertyMap.set(key, entry) - } - entry.schemas.push(value) - } - } - } - - const intersectionSchemas = toArray(schema.allOf?.map(s => simplifyComposedObjectJsonSchemasAndRefs(s, doc))) - const objectIntersectionSchemas: ObjectSchema[] = [] - for (const u of intersectionSchemas) { - if (!isObjectSchema(u)) { - return schema - } - - objectIntersectionSchemas.push(u) - } - - // if object schema in the same level with anyOf/oneOf/allOf - if (isObjectSchema(schema)) { - objectIntersectionSchemas.push(schema) - } - - const mergedInteractionPropertyMap: Map = new Map() - for (const u of objectIntersectionSchemas) { - if (u.properties) { - for (const [key, value] of Object.entries(u.properties)) { - let entry = mergedInteractionPropertyMap.get(key) - if (!entry) { - const required = objectIntersectionSchemas.some(s => s.required?.includes(key)) - - entry = { required, schemas: [] } - mergedInteractionPropertyMap.set(key, entry) - } - - entry.schemas.push(value) - } - } - } - - const resultObjectSchema: { type: 'object', properties: Record, required: string[] } = { type: 'object', properties: {}, required: [] } - const keys = new Set([ - ...mergedUnionPropertyMap.keys(), - ...mergedInteractionPropertyMap.keys(), - ]) - if (keys.size === 0) { - return schema - } - - const deduplicateSchemas = (schemas: JSONSchema[]): JSONSchema[] => { - const seen = new Set() - const result: JSONSchema[] = [] - for (const schema of schemas) { - const key = stringifyJSON(schema) - if (!seen.has(key)) { - seen.add(key) - result.push(schema) - } - } - return result - } - - for (const key of keys) { - const unionEntry = mergedUnionPropertyMap.get(key) - const intersectionEntry = mergedInteractionPropertyMap.get(key) - - resultObjectSchema.properties[key] = (() => { - const dedupedUnionSchemas = unionEntry ? deduplicateSchemas(unionEntry.schemas) : [] - const dedupedIntersectionSchemas = intersectionEntry ? deduplicateSchemas(intersectionEntry.schemas) : [] - - if (!dedupedUnionSchemas.length) { - return dedupedIntersectionSchemas.length === 1 - ? dedupedIntersectionSchemas[0]! - : { allOf: dedupedIntersectionSchemas } - } - - if (!dedupedIntersectionSchemas.length) { - return dedupedUnionSchemas.length === 1 - ? dedupedUnionSchemas[0]! - : { anyOf: dedupedUnionSchemas } - } - - const allOf = deduplicateSchemas([ - ...dedupedIntersectionSchemas, - dedupedUnionSchemas.length === 1 - ? dedupedUnionSchemas[0]! - : { anyOf: dedupedUnionSchemas }, - ]) - - return allOf.length === 1 - ? allOf[0]! - : { allOf } - })() - - if (unionEntry?.required || intersectionEntry?.required) { - resultObjectSchema.required.push(key) - } - } - - return resultObjectSchema -} diff --git a/packages/openapi/src/plugins/index.test.ts b/packages/openapi/src/plugins/index.test.ts new file mode 100644 index 000000000..dd58851ae --- /dev/null +++ b/packages/openapi/src/plugins/index.test.ts @@ -0,0 +1,5 @@ +it('exports OpenAPIReferenceHandlerPlugin', async () => { + await expect(import('.')).resolves.toMatchObject({ + OpenAPIReferenceHandlerPlugin: expect.any(Function), + }) +}) diff --git a/packages/openapi/src/plugins/openapi-reference.test.ts b/packages/openapi/src/plugins/openapi-reference.test.ts index e1fa0dcf2..7df899ace 100644 --- a/packages/openapi/src/plugins/openapi-reference.test.ts +++ b/packages/openapi/src/plugins/openapi-reference.test.ts @@ -1,228 +1,274 @@ -import { os } from '@orpc/server' -import * as z from 'zod' -import { ZodToJsonSchemaConverter } from '../../../zod/src' -import { OpenAPIHandler } from '../adapters/fetch/openapi-handler' -import { OpenAPIGenerator } from '../openapi-generator' -import { OpenAPIReferencePlugin } from './openapi-reference' - -describe('openAPIReferencePlugin', () => { - const jsonSchemaConverter = new ZodToJsonSchemaConverter() - const generator = new OpenAPIGenerator({ - schemaConverters: [jsonSchemaConverter], - }) - const router = { ping: os.input(z.object({ name: z.string() })).handler(() => 'pong') } - - it('serve docs and spec endpoints', async () => { - const handler = new OpenAPIHandler(router, { - plugins: [ - new OpenAPIReferencePlugin({ - schemaConverters: [jsonSchemaConverter], - }), - ], - }) +import type { OpenAPIDocument } from '../types' +import { OpenAPIReferenceHandlerPlugin } from './openapi-reference' - const { response } = await handler.handle(new Request('http://localhost:3000')) +describe('openAPIReferenceHandlerPlugin', () => { + beforeEach(() => { + vi.clearAllMocks() + }) - expect(response!.status).toBe(200) - expect(response!.headers.get('content-type')).toBe('text/html') - expect(await response!.text()).toContain('API Reference') + function createSpec(title = 'Example API'): OpenAPIDocument { + return { + openapi: '3.1.0', + info: { + title, + version: '1.0.0', + }, + paths: {}, + } as OpenAPIDocument + } + + function getInterceptor( + plugin: OpenAPIReferenceHandlerPlugin, + options: Record = {}, + ) { + const initialized = plugin.init(options as any) + const interceptor = initialized.routingInterceptors?.at(-1) + + expect(interceptor).toBeDefined() + + return { + initialized, + interceptor: interceptor!, + } + } + + async function invoke( + interceptor: ReturnType['interceptor'], + { + url, + method = 'GET', + prefix, + nextResult = { matched: false as const }, + }: { + url: `/${string}` + method?: string + prefix?: `/${string}` + nextResult?: any + }, + ) { + const next = vi.fn().mockResolvedValue(nextResult) + + const result = await interceptor({ + next, + context: {}, + prefix, + request: { + method, + url, + headers: {}, + signal: undefined, + }, + } as any) + + return { next, result } + } + + it('preserves existing routing interceptors and returns the matched result from next', async () => { + const spec = vi.fn().mockResolvedValue(createSpec()) + const existing = vi.fn(({ next }) => next()) + const plugin = new OpenAPIReferenceHandlerPlugin({ spec }) + const { initialized, interceptor } = getInterceptor(plugin, { + routingInterceptors: [existing], + }) + const nextResult = { + matched: true as const, + response: { status: 204, headers: {}, body: 'handled' }, + } - const { response: specResponse } = await handler.handle(new Request('http://localhost:3000/spec.json')) + expect(initialized.routingInterceptors).toHaveLength(2) + expect(initialized.routingInterceptors?.[0]).toBe(existing) - expect(specResponse!.status).toBe(200) - expect(specResponse!.headers.get('content-type')).toBe('application/json') - expect(await specResponse!.json()).toEqual({ - ...await generator.generate(router), - servers: [{ url: 'http://localhost:3000/' }], + const { next, result } = await invoke(interceptor, { + url: '/', + nextResult, }) - expect( - await handler.handle(new Request('http://localhost:3000/not_found')), - ).toEqual({ matched: false }) + expect(next).toHaveBeenCalledOnce() + expect(result).toBe(nextResult) + expect(spec).not.toHaveBeenCalled() }) - it('serve docs and spec endpoints with prefix', async () => { - const handler = new OpenAPIHandler(router, { - plugins: [ - new OpenAPIReferencePlugin({ - schemaConverters: [jsonSchemaConverter], - }), - ], - }) + it('returns the unmatched result when neither docs nor spec path matches', async () => { + const spec = vi.fn().mockResolvedValue(createSpec()) + const plugin = new OpenAPIReferenceHandlerPlugin({ spec }) + const { interceptor } = getInterceptor(plugin) + const nextResult = { matched: false as const } - const { response } = await handler.handle(new Request('http://localhost:3000/api'), { - prefix: '/api', + const { result } = await invoke(interceptor, { + url: '/not-found', + nextResult, }) - expect(response!.status).toBe(200) - expect(response!.headers.get('content-type')).toBe('text/html') - expect(await response!.text()).toContain('API Reference') + expect(result).toBe(nextResult) + expect(spec).not.toHaveBeenCalled() + }) - const { response: specResponse } = await handler.handle(new Request('http://localhost:3000/api/spec.json'), { - prefix: '/api', - }) + it('returns the unmatched result for non-GET requests', async () => { + const spec = vi.fn().mockResolvedValue(createSpec()) + const plugin = new OpenAPIReferenceHandlerPlugin({ spec }) + const { interceptor } = getInterceptor(plugin) + const nextResult = { matched: false as const } - expect(specResponse!.status).toBe(200) - expect(specResponse!.headers.get('content-type')).toBe('application/json') - expect(await specResponse!.json()).toEqual({ - ...await generator.generate(router), - servers: [{ url: 'http://localhost:3000/api' }], + const { result } = await invoke(interceptor, { + method: 'POST', + url: '/spec.json', + nextResult, }) - expect( - await handler.handle(new Request('http://localhost:3000'), { - prefix: '/api', - }), - ).toEqual({ matched: false }) - - expect( - await handler.handle(new Request('http://localhost:3000/spec.json'), { - prefix: '/api', - }), - ).toEqual({ matched: false }) - - expect( - await handler.handle(new Request('http://localhost:3000/api/not_found'), { - prefix: '/api', - }), - ).toEqual({ matched: false }) + expect(result).toBe(nextResult) + expect(spec).not.toHaveBeenCalled() }) - it('not serve docs and spec endpoints if procedure matched', async () => { - const router = { - ping: os.route({ method: 'GET', path: '/' }).handler(() => 'pong'), - pong: os.route({ method: 'GET', path: '/spec.json' }).handler(() => 'ping'), - } - - const handler = new OpenAPIHandler(router, { - plugins: [ - new OpenAPIReferencePlugin({ - schemaConverters: [jsonSchemaConverter], - }), - ], + it('serves the OpenAPI spec file from a custom spec path with a runtime prefix', async () => { + const specDocument = createSpec('Generated API') + const spec = vi.fn().mockResolvedValue(specDocument) + const plugin = new OpenAPIReferenceHandlerPlugin({ + spec, + specPath: '/openapi.json', + docsPath: '/docs', }) + const { interceptor } = getInterceptor(plugin) - const { response } = await handler.handle(new Request('http://localhost:3000')) - expect(await response!.json()).toEqual('pong') - - const { response: specResponse } = await handler.handle(new Request('http://localhost:3000/spec.json')) - expect(await specResponse!.json()).toEqual('ping') + const { result } = await invoke(interceptor, { + url: '/gateway/openapi.json', + prefix: '/gateway', + }) - const { matched } = await handler.handle(new Request('http://localhost:3000/not_found')) - expect(matched).toBe(false) + expect(spec).toHaveBeenCalledOnce() + expect(spec).toHaveBeenCalledWith(expect.objectContaining({ + prefix: '/gateway', + request: expect.objectContaining({ url: '/gateway/openapi.json' }), + })) + expect(result.matched).toBe(true) + expect(result.response?.status).toBe(200) + expect(result.response?.headers).toEqual({}) + expect(result.response?.body).toBeInstanceOf(Blob) + expect((result.response?.body as Blob).type).toBe('application/json') + await expect((result.response?.body as Blob).text()).resolves.toBe(JSON.stringify(specDocument)) }) - it('with config', async () => { - const handler = new OpenAPIHandler(router, { - plugins: [ - new OpenAPIReferencePlugin({ - schemaConverters: [jsonSchemaConverter], - docsConfig: async () => ({ foo: '__SOME_VALUE__' }), - }), - ], + it('renders scalar docs with default URLs, no stylesheet link, and the spec title fallback', async () => { + const plugin = new OpenAPIReferenceHandlerPlugin({ + spec: createSpec('Scalar Default Title'), }) + const { interceptor } = getInterceptor(plugin) - const { response } = await handler.handle(new Request('http://localhost:3000')) - - expect(response!.status).toBe(200) - expect(response!.headers.get('content-type')).toBe('text/html') - expect(await response!.text()).toContain('__SOME_VALUE__') - }) - - it('should serve swagger UI when docsProvider is swagger', async () => { - const handler = new OpenAPIHandler(router, { - plugins: [ - new OpenAPIReferencePlugin({ - schemaConverters: [jsonSchemaConverter], - docsProvider: 'swagger', - }), - ], + const { result } = await invoke(interceptor, { + url: '/', }) - const { response } = await handler.handle(new Request('http://localhost:3000')) + expect(result.matched).toBe(true) + expect(result.response?.status).toBe(200) + expect(result.response?.headers).toEqual({ + 'content-disposition': [], + }) + expect((result.response?.body as Blob).type).toBe('text/html') - expect(response!.status).toBe(200) - expect(response!.headers.get('content-type')).toBe('text/html') + const html = await (result.response?.body as Blob).text() - const html = await response!.text() - expect(html).toContain('API Reference') - expect(html).toContain('swagger-ui-dist') - expect(html).toContain('swagger-ui.css') - expect(html).toContain('SwaggerUIBundle') - expect(html).not.toContain('Scalar') + expect(html).toContain('Scalar Default Title') + expect(html).toContain('https://cdn.jsdelivr.net/npm/@scalar/api-reference') + expect(html).toContain('Scalar.createApiReference(\'#app\', scalarConfig)') + expect(html).toContain('const scalarConfig =') + expect(html).not.toContain('rel="stylesheet"') + expect(html).not.toContain('undefined') }) - it('should serve scalar UI when docsProvider is scalar (default)', async () => { - const handler = new OpenAPIHandler(router, { - plugins: [ - new OpenAPIReferencePlugin({ - schemaConverters: [jsonSchemaConverter], - docsProvider: 'scalar', - }), - ], + it('renders scalar docs with custom title, head, URLs, stylesheet, and escaped config values', async () => { + const plugin = new OpenAPIReferenceHandlerPlugin({ + spec: createSpec('Scalar Custom'), + provider: 'scalar', + docsPath: '/docs', + docsTitle: async () => 'Scalar & "Docs" ', + docsHead: async () => '', + providerScriptUrl: 'https://cdn.example.com/scalar.js?foo=1&bar=', + providerCssUrl: 'https://cdn.example.com/scalar.css?foo=1&bar=', + providerConfig: { + pageTitle: '&\'<>/', + } as any, }) + const { interceptor } = getInterceptor(plugin) - const { response } = await handler.handle(new Request('http://localhost:3000')) - - expect(response!.status).toBe(200) - expect(response!.headers.get('content-type')).toBe('text/html') + const { result } = await invoke(interceptor, { + url: '/gateway/docs', + prefix: '/gateway', + }) - const html = await response!.text() - expect(html).toContain('API Reference') - expect(html).toContain('@scalar/api-reference') - expect(html).toContain('Scalar.createApiReference') - expect(html).not.toContain('SwaggerUIBundle') + const html = await (result.response?.body as File).text() + + expect(html).toContain('Scalar & "Docs" <Guide>') + expect(html).toContain('') + expect(html).toContain('') + expect(html).toContain('') + expect(html).toContain('pageTitle') + expect(html).toContain('\\u0026') + expect(html).toContain('\\u0027') + expect(html).toContain('\\u003C') + expect(html).toContain('\\u003E') + expect(html).toContain('\\u002F') }) - it('should use custom docsScriptUrl and docsCssUrl for swagger', async () => { - const customScriptUrl = 'https://custom.example.com/swagger-ui-bundle.js' - const customCssUrl = 'https://custom.example.com/swagger-ui.css' - - const handler = new OpenAPIHandler(router, { - plugins: [ - new OpenAPIReferencePlugin({ - schemaConverters: [jsonSchemaConverter], - docsProvider: 'swagger', - docsScriptUrl: customScriptUrl, - docsCssUrl: customCssUrl, - }), - ], + it('renders swagger docs with default asset URLs and unquoted bundle references', async () => { + const plugin = new OpenAPIReferenceHandlerPlugin({ + spec: createSpec('Swagger Default Title'), + provider: 'swagger', }) + const { interceptor } = getInterceptor(plugin) - const { response } = await handler.handle(new Request('http://localhost:3000')) - - expect(response!.status).toBe(200) - expect(response!.headers.get('content-type')).toBe('text/html') + const { result } = await invoke(interceptor, { + url: '/', + }) - const html = await response!.text() - expect(html).toContain(customScriptUrl) - expect(html).toContain(customCssUrl) + const html = await (result.response?.body as File).text() + + expect(html).toContain('Swagger Default Title') + expect(html).toContain('https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js') + expect(html).toContain('https://unpkg.com/swagger-ui-dist/swagger-ui.css') + expect(html).toContain('const swaggerConfig =') + expect(html).toContain('SwaggerUIBundle.presets.apis') + expect(html).toContain('SwaggerUIBundle.plugins.DownloadUrl') + expect(html).not.toContain('"SwaggerUIBundle.presets.apis"') + expect(html).not.toContain('"SwaggerUIBundle.plugins.DownloadUrl"') + expect(html).toContain('window.ui = SwaggerUIBundle(swaggerConfig)') }) - it('should work with swagger UI config', async () => { - const handler = new OpenAPIHandler(router, { - plugins: [ - new OpenAPIReferencePlugin({ - schemaConverters: [jsonSchemaConverter], - docsProvider: 'swagger', - docsConfig: async () => ({ - tryItOutEnabled: true, - customOption: '__SWAGGER_CONFIG__', - }), - }), - ], + it('renders swagger docs with custom title, head, asset URLs, and escaped provider config', async () => { + const plugin = new OpenAPIReferenceHandlerPlugin({ + spec: createSpec('Swagger Custom'), + provider: 'swagger', + docsPath: '/reference', + docsTitle: async () => 'Swagger & "Docs" ', + docsHead: async () => '', + providerScriptUrl: 'https://cdn.example.com/swagger.js?foo=1&bar=', + providerCssUrl: 'https://cdn.example.com/swagger.css?foo=1&bar=', + providerConfig: { + tryItOutEnabled: true, + customOption: '&\'<>/', + presets: ['SwaggerUIBundle.presets.apis'], + plugins: ['SwaggerUIBundle.plugins.DownloadUrl'], + } as any, }) + const { interceptor } = getInterceptor(plugin) - const { response } = await handler.handle(new Request('http://localhost:3000')) + const { result } = await invoke(interceptor, { + url: '/api/reference?view=full', + prefix: '/api', + }) - expect(response!.status).toBe(200) - expect(response!.headers.get('content-type')).toBe('text/html') + const html = await (result.response?.body as File).text() - const html = await response!.text() - expect(html).toContain('swagger-ui-bundle.js') - expect(html).toContain('swagger-ui.css') - expect(html).toContain('SwaggerUIBundle') - expect(html).toContain('__SWAGGER_CONFIG__') + expect(html).toContain('Swagger & "Docs" <Guide>') + expect(html).toContain('') + expect(html).toContain('') + expect(html).toContain('') expect(html).toContain('tryItOutEnabled') + expect(html).toContain('customOption') + expect(html).toContain('\\u0026') + expect(html).toContain('\\u0027') + expect(html).toContain('\\u003C') + expect(html).toContain('\\u003E') + expect(html).toContain('\\u002F') + expect(html).not.toContain('"SwaggerUIBundle.presets.apis"') + expect(html).not.toContain('"SwaggerUIBundle.plugins.DownloadUrl"') }) }) diff --git a/packages/openapi/src/plugins/openapi-reference.ts b/packages/openapi/src/plugins/openapi-reference.ts index ec076b231..eaad9be3e 100644 --- a/packages/openapi/src/plugins/openapi-reference.ts +++ b/packages/openapi/src/plugins/openapi-reference.ts @@ -1,281 +1,285 @@ -import type { OpenAPI } from '@orpc/contract' -import type { Context, HTTPPath, Router } from '@orpc/server' -import type { StandardHandlerInterceptorOptions, StandardHandlerOptions, StandardHandlerPlugin } from '@orpc/server/standard' +import type { Context } from '@orpc/server' +import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptorOptions } from '@orpc/server/standard' import type { Promisable, Value } from '@orpc/shared' -import type { OpenAPIGeneratorGenerateOptions, OpenAPIGeneratorOptions } from '../openapi-generator' -import { once, stringifyJSON, value } from '@orpc/shared' -import { OpenAPIGenerator } from '../openapi-generator' +import type { ApiReferenceConfiguration as ScalarProviderConfig } from '@scalar/api-reference' +import type { StandardUrl } from '@standardserver/core' +import type { SwaggerUIOptions } from 'swagger-ui' +import type { OpenAPIDocument } from '../types' +import { getOpenTelemetryConfig, matchesHttpPath, mergeHttpPath, stringifyJSON, toArray, value } from '@orpc/shared' -export interface OpenAPIReferencePluginOptions extends OpenAPIGeneratorOptions { +export type OpenAPIReferenceHandlerPluginProvider = 'scalar' | 'swagger' + +export interface OpenAPIReferenceHandlerPluginScalarConfig extends Partial { +} + +export interface OpenAPIReferenceHandlerPluginSwaggerConfig extends Partial> { + dom_id?: undefined | never + presets?: undefined | `SwaggerUIBundle.${string}`[] + plugins?: undefined | `SwaggerUIBundle.${string}`[] +} + +export interface OpenAPIReferenceHandlerPluginOptions { /** - * Options to pass to the OpenAPI generate. - * + * A static or dynamic OpenAPI document to serve. + * Receives routing interceptor options when provided as a function. */ - specGenerateOptions?: Value, [StandardHandlerInterceptorOptions]> + spec: Value, [StandardHandlerRoutingInterceptorOptions]> /** * The URL path at which to serve the OpenAPI JSON. * * @default '/spec.json' */ - specPath?: HTTPPath + specPath?: StandardUrl /** - * The URL path at which to serve the API reference UI. + * The UI provider to use for rendering the API reference. * - * @default '/' + * @default 'scalar' */ - docsPath?: HTTPPath + provider?: TProvider /** - * The document title for the API reference UI. - * - * @default 'API Reference' + * Provider-specific configuration passed directly to the chosen UI library. + * Options differ depending on `provider`. */ - docsTitle?: Value, [StandardHandlerInterceptorOptions]> + providerConfig?: undefined | ( + TProvider extends 'swagger' + ? OpenAPIReferenceHandlerPluginSwaggerConfig + : OpenAPIReferenceHandlerPluginScalarConfig + ) /** - * The UI library to use for rendering the API reference. + * URL for the provider's main script bundle. * - * @default 'scalar' - */ - docsProvider?: 'scalar' | 'swagger' - - /** - * Arbitrary configuration object for the UI. + * @default 'https://cdn.jsdelivr.net/npm/@scalar/api-reference' | 'https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js' */ - docsConfig?: Value>, [StandardHandlerInterceptorOptions]> + providerScriptUrl?: undefined | string /** - * HTML to inject into the of the docs page. + * URL for the provider's stylesheet. * - * @warning This is not escaped special characters, so must be used with caution to avoid XSS vulnerabilities. - * - * @default '' + * @default undefined | 'https://unpkg.com/swagger-ui-dist/swagger-ui.css' */ - docsHead?: Value, [StandardHandlerInterceptorOptions]> + providerCssUrl?: undefined | string /** - * URL of the external script bundle for the reference UI. + * The URL path at which to serve the API reference UI. * - * - For Scalar: defaults to 'https://cdn.jsdelivr.net/npm/@scalar/api-reference' - * - For Swagger UI: defaults to 'https://unpkg.com/swagger-ui-dist@5.17.14/swagger-ui-bundle.js' + * @default '/' */ - docsScriptUrl?: Value, [StandardHandlerInterceptorOptions]> + docsPath?: StandardUrl /** - * URL of the external CSS bundle for the reference UI (used by Swagger UI). + * The document title for the API reference UI. * - * @default 'https://unpkg.com/swagger-ui-dist@5.17.14/swagger-ui.css' (if swagger) + * @default spec.info.title */ - docsCssUrl?: Value, [StandardHandlerInterceptorOptions]> + docsTitle?: Value, [StandardHandlerRoutingInterceptorOptions]> /** - * Override function to generate the full HTML for the docs page. + * Raw HTML to inject into the `` of the API reference page. + * Useful for custom stylesheets, meta tags, or scripts. + * + * @default '' */ - renderDocsHtml?: ( - specUrl: string, - title: string, - head: string, - scriptUrl: string, - config: Record | undefined, - spec: OpenAPI.Document, - docsProvider: 'scalar' | 'swagger', - cssUrl: string | undefined, - ) => string + docsHead?: Value, [StandardHandlerRoutingInterceptorOptions]> } -export class OpenAPIReferencePlugin implements StandardHandlerPlugin { - private readonly generator: OpenAPIGenerator - private readonly specGenerateOptions: OpenAPIReferencePluginOptions['specGenerateOptions'] - private readonly specPath: Exclude['specPath'], undefined> - private readonly docsPath: Exclude['docsPath'], undefined> - private readonly docsTitle: Exclude['docsTitle'], undefined> - private readonly docsHead: Exclude['docsHead'], undefined> - private readonly docsProvider: Exclude['docsProvider'], undefined> - private readonly docsScriptUrl: Exclude['docsScriptUrl'], undefined> - private readonly docsCssUrl: OpenAPIReferencePluginOptions['docsCssUrl'] - private readonly docsConfig: OpenAPIReferencePluginOptions['docsConfig'] - private readonly renderDocsHtml: Exclude['renderDocsHtml'], undefined> - - constructor(options: OpenAPIReferencePluginOptions = {}) { - this.specGenerateOptions = options.specGenerateOptions +export class OpenAPIReferenceHandlerPlugin< + T extends Context, + TProvider extends OpenAPIReferenceHandlerPluginProvider, +> implements StandardHandlerPlugin { + name = '~openapi-reference' + + private readonly spec: OpenAPIReferenceHandlerPluginOptions['spec'] + private readonly specPath: Exclude['specPath'], undefined> + private readonly provider: Exclude['provider'], undefined> + private readonly providerConfig: OpenAPIReferenceHandlerPluginOptions['providerConfig'] + private readonly providerScriptUrl: OpenAPIReferenceHandlerPluginOptions['providerScriptUrl'] + private readonly providerCssUrl: OpenAPIReferenceHandlerPluginOptions['providerCssUrl'] + private readonly docsPath: Exclude['docsPath'], undefined> + private readonly docsTitle: OpenAPIReferenceHandlerPluginOptions['docsTitle'] + private readonly docsHead: Exclude['docsHead'], undefined> + + constructor(options: OpenAPIReferenceHandlerPluginOptions) { + this.spec = options.spec + this.specPath = options.specPath ?? '/spec.json' + this.provider = options.provider ?? 'scalar' as TProvider + this.providerConfig = options.providerConfig + this.providerScriptUrl = options.providerScriptUrl + this.providerCssUrl = options.providerCssUrl + this.docsTitle = options.docsTitle this.docsPath = options.docsPath ?? '/' - this.docsTitle = options.docsTitle ?? 'API Reference' - this.docsConfig = options.docsConfig ?? undefined - this.docsProvider = options.docsProvider ?? 'scalar' - - // Set default script URL based on UI type - this.docsScriptUrl = options.docsScriptUrl ?? ( - this.docsProvider === 'swagger' - ? 'https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js' - : 'https://cdn.jsdelivr.net/npm/@scalar/api-reference' - ) - - // Set CSS URL for Swagger UI - this.docsCssUrl = options.docsCssUrl ?? ( - this.docsProvider === 'swagger' - ? 'https://unpkg.com/swagger-ui-dist/swagger-ui.css' - : undefined - ) - this.docsHead = options.docsHead ?? '' - this.specPath = options.specPath ?? '/spec.json' - this.generator = new OpenAPIGenerator(options) - - /** Escapes a string for safe embedding in an HTML attribute value. */ - const escapeHtmlEntities = (s: string) => s - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>') - - /** - * Serialises a value to JSON safe for HTML embedding (attribute or - - - - - - ` - } - else { - const scalarConfig = { - content: stringifyJSON(spec), - ...config, - } - - body = ` - -
- - - - - - - - ` - } - - return ` - - - - - - ${escapeHtmlEntities(title)} - ${cssUrl ? `` : ''} - ${head} - - ${body} - - ` - }) } - init(options: StandardHandlerOptions, router: Router): void { - options.interceptors ??= [] - - options.interceptors.push(async (options) => { - const res = await options.next() - - if (res.matched || options.request.method !== 'GET') { - return res - } - - const prefix = options.prefix ?? '' - const requestPathname = options.request.url.pathname.replace(/\/$/, '') || '/' - const docsUrl = new URL(`${prefix}${this.docsPath}`.replace(/\/$/, ''), options.request.url.origin) - const specUrl = new URL(`${prefix}${this.specPath}`.replace(/\/$/, ''), options.request.url.origin) - - const generateSpec = once(async () => { - return await this.generator.generate(router, { - servers: [{ url: new URL(prefix, options.request.url.origin).toString() }], - ...await value(this.specGenerateOptions, options), - }) - }) - - if (requestPathname === specUrl.pathname) { - const spec = await generateSpec() - - return { - matched: true, - response: { - status: 200, - headers: {}, - body: new File([stringifyJSON(spec)], 'spec.json', { type: 'application/json' }), - }, - } - } - - if (requestPathname === docsUrl.pathname) { - const html = this.renderDocsHtml( - specUrl.toString(), - await value(this.docsTitle, options), - await value(this.docsHead, options), - await value(this.docsScriptUrl, options), - await value(this.docsConfig, options), - await generateSpec(), - this.docsProvider, - await value(this.docsCssUrl, options), - ) - - return { - matched: true, - response: { - status: 200, - headers: {}, - body: new File([html], 'api-reference.html', { type: 'text/html' }), - }, - } - } - - return res - }) + init(options: StandardHandlerOptions): StandardHandlerOptions { + return { + ...options, + routingInterceptors: [ + // Run after user-provided routing interceptors so they can capture the ui/spec responses + ...toArray(options.routingInterceptors), + async ({ next, ...routingInterceptorOptions }) => { + const result = await next() + + if (result.matched || routingInterceptorOptions.request.method !== 'GET') { + return result + } + + const isSpecPath = matchesHttpPath( + routingInterceptorOptions.request.url, + routingInterceptorOptions.prefix ? mergeHttpPath(routingInterceptorOptions.prefix, this.specPath) : this.specPath, + ) + + const isDocsPath = matchesHttpPath( + routingInterceptorOptions.request.url, + routingInterceptorOptions.prefix ? mergeHttpPath(routingInterceptorOptions.prefix, this.docsPath) : this.docsPath, + ) + + if (!isSpecPath && !isDocsPath) { + return result + } + + const span = getOpenTelemetryConfig()?.trace.getActiveSpan() + const spec = await value(this.spec, routingInterceptorOptions) + + if (isSpecPath) { + span?.updateName(`${routingInterceptorOptions.request.method} ${routingInterceptorOptions.request.url} (openapi spec)`) + + const specFile = new File([stringifyJSON(spec)], `${spec.info.title}.json`, { + type: 'application/json', + }) + return { matched: true, response: { status: 200, headers: {}, body: specFile } } + } + + span?.updateName(`${routingInterceptorOptions.request.method} ${routingInterceptorOptions.request.url} (${this.provider} ui)`) + + const docsTitle = (await value(this.docsTitle, routingInterceptorOptions)) ?? spec.info.title + const docsHead = await value(this.docsHead, routingInterceptorOptions) + let html: string | undefined + + if (this.provider === 'swagger') { + const scriptUrl = this.providerScriptUrl ?? 'https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js' + const cssUrl = this.providerCssUrl ?? 'https://unpkg.com/swagger-ui-dist/swagger-ui.css' + const config = { + dom_id: '#app', + spec, + deepLinking: true, + presets: [ + 'SwaggerUIBundle.presets.apis', + 'SwaggerUIBundle.presets.standalone', + ], + plugins: [ + 'SwaggerUIBundle.plugins.DownloadUrl', + ], + ...this.providerConfig, + } + + html = ` + + + + + + ${escapeHtmlEntities(docsTitle)} + + ${docsHead} + + +
+ + + + + + + + + ` + } + + else { + const scriptUrl = this.providerScriptUrl ?? 'https://cdn.jsdelivr.net/npm/@scalar/api-reference' + const cssUrl = this.providerCssUrl + const config: ScalarProviderConfig = { + content: stringifyJSON(spec), + ...this.providerConfig as any, + } + + html = ` + + + + + + ${escapeHtmlEntities(docsTitle)} + ${cssUrl ? `` : ''} + ${docsHead} + + +
+ + + + + + + + + ` + } + + const htmlBlob = new Blob([html], { + type: 'text/html', + }) + + return { + matched: true, + response: { + status: 200, + headers: { + 'content-disposition': [], // disable auto-gen header + }, + body: htmlBlob, + }, + } + }, + ], + } } } + +/** Escapes a string for safe embedding in an HTML attribute value. */ +function escapeHtmlEntities(s: string) { + return s + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>') +} + +/** + * Serialises a value to JSON safe for HTML embedding (attribute or - - diff --git a/playgrounds/browser-extension/entrypoints/popup/lib/orpc.ts b/playgrounds/browser-extension/entrypoints/popup/lib/orpc.ts deleted file mode 100644 index 890f7dc94..000000000 --- a/playgrounds/browser-extension/entrypoints/popup/lib/orpc.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { router } from '../.../../../background/routers' -import type { RouterClient } from '@orpc/server' -import { createORPCClient } from '@orpc/client' -import { RPCLink } from '@orpc/client/message-port' -import { createTanstackQueryUtils } from '@orpc/tanstack-query' - -const port = browser.runtime.connect() - -const link = new RPCLink({ - port, -}) - -export const client: RouterClient = createORPCClient(link) - -export const orpc = createTanstackQueryUtils(client) diff --git a/playgrounds/browser-extension/entrypoints/popup/main.tsx b/playgrounds/browser-extension/entrypoints/popup/main.tsx deleted file mode 100644 index 75ac29fe1..000000000 --- a/playgrounds/browser-extension/entrypoints/popup/main.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' - -const queryClient = new QueryClient() - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - , -) diff --git a/playgrounds/browser-extension/entrypoints/popup/playground-client.ts b/playgrounds/browser-extension/entrypoints/popup/playground-client.ts deleted file mode 100644 index 707fcf89c..000000000 --- a/playgrounds/browser-extension/entrypoints/popup/playground-client.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { client as orpc } from './lib/orpc' -import { safe } from '@orpc/client' - -const token = await orpc.auth.signin({ - email: 'john@doe.com', - password: '123456', -}) - -const [error, planet, isDefined] = await safe(orpc.planet.update({ id: 1, name: 'Earth', description: 'The planet Earth' })) - -if (error) { - if (isDefined) { - const id = error.data.id - // ^ type-safe - } - - console.log('ERROR', error) -} -else { - console.log('PLANET', planet) -} diff --git a/playgrounds/browser-extension/entrypoints/popup/playground-tanstac-query.ts b/playgrounds/browser-extension/entrypoints/popup/playground-tanstac-query.ts deleted file mode 100644 index 711f5e954..000000000 --- a/playgrounds/browser-extension/entrypoints/popup/playground-tanstac-query.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { orpc } from './lib/orpc' -import { isDefinedError } from '@orpc/client' -import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query' - -const query = useInfiniteQuery( - orpc.planet.list.infiniteOptions({ - input: cursor => ({ cursor }), - getNextPageParam: lastPage => (lastPage.at(-1)?.id ?? -1) + 1, - initialPageParam: 0, - }), -) - -const queryClient = useQueryClient() - -const mutation = useMutation( - orpc.planet.update.mutationOptions({ - onError(error) { - if (isDefinedError(error)) { - const id = error.data.id - // ^ type-safe - } - }, - onSuccess() { - queryClient.invalidateQueries({ - queryKey: orpc.planet.key(), - }) - }, - }), -) diff --git a/playgrounds/browser-extension/package.json b/playgrounds/browser-extension/package.json deleted file mode 100644 index 5e3ab0259..000000000 --- a/playgrounds/browser-extension/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@orpc/browser-extension-playground", - "type": "module", - "version": "1.14.6", - "private": true, - "description": "manifest.json description", - "scripts": { - "dev": "wxt", - "dev:firefox": "wxt -b firefox", - "build": "wxt build", - "build:firefox": "wxt build -b firefox", - "zip": "wxt zip", - "zip:firefox": "wxt zip -b firefox", - "type:check": "tsc --noEmit", - "postinstall": "wxt prepare" - }, - "devDependencies": { - "@orpc/client": "next", - "@orpc/openapi": "next", - "@orpc/react": "next", - "@orpc/server": "next", - "@orpc/tanstack-query": "next", - "@orpc/zod": "next", - "@tanstack/react-query": "^5.90.21", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@wxt-dev/module-react": "^1.2.1", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "typescript": "~5.9.3", - "wxt": "^0.20.18", - "zod": "^4.3.6" - } -} diff --git a/playgrounds/browser-extension/public/icon/128.png b/playgrounds/browser-extension/public/icon/128.png deleted file mode 100644 index 9e35d1307..000000000 Binary files a/playgrounds/browser-extension/public/icon/128.png and /dev/null differ diff --git a/playgrounds/browser-extension/public/icon/16.png b/playgrounds/browser-extension/public/icon/16.png deleted file mode 100644 index cd09f8cfb..000000000 Binary files a/playgrounds/browser-extension/public/icon/16.png and /dev/null differ diff --git a/playgrounds/browser-extension/public/icon/32.png b/playgrounds/browser-extension/public/icon/32.png deleted file mode 100644 index f51ce1b5c..000000000 Binary files a/playgrounds/browser-extension/public/icon/32.png and /dev/null differ diff --git a/playgrounds/browser-extension/public/icon/48.png b/playgrounds/browser-extension/public/icon/48.png deleted file mode 100644 index cb7a4494a..000000000 Binary files a/playgrounds/browser-extension/public/icon/48.png and /dev/null differ diff --git a/playgrounds/browser-extension/public/icon/96.png b/playgrounds/browser-extension/public/icon/96.png deleted file mode 100644 index c28ad52d5..000000000 Binary files a/playgrounds/browser-extension/public/icon/96.png and /dev/null differ diff --git a/playgrounds/browser-extension/public/wxt.svg b/playgrounds/browser-extension/public/wxt.svg deleted file mode 100644 index 0e763206b..000000000 --- a/playgrounds/browser-extension/public/wxt.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/playgrounds/browser-extension/tsconfig.json b/playgrounds/browser-extension/tsconfig.json deleted file mode 100644 index c8d47b3a3..000000000 --- a/playgrounds/browser-extension/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./.wxt/tsconfig.json", - "compilerOptions": { - "jsx": "react-jsx", - "allowImportingTsExtensions": true - } -} diff --git a/playgrounds/browser-extension/wxt.config.ts b/playgrounds/browser-extension/wxt.config.ts deleted file mode 100644 index 2994c6122..000000000 --- a/playgrounds/browser-extension/wxt.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from 'wxt' - -// See https://wxt.dev/api/config.html -export default defineConfig({ - modules: ['@wxt-dev/module-react'], -}) diff --git a/playgrounds/bun-websocket-otel/.gitignore b/playgrounds/bun-websocket-otel/.gitignore deleted file mode 100644 index a14702c40..000000000 --- a/playgrounds/bun-websocket-otel/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -# dependencies (bun install) -node_modules - -# output -out -dist -*.tgz - -# code coverage -coverage -*.lcov - -# logs -logs -_.log -report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# caches -.eslintcache -.cache -*.tsbuildinfo - -# IntelliJ based IDEs -.idea - -# Finder (MacOS) folder config -.DS_Store diff --git a/playgrounds/bun-websocket-otel/README.md b/playgrounds/bun-websocket-otel/README.md deleted file mode 100644 index cd00610b0..000000000 --- a/playgrounds/bun-websocket-otel/README.md +++ /dev/null @@ -1,136 +0,0 @@ -# ORPC Playground - -This playground demonstrates the integration of [oRPC](https://orpc.dev), [Bun WebSocket](https://bun.com/docs/api/websockets), and [OpenTelemetry](https://opentelemetry.io). - -## Getting Started - -First, run the development server: - -```bash -bun otel:run -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -Open [http://localhost:16686/](http://localhost:16686/) to view the OpenTelemetry dashboard. - -## Sponsors - -If you find oRPC valuable and would like to support its development, you can do so here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). - -### 🏆 Platinum Sponsor - - - - - -
ScreenshotOne.com
ScreenshotOne.com
- -### 🥈 Silver Sponsor - - - - - -
村上さん
村上さん
- -### Generous Sponsors - - - - - -
LN Markets
LN Markets
- -### Sponsors - - - - - - - - - - - - - - - - - - - - - - - - -
Reece McDonald
Reece McDonald
nk
nk
supastarter
supastarter
Dexter Miguel
Dexter Miguel
herrfugbaum
herrfugbaum
Ryota Murakami
Ryota Murakami
David Cramer
David Cramer
Valerii Petryniak
Valerii Petryniak
Valerii Strilets
Valerii Strilets
Kyle Mistele
Kyle Mistele
Andrew Peters
Andrew Peters
Ryan Vogel
Ryan Vogel
christ12938
christ12938
Peter Adam
Peter Adam
Ryan Soderberg
Ryan Soderberg
shota
shota
- -### Backers - - - - - - - - - - - - - - - - - - - - - - - - - -
David Walsh
David Walsh
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
Alex
Alex
nattstack
nattstack
Andrey Gubanov
Andrey Gubanov
- -### Past Sponsors - -

- Maxie - Stijn Timmer - あわわわとーにゅ - Zuplo - motopods - Francisco Hermida - Théo LUDWIG - Abhay Ramesh - shr.ink oü - 0x4e32 - Ryuz - happyboy - yicchi - Saksham - Roman Hrynevych - rokitg - Omar Khatib - Yu-Sabo - Bapusaheb Patil - grim - Nelson Lai - Lê Cao Nguyên - Robert Soriano - SKostyukovich - Fabworks - Novak Antonijevic - Laduni Estu Syalwa - Chen, Zhi-Yuan - Illarion Koperski - Anees Iqbal - Sefa Eyeoglu - Adam Tkaczyk - plancraft -

diff --git a/playgrounds/bun-websocket-otel/bun-env.d.ts b/playgrounds/bun-websocket-otel/bun-env.d.ts deleted file mode 100644 index 0c6343ac5..000000000 --- a/playgrounds/bun-websocket-otel/bun-env.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Generated by `bun init` - -declare module '*.svg' { - /** - * A path to the SVG file - */ - const path: `${string}.svg` - export = path -} - -declare module '*.module.css' { - /** - * A record of class names to their corresponding CSS module classes - */ - const classes: { readonly [key: string]: string } - export = classes -} diff --git a/playgrounds/bun-websocket-otel/package.json b/playgrounds/bun-websocket-otel/package.json deleted file mode 100644 index 9ec2562e5..000000000 --- a/playgrounds/bun-websocket-otel/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@orpc/bun-websocket-otel-playground", - "type": "module", - "version": "1.14.6", - "private": true, - "scripts": { - "dev": "bun --hot src/index.tsx", - "build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'", - "start": "NODE_ENV=production bun src/index.tsx", - "type:check": "tsc --noEmit", - "otel:run": "docker run --rm --name jaeger -d -e COLLECTOR_OTLP_ENABLED=true -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one:latest", - "otel:stop": "docker stop jaeger" - }, - "devDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/auto-instrumentations-node": "^0.71.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.213.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/instrumentation-document-load": "^0.58.0", - "@opentelemetry/resources": "^2.6.0", - "@opentelemetry/sdk-node": "^0.213.0", - "@opentelemetry/sdk-trace-node": "^2.6.0", - "@opentelemetry/sdk-trace-web": "^2.6.0", - "@orpc/client": "next", - "@orpc/otel": "next", - "@orpc/server": "next", - "@orpc/tanstack-query": "next", - "@tanstack/react-query": "^5.90.21", - "@types/bun": "latest", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "zod": "^4.3.6" - } -} diff --git a/playgrounds/bun-websocket-otel/src/App.tsx b/playgrounds/bun-websocket-otel/src/App.tsx deleted file mode 100644 index d07e762c6..000000000 --- a/playgrounds/bun-websocket-otel/src/App.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { CreatePlanetMutationForm } from './components/orpc-mutation' -import { ListPlanetsQuery } from './components/orpc-query' -import { SSE } from './components/orpc-sse' - -const queryClient = new QueryClient() - -export function App() { - return ( - -
-

ORPC Playground

- You can visit the - {' '} - Redirect to Scalar API Reference - {' '} - page. -
- -
- -
- -
-
- ) -} - -export default App diff --git a/playgrounds/bun-websocket-otel/src/components/orpc-mutation.tsx b/playgrounds/bun-websocket-otel/src/components/orpc-mutation.tsx deleted file mode 100644 index b841d2815..000000000 --- a/playgrounds/bun-websocket-otel/src/components/orpc-mutation.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { orpc } from '../lib/orpc' -import { useMutation, useQueryClient } from '@tanstack/react-query' - -export function CreatePlanetMutationForm() { - const queryClient = useQueryClient() - - const { mutate } = useMutation( - orpc.planet.create.mutationOptions({ - onSuccess() { - queryClient.invalidateQueries({ - queryKey: orpc.planet.key(), - }) - }, - onError(error) { - console.error(error) - alert(error.message) - }, - }), - ) - - return ( -
-

oRPC and Tanstack Query | Create Planet example

- -
{ - e.preventDefault() - const form = new FormData(e.target as HTMLFormElement) - - const name = form.get('name') as string - const description - = (form.get('description') as string | null) ?? undefined - const image = form.get('image') as File - - mutate({ - name, - description, - image: image.size > 0 ? image : undefined, - }) - }} - > - - - - -
-
diff --git a/playgrounds/svelte-kit/src/routes/orpc-query.svelte b/playgrounds/svelte-kit/src/routes/orpc-query.svelte deleted file mode 100644 index c7a80aade..000000000 --- a/playgrounds/svelte-kit/src/routes/orpc-query.svelte +++ /dev/null @@ -1,57 +0,0 @@ - - -{#if query.status === 'pending'} -

Loading...

-{:else if query.status === 'success'} -
-

oRPC and TanStack Query | List Planets example

- - - - - - - - - - - {#each query.data.pages as page} - {#each page as planet} - - - - - - - {/each} - {/each} - - - - - - -
IDNameDescriptionImage
{planet.id}{planet.name}{planet.description}{planet.imageUrl}
- - -
-
-{:else} -

Something went wrong.

-{/if} diff --git a/playgrounds/svelte-kit/src/routes/orpc-stream.svelte b/playgrounds/svelte-kit/src/routes/orpc-stream.svelte deleted file mode 100644 index d25793d2f..000000000 --- a/playgrounds/svelte-kit/src/routes/orpc-stream.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - -
-

oRPC and Tanstack Query | Event Iterator example

-
-{JSON.stringify(streamed.data, null, 2)}
-  
-
\ No newline at end of file diff --git a/playgrounds/svelte-kit/src/routes/rpc/[...rest]/+server.ts b/playgrounds/svelte-kit/src/routes/rpc/[...rest]/+server.ts deleted file mode 100644 index 84ebb3c08..000000000 --- a/playgrounds/svelte-kit/src/routes/rpc/[...rest]/+server.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { RPCHandler } from '@orpc/server/fetch' -import { router } from '../../../routers' -import { onError } from '@orpc/server' -import type { RequestHandler } from '@sveltejs/kit' -import '../../../polyfill' -import { BatchHandlerPlugin } from '@orpc/server/plugins' - -const handler = new RPCHandler(router, { - interceptors: [ - onError((error) => { - console.error(error) - }), - ], - plugins: [ - new BatchHandlerPlugin(), - ], -}) - -const handle: RequestHandler = async ({ request }) => { - const context = request.headers.get('Authorization') - ? { user: { id: 'test', name: 'John Doe', email: 'john@doe.com' } } - : {} - - const { response } = await handler.handle(request, { - prefix: '/rpc', - context, - }) - - return response ?? new Response('Not Found', { status: 404 }) -} - -export const GET = handle -export const POST = handle -export const PUT = handle -export const PATCH = handle -export const DELETE = handle diff --git a/playgrounds/svelte-kit/src/schemas/auth.ts b/playgrounds/svelte-kit/src/schemas/auth.ts deleted file mode 100644 index 96a5cb07b..000000000 --- a/playgrounds/svelte-kit/src/schemas/auth.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as z from 'zod' - -export const CredentialSchema = z.object({ - email: z.email(), - password: z.string(), -}) - -export const TokenSchema = z.object({ - token: z.string(), -}) diff --git a/playgrounds/svelte-kit/src/schemas/planet.ts b/playgrounds/svelte-kit/src/schemas/planet.ts deleted file mode 100644 index 7e005fb9f..000000000 --- a/playgrounds/svelte-kit/src/schemas/planet.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as z from 'zod' -import { UserSchema } from './user' - -export type NewPlanet = z.infer -export type UpdatePlanet = z.infer -export type Planet = z.infer - -export const NewPlanetSchema = z.object({ - name: z.string(), - description: z.string().optional(), - image: z.file().mime(['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml', 'image/gif']).optional(), -}) - -export const UpdatePlanetSchema = z.object({ - id: z.number().int().min(1), - name: z.string(), - description: z.string().optional(), - image: z.file().mime(['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml', 'image/gif']).optional(), -}) - -export const PlanetSchema = z.object({ - id: z.number().int().min(1), - name: z.string(), - description: z.string().optional(), - imageUrl: z.url().optional(), - creator: UserSchema, -}) diff --git a/playgrounds/svelte-kit/src/schemas/user.ts b/playgrounds/svelte-kit/src/schemas/user.ts deleted file mode 100644 index bbf887e4a..000000000 --- a/playgrounds/svelte-kit/src/schemas/user.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { JSON_SCHEMA_REGISTRY } from '@orpc/zod/zod4' -import * as z from 'zod' - -export type NewUser = z.infer -export type User = z.infer - -export const NewUserSchema = z.object({ - name: z.string(), - email: z.email(), - password: z.string(), -}) - -JSON_SCHEMA_REGISTRY.add(NewUserSchema, { - examples: [ - { - name: 'John Doe', - email: 'john@doe.com', - password: '123456', - }, - ], -}) - -export const UserSchema = z.object({ - id: z.string(), - name: z.string(), - email: z.email(), -}) - -JSON_SCHEMA_REGISTRY.add(UserSchema, { - examples: [ - { - id: '1', - name: 'John Doe', - email: 'john@doe.com', - }, - ], -}) diff --git a/playgrounds/svelte-kit/static/favicon.png b/playgrounds/svelte-kit/static/favicon.png deleted file mode 100644 index 825b9e65a..000000000 Binary files a/playgrounds/svelte-kit/static/favicon.png and /dev/null differ diff --git a/playgrounds/svelte-kit/svelte.config.js b/playgrounds/svelte-kit/svelte.config.js deleted file mode 100644 index e92eb5e12..000000000 --- a/playgrounds/svelte-kit/svelte.config.js +++ /dev/null @@ -1,18 +0,0 @@ -import adapter from '@sveltejs/adapter-auto' -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' - -/** @type {import('@sveltejs/kit').Config} */ -const config = { - // Consult https://svelte.dev/docs/kit/integrations - // for more information about preprocessors - preprocess: vitePreprocess(), - - kit: { - // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. - // If your environment is not supported, or you settled on a specific environment, switch out the adapter. - // See https://svelte.dev/docs/kit/adapters for more information about adapters. - adapter: adapter(), - }, -} - -export default config diff --git a/playgrounds/svelte-kit/tsconfig.json b/playgrounds/svelte-kit/tsconfig.json deleted file mode 100644 index 3638344e1..000000000 --- a/playgrounds/svelte-kit/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "./.svelte-kit/tsconfig.json", - "compilerOptions": { - "moduleResolution": "bundler", - "resolveJsonModule": true, - "allowJs": true, - "checkJs": true, - "strict": true, - "sourceMap": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true - } - // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias - // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files - // - // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes - // from the referenced tsconfig.json - TypeScript does not merge them in -} diff --git a/playgrounds/svelte-kit/vite.config.ts b/playgrounds/svelte-kit/vite.config.ts deleted file mode 100644 index bf00a447e..000000000 --- a/playgrounds/svelte-kit/vite.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { sveltekit } from '@sveltejs/kit/vite' -import { defineConfig } from 'vite' - -export default defineConfig({ - plugins: [sveltekit()], - ssr: { - // Tell Vite not to externalize this package, so it will be processed by Vite. - noExternal: [/^@orpc\/.+/], - }, -}) diff --git a/playgrounds/tanstack-start/.gitignore b/playgrounds/tanstack-start/.gitignore deleted file mode 100644 index be342025d..000000000 --- a/playgrounds/tanstack-start/.gitignore +++ /dev/null @@ -1,22 +0,0 @@ -node_modules -package-lock.json -yarn.lock - -.DS_Store -.cache -.env -.vercel -.output -.vinxi - -/build/ -/api/ -/server/build -/public/build -.vinxi -# Sentry Config File -.env.sentry-build-plugin -/test-results/ -/playwright-report/ -/blob-report/ -/playwright/.cache/ diff --git a/playgrounds/tanstack-start/.vscode/settings.json b/playgrounds/tanstack-start/.vscode/settings.json deleted file mode 100644 index ad92582bd..000000000 --- a/playgrounds/tanstack-start/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "editor.formatOnSave": true -} diff --git a/playgrounds/tanstack-start/README.md b/playgrounds/tanstack-start/README.md deleted file mode 100644 index 147ed1051..000000000 --- a/playgrounds/tanstack-start/README.md +++ /dev/null @@ -1,135 +0,0 @@ -# ORPC Playground - -This is a playground for [oRPC](https://orpc.dev) and [Tanstack Start](https://tanstack.com/start/latest). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -Open [http://localhost:3000/api](http://localhost:3000/api) to see the Scalar API Client. - -## Sponsors - -If you find oRPC valuable and would like to support its development, you can do so here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). - -### 🏆 Platinum Sponsor - - - - - -
ScreenshotOne.com
ScreenshotOne.com
- -### 🥈 Silver Sponsor - - - - - -
村上さん
村上さん
- -### Generous Sponsors - - - - - -
LN Markets
LN Markets
- -### Sponsors - - - - - - - - - - - - - - - - - - - - - - - - -
Reece McDonald
Reece McDonald
nk
nk
supastarter
supastarter
Dexter Miguel
Dexter Miguel
herrfugbaum
herrfugbaum
Ryota Murakami
Ryota Murakami
David Cramer
David Cramer
Valerii Petryniak
Valerii Petryniak
Valerii Strilets
Valerii Strilets
Kyle Mistele
Kyle Mistele
Andrew Peters
Andrew Peters
Ryan Vogel
Ryan Vogel
christ12938
christ12938
Peter Adam
Peter Adam
Ryan Soderberg
Ryan Soderberg
shota
shota
- -### Backers - - - - - - - - - - - - - - - - - - - - - - - - - -
David Walsh
David Walsh
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
Alex
Alex
nattstack
nattstack
Andrey Gubanov
Andrey Gubanov
- -### Past Sponsors - -

- Maxie - Stijn Timmer - あわわわとーにゅ - Zuplo - motopods - Francisco Hermida - Théo LUDWIG - Abhay Ramesh - shr.ink oü - 0x4e32 - Ryuz - happyboy - yicchi - Saksham - Roman Hrynevych - rokitg - Omar Khatib - Yu-Sabo - Bapusaheb Patil - grim - Nelson Lai - Lê Cao Nguyên - Robert Soriano - SKostyukovich - Fabworks - Novak Antonijevic - Laduni Estu Syalwa - Chen, Zhi-Yuan - Illarion Koperski - Anees Iqbal - Sefa Eyeoglu - Adam Tkaczyk - plancraft -

diff --git a/playgrounds/tanstack-start/package.json b/playgrounds/tanstack-start/package.json deleted file mode 100644 index c8f7011fa..000000000 --- a/playgrounds/tanstack-start/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@orpc/tanstack-start-playground", - "type": "module", - "private": true, - "sideEffects": false, - "scripts": { - "dev": "vite dev", - "build": "vite build", - "start": "node .output/server/index.mjs", - "type:check": "tsc --noEmit" - }, - "devDependencies": { - "@orpc/client": "next", - "@orpc/json-schema": "next", - "@orpc/openapi": "next", - "@orpc/react": "next", - "@orpc/server": "next", - "@orpc/tanstack-query": "next", - "@orpc/zod": "next", - "@tanstack/react-query": "^5.90.21", - "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-router": "^1.166.7", - "@tanstack/react-router-devtools": "^1.166.7", - "@tanstack/react-router-ssr-query": "^1.166.7", - "@tanstack/react-start": "^1.166.8", - "@types/node": "^22.19.3", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.4", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "vite": "^7.3.1", - "vite-tsconfig-paths": "^6.1.1", - "zod": "^4.3.6" - } -} diff --git a/playgrounds/tanstack-start/src/components/orpc-mutation.tsx b/playgrounds/tanstack-start/src/components/orpc-mutation.tsx deleted file mode 100644 index 7333447e0..000000000 --- a/playgrounds/tanstack-start/src/components/orpc-mutation.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { orpc } from '~/lib/orpc' -import { useMutation, useQueryClient } from '@tanstack/react-query' - -export function CreatePlanetMutationForm() { - const queryClient = useQueryClient() - - const { mutate } = useMutation( - orpc.planet.create.mutationOptions({ - onSuccess() { - queryClient.invalidateQueries({ - queryKey: orpc.planet.key(), - }) - }, - onError(error) { - console.error(error) - alert(error.message) - }, - }), - ) - - return ( -
-

oRPC and Tanstack Query | Create Planet example

- -
{ - e.preventDefault() - const form = new FormData(e.target as HTMLFormElement) - - const name = form.get('name') as string - const description - = (form.get('description') as string | null) ?? undefined - const image = form.get('image') as File - - mutate({ - name, - description, - image: image.size > 0 ? image : undefined, - }) - }} - > - -